Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions Tests/StackNudgePanelCoreTests/ExtensionHostTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,26 @@ final class ExtensionHostTests: XCTestCase {
visible: true, now: now))
}

// A click on a footer hint has to send what the keypress would. resolve()
// reads a row action as belonging to the selected row and a document action
// as belonging to none, so the pairing the footer renders must agree — a
// document action sent with a row id would spawn `--action refresh --row x`
// for a row the action was never about.
func testADocumentActionCarriesNoRowAndARowActionCarriesItsOwn() {
let json = """
{"schema":1,
"rows":[{"id":"h1","title":"A","actions":[{"id":"open","label":"Open","key":"o"}]}],
"actions":[{"id":"refresh","label":"Sync now","key":"r"}]}
"""
guard case .success(let document) = ExtensionDocument.parse(Data(json.utf8)) else {
return XCTFail("fixture didn't parse")
}
XCTAssertEqual(ExtensionHost.resolve(key: "r", in: document, selectedRow: "h1")?.row, nil)
XCTAssertEqual(ExtensionHost.resolve(key: "o", in: document, selectedRow: "h1")?.row, "h1")
// With nothing selected a row action isn't reachable at all.
XCTAssertNil(ExtensionHost.resolve(key: "o", in: document, selectedRow: nil))
}

// MARK: - Opening a tab

func testOpeningATabRefreshesItUnlessTheManifestOptedOut() {
Expand All @@ -407,4 +427,80 @@ final class ExtensionHostTests: XCTestCase {
host.tabAppeared("radar")
XCTAssertEqual(recorder.calls.map(\.id), ["derby"])
}

// The panel is an NSPanel that is ordered out, not torn down, so its SwiftUI
// tree survives being hidden and `onAppear` — the only thing that calls
// tabAppeared — does not fire again when it comes back. Reopening onto a tab
// you were already on is therefore not an "open" as far as the host is
// concerned, and the pane shows whatever it last fetched.
//
// This asks whether the scheduled refresh covers that gap on its own.
// The floor is what makes tabAppeared safe to call from both onAppear and
// the panel becoming visible: toggling the panel must not spawn a script
// faster than polling is allowed to.
func testOpeningATabAgainImmediatelyDoesNotRespawn() {
let recorder = Recorder()
let (host, _, _) = host([manifest("derby")], recorder: recorder)
let now = Date()
host.tabAppeared("derby", now: now)
XCTAssertEqual(recorder.calls.count, 1)

// Within the floor: a second open is the same open.
host.tabAppeared("derby", now: now.addingTimeInterval(1))
XCTAssertEqual(recorder.calls.count, 1)

// Past it: a real reopen, and the pane is refetched.
host.tabAppeared("derby",
now: now.addingTimeInterval(
TimeInterval(ExtensionManifest.minimumIntervalSeconds) + 1))
XCTAssertEqual(recorder.calls.count, 2)
}

// An extension that asks only for onOpen has no schedule to fall back on,
// so showing the panel onto its tab is the only thing that can refresh it.
func testATabWithNoScheduleStillRefreshesWhenThePanelComesBack() {
let recorder = Recorder()
let (host, _, _) = host([manifest("derby", refresh: "{\"onOpen\":true}")],
recorder: recorder)
let now = Date()
host.tabAppeared("derby", now: now)
XCTAssertEqual(recorder.calls.count, 1)

// Hidden for ten minutes. No interval, so no tick will ever help.
var later = now
for _ in 0..<120 {
later = later.addingTimeInterval(5)
host.tick(visibleTab: nil, now: later)
}
XCTAssertEqual(recorder.calls.count, 1)

host.tabAppeared("derby", now: later)
XCTAssertEqual(recorder.calls.count, 2,
"showing the panel is the only refresh this extension gets")
}

func testReopeningOntoATabYouWereAlreadyOnGetsFreshData() {
let recorder = Recorder()
let (host, _, _) = host([manifest("derby", refresh: "{\"intervalSeconds\":30}")],
recorder: recorder,
result: { .transient("stub") })
// Opened once, fetched once.
host.tabAppeared("derby")
XCTAssertEqual(recorder.calls.count, 1)

// Hidden for five minutes: whileFocusedOnly means no polling, by design.
var now = Date()
for _ in 0..<60 {
now = now.addingTimeInterval(5)
host.tick(visibleTab: nil, now: now)
}
XCTAssertEqual(recorder.calls.count, 1, "a hidden pane must not poll")

// Reopened onto the same tab. onAppear does not fire, so the first tick
// after it becomes visible is the only thing that can catch it up.
now = now.addingTimeInterval(5)
host.tick(visibleTab: "derby", now: now)
XCTAssertEqual(recorder.calls.count, 2,
"reopening onto a stale tab must refetch without a keypress")
}
}
18 changes: 17 additions & 1 deletion panel/ExtensionHost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,24 @@ final class ExtensionHost: ObservableObject {

// Opening a tab refreshes it unless the manifest opted out, and unless
// something is already in flight.
func tabAppeared(_ id: String) {
//
// "Opening" includes showing the panel onto a tab you were already on,
// which SwiftUI cannot tell us: the panel is ordered out rather than torn
// down, so its view tree survives being hidden and `onAppear` never fires
// again. Without that call the pane shows whatever it last fetched, and an
// extension declaring `onOpen` with no `intervalSeconds` would stay that
// way until the tab was switched away from and back.
//
// The floor is what makes it safe to call from both places. It is the
// manifest's own minimum poll interval, so toggling the panel cannot spawn
// a script faster than polling is allowed to.
func tabAppeared(_ id: String, now: Date = Date()) {
guard let manifest = manifest(id), manifest.refresh.onOpen else { return }
if let attemptedAt = pane(id).attemptedAt,
now.timeIntervalSince(attemptedAt)
< TimeInterval(ExtensionManifest.minimumIntervalSeconds) {
return
}
refresh(id)
}

Expand Down
24 changes: 20 additions & 4 deletions panel/ExtensionTabView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -326,16 +326,32 @@ struct ExtensionTabView: View {
// Only bound actions get a hint. An action whose key request was
// refused has no shortcut and no button, so advertising it would be
// a lie — see the note on ExtensionKey.
ForEach(hintedActions, id: \.id) { action in
FooterHint(label: action.label, keys: [Self.keyCap(action.key ?? "")])
// Clickable, because it looks clickable. The pane was
// keyboard-only by design and the footer still advertised each
// action with its key cap — which reads as a button, so it gets
// pressed, and nothing happens. A refresh arriving on the poll
// thirty seconds later then looks like the click working.
ForEach(hintedActions, id: \.action.id) { hint in
Button {
host.perform(action: hint.action.id, row: hint.row, on: id)
} label: {
FooterHint(label: hint.action.label,
keys: [Self.keyCap(hint.action.key ?? "")])
}
.buttonStyle(.plain)
.disabled(pane.busy)
}
}
}

private var hintedActions: [ExtensionDocument.Action] {
// Paired with the row each one acts on, so a click sends what the keypress
// would: ExtensionHost.resolve reads a row action as belonging to the
// selected row and a document action as belonging to none.
private var hintedActions: [(action: ExtensionDocument.Action, row: String?)] {
guard let document = pane.document else { return [] }
let rowActions = document.rows.first { $0.id == pane.selectedRow }?.actions ?? []
return (rowActions + document.actions).filter { $0.key != nil }
return rowActions.filter { $0.key != nil }.map { ($0, pane.selectedRow) }
+ document.actions.filter { $0.key != nil }.map { ($0, nil) }
}

static func keyCap(_ key: String) -> String {
Expand Down
11 changes: 11 additions & 0 deletions panel/Panel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4792,12 +4792,23 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
NSApp.activate(ignoringOtherApps: true)
panel.makeKeyAndOrderFront(nil)
}
refreshVisibleExtensionTab()
return
}
positionPanel() // re-resolve in case the user moved to a different display
NSApp.activate(ignoringOtherApps: true)
panel.makeKeyAndOrderFront(nil)
usageSurfaceDidChange()
refreshVisibleExtensionTab()
}

// Showing the panel onto an extension tab is opening that tab, to anyone
// using it. ExtensionTabView.onAppear cannot say so: the panel is ordered
// out rather than torn down, so the view survives being hidden and never
// appears again — leaving the pane on whatever it fetched before.
private func refreshVisibleExtensionTab() {
guard case .extensionTab(let id) = nav.mode else { return }
extensions.tabAppeared(id)
}

// NSApp.hide hides all our windows AND deactivates the app, so the system
Expand Down
Loading