From 0f323299a0f21c6696f69ec110baf934b6527324 Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Mon, 27 Apr 2026 21:17:36 +1000 Subject: [PATCH 1/2] Reset panel state after inactivity timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Panel was holding the previous search, scroll position, and selection across hide/show because PanelController caches the FloatingPanel and SearchViewModel for the app's lifetime. After a long gap that 'resume' behaviour is friction, not a feature. On show, if the panel has been hidden longer than the new panelResetTimeout preference (default 2 minutes), reset the view-model back to a clean state — empty query, results re-queried from scratch, first item selected. Configurable in Settings → Behaviour, with a Never option for anyone who liked the old behaviour. Co-authored-by: Claude Co-authored-by: GitButler --- Sources/Vista/PanelController.swift | 35 ++++++++++++++++--- Sources/Vista/Preferences.swift | 52 +++++++++++++++++++++++++++++ Sources/Vista/SearchViewModel.swift | 13 ++++++++ Sources/Vista/SettingsView.swift | 9 +++++ 4 files changed, 105 insertions(+), 4 deletions(-) diff --git a/Sources/Vista/PanelController.swift b/Sources/Vista/PanelController.swift index 8732dfe..d501284 100644 --- a/Sources/Vista/PanelController.swift +++ b/Sources/Vista/PanelController.swift @@ -11,6 +11,7 @@ import VistaCore public final class PanelController { private var panel: FloatingPanel? + private var viewModel: SearchViewModel? private let store: ScreenshotStore private let thumbnails: ThumbnailCache private let actions: ActionHandlers @@ -21,6 +22,12 @@ public final class PanelController { /// back at the original target rather than at vista itself. private var previousFrontmostApp: NSRunningApplication? + /// Wall-clock time the panel was last hidden. Used to decide whether + /// to reset query/selection on the next show — see `show()`. nil + /// means we've never hidden the panel this session, in which case + /// the view-model is already fresh and there's nothing to reset. + private var lastHiddenAt: Date? + public init( store: ScreenshotStore, thumbnails: ThumbnailCache, @@ -44,7 +51,7 @@ public final class PanelController { /// This is what we bind the global hotkey to. public func toggle() { if let panel, panel.isVisible { - panel.orderOut(nil) + hidePanel() } else { capturePreviousFrontmost() show() @@ -54,12 +61,31 @@ public final class PanelController { public func show() { capturePreviousFrontmost() let panel = ensurePanel() + // If the panel has been out of sight long enough that the + // previous query is unlikely to still be relevant, wipe back to + // a clean state before the window is visible. Decided on each + // show so changing the timeout in Settings takes effect + // immediately. Skip on the very first show of the session + // (lastHiddenAt == nil) — the view-model is already fresh. + if let last = lastHiddenAt, + let timeout = preferences.panelResetTimeout.seconds, + Date().timeIntervalSince(last) >= timeout { + viewModel?.resetState() + } // Apply the latest panel-size preference every show so Appearance // slider changes take effect without needing a relaunch. panel.sizeFraction = preferences.panelSizeFraction panel.show() } + /// Hides the panel and stamps the hide time so the next show can + /// decide whether enough time has elapsed to warrant a state reset. + /// Use this instead of calling `panel.orderOut` directly. + private func hidePanel() { + panel?.orderOut(nil) + lastHiddenAt = Date() + } + /// Records whoever was frontmost before we activate. Skips vista /// itself — if the user opens the panel while it's already visible /// (e.g. via the menu bar) we don't want to overwrite the real @@ -85,10 +111,10 @@ public final class PanelController { /// clipboard copy already happened in ActionHandlers. private func pasteToPreviousFrontmost() { guard let target = previousFrontmostApp else { - panel?.orderOut(nil) + hidePanel() return } - panel?.orderOut(nil) + hidePanel() // Activate after panel hides. A short delay gives AppKit time to // process the orderOut before we ask another app to take focus; // without it the frontmost-change can be dropped on the floor. @@ -117,12 +143,13 @@ public final class PanelController { if let existing = panel { return existing } let viewModel = SearchViewModel(store: store) + self.viewModel = viewModel let content = PanelContentView( model: viewModel, thumbnails: thumbnails, actions: actions, preferences: preferences, - dismiss: { [weak self] in self?.panel?.orderOut(nil) } + dismiss: { [weak self] in self?.hidePanel() } ) // NSHostingView is the bridge between SwiftUI and AppKit — the diff --git a/Sources/Vista/Preferences.swift b/Sources/Vista/Preferences.swift index 05f2274..5926620 100644 --- a/Sources/Vista/Preferences.swift +++ b/Sources/Vista/Preferences.swift @@ -81,6 +81,45 @@ public enum StorageDuration: String, CaseIterable, Identifiable, Sendable, Codab } } +/// How long the panel must be hidden before reopening it resets the +/// search query, scroll position, and selection. "Resume where I left +/// off" only feels right inside a single train-of-thought; come back an +/// hour later and a stale query is friction, not a feature. +public enum PanelResetTimeout: String, CaseIterable, Identifiable, Sendable, Codable { + case never + case thirtySeconds + case oneMinute + case twoMinutes + case fiveMinutes + case tenMinutes + + public var id: String { rawValue } + + public var label: String { + switch self { + case .never: return "Never" + case .thirtySeconds: return "30 seconds" + case .oneMinute: return "1 minute" + case .twoMinutes: return "2 minutes" + case .fiveMinutes: return "5 minutes" + case .tenMinutes: return "10 minutes" + } + } + + /// Hidden-duration threshold above which the panel resets on next + /// show. nil = never reset. + public var seconds: TimeInterval? { + switch self { + case .never: return nil + case .thirtySeconds: return 30 + case .oneMinute: return 60 + case .twoMinutes: return 120 + case .fiveMinutes: return 300 + case .tenMinutes: return 600 + } + } +} + /// A persistent, security-scoped reference to a folder the user added. /// The bookmark is what survives app restarts — re-resolving it yields /// back a URL we're allowed to read even in a hardened-runtime build. @@ -130,6 +169,7 @@ public final class Preferences { static let storageDuration = "vista.storageDuration" static let launchAtLogin = "vista.launchAtLogin" static let watchDefaultFolder = "vista.watchDefaultFolder" + static let panelResetTimeout = "vista.panelResetTimeout" } private let defaults: UserDefaults @@ -230,6 +270,15 @@ public final class Preferences { } } + /// How long the panel can stay hidden before its query/selection are + /// reset on next show. Read on demand by PanelController; no + /// downstream observers need to react, so we don't emit a change. + public var panelResetTimeout: PanelResetTimeout { + didSet { + defaults.set(panelResetTimeout.rawValue, forKey: Key.panelResetTimeout) + } + } + // MARK: - Watched folders (bookmark-backed) /// Folders the indexer will watch. Mutated through the add/remove @@ -366,6 +415,9 @@ public final class Preferences { // "just work" without requiring a folder to be added first. self.watchDefaultFolder = defaults.object(forKey: Key.watchDefaultFolder) as? Bool ?? true + let resetRaw = defaults.string(forKey: Key.panelResetTimeout) ?? PanelResetTimeout.twoMinutes.rawValue + self.panelResetTimeout = PanelResetTimeout(rawValue: resetRaw) ?? .twoMinutes + // Watched folders live in a JSON file so we can store bookmark // data cleanly. Application Support is the right location — // survives upgrades, not subject to cache eviction. diff --git a/Sources/Vista/SearchViewModel.swift b/Sources/Vista/SearchViewModel.swift index 016fb8a..1b35aaa 100644 --- a/Sources/Vista/SearchViewModel.swift +++ b/Sources/Vista/SearchViewModel.swift @@ -39,6 +39,19 @@ public final class SearchViewModel { runQuery(queryText) } + /// Wipes the query, scroll, and selection back to a clean "first + /// open" state. Called by PanelController when the panel has been + /// hidden longer than the user's reset timeout. + /// + /// Setting `queryText` schedules a debounced query, but we also run + /// the empty query synchronously so the very next frame the panel + /// appears on is already showing the fresh, top-of-list state — no + /// flash of stale results. + public func resetState() { + queryText = "" + runQuery("") + } + public var selectedRecord: ScreenshotRecord? { guard results.indices.contains(selectedIndex) else { return nil } return results[selectedIndex] diff --git a/Sources/Vista/SettingsView.swift b/Sources/Vista/SettingsView.swift index 754087a..930efea 100644 --- a/Sources/Vista/SettingsView.swift +++ b/Sources/Vista/SettingsView.swift @@ -176,6 +176,15 @@ private struct BehaviourTab: View { Text("What ⏎ does when a result is selected. Every action is still available via the ⌘K menu.") .font(.caption) .foregroundStyle(.secondary) + + Picker("Reset after", selection: $preferences.panelResetTimeout) { + ForEach(PanelResetTimeout.allCases) { option in + Text(option.label).tag(option) + } + } + Text("Clear the search and jump back to the top when Vista has been hidden this long. Set to Never to keep your last search across sessions.") + .font(.caption) + .foregroundStyle(.secondary) } .formStyle(.grouped) .padding(20) From 3bf89d3d74ec6b17795c618de002c716a10a5945 Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Mon, 27 Apr 2026 22:13:46 +1000 Subject: [PATCH 2/2] Address Copilot review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cover AppKit's hidesOnDeactivate auto-hide path: route every dismissal through a new FloatingPanel.onHide hook so lastHiddenAt is also stamped when the panel hides on focus loss, not just explicit calls. - Cancel the debounced query inside SearchViewModel.resetState() so we don't run store.search twice (sync + 120 ms debounced replay). - Reword the 'Reset after' caption to 'between panel opens' — the previous 'across sessions' wording implied state survived an app relaunch, which it doesn't. Co-authored-by: Claude Co-authored-by: GitButler --- Sources/Vista/FloatingPanel.swift | 15 +++++++++++++++ Sources/Vista/PanelController.swift | 9 +++++---- Sources/Vista/SearchViewModel.swift | 10 ++++++---- Sources/Vista/SettingsView.swift | 2 +- 4 files changed, 27 insertions(+), 9 deletions(-) diff --git a/Sources/Vista/FloatingPanel.swift b/Sources/Vista/FloatingPanel.swift index 0a7b503..6858cf9 100644 --- a/Sources/Vista/FloatingPanel.swift +++ b/Sources/Vista/FloatingPanel.swift @@ -19,6 +19,12 @@ public final class FloatingPanel: NSPanel { /// needing a relaunch. public var sizeFraction: CGFloat = 0.6 + /// Fired after every `orderOut` — including the AppKit-driven path + /// that runs when `hidesOnDeactivate` makes the panel disappear on + /// focus loss. PanelController hooks this to keep `lastHiddenAt` + /// accurate across every dismissal route, not just explicit ones. + public var onHide: (() -> Void)? + public init(contentView: NSView) { // Borderless = no traffic-light buttons, no titlebar strip. The // whole visible surface is our SwiftUI content, which handles its @@ -83,6 +89,15 @@ public final class FloatingPanel: NSPanel { NSApp.activate(ignoringOtherApps: true) } + /// AppKit funnels every dismissal — explicit `orderOut`, hotkey + /// toggle, paste flow, and the `hidesOnDeactivate` auto-hide on + /// focus loss — through this method, so it's the single point we + /// need to fire `onHide` from. + public override func orderOut(_ sender: Any?) { + super.orderOut(sender) + onHide?() + } + /// Chosen by proximity to the cursor — multi-monitor users expect the /// panel to appear on whichever screen they're currently looking at. private static func activeScreen() -> NSScreen { diff --git a/Sources/Vista/PanelController.swift b/Sources/Vista/PanelController.swift index d501284..1f019a0 100644 --- a/Sources/Vista/PanelController.swift +++ b/Sources/Vista/PanelController.swift @@ -78,12 +78,12 @@ public final class PanelController { panel.show() } - /// Hides the panel and stamps the hide time so the next show can - /// decide whether enough time has elapsed to warrant a state reset. - /// Use this instead of calling `panel.orderOut` directly. + /// Hides the panel. The hide-time stamp is set by the FloatingPanel's + /// `onHide` hook (see `ensurePanel`) — that runs for every dismissal + /// path, including AppKit's `hidesOnDeactivate` auto-hide on focus + /// loss, not just calls that go through this method. private func hidePanel() { panel?.orderOut(nil) - lastHiddenAt = Date() } /// Records whoever was frontmost before we activate. Skips vista @@ -157,6 +157,7 @@ public final class PanelController { let host = NSHostingView(rootView: content) host.autoresizingMask = [.width, .height] let panel = FloatingPanel(contentView: host) + panel.onHide = { [weak self] in self?.lastHiddenAt = Date() } self.panel = panel return panel } diff --git a/Sources/Vista/SearchViewModel.swift b/Sources/Vista/SearchViewModel.swift index 1b35aaa..6bbb114 100644 --- a/Sources/Vista/SearchViewModel.swift +++ b/Sources/Vista/SearchViewModel.swift @@ -43,12 +43,14 @@ public final class SearchViewModel { /// open" state. Called by PanelController when the panel has been /// hidden longer than the user's reset timeout. /// - /// Setting `queryText` schedules a debounced query, but we also run - /// the empty query synchronously so the very next frame the panel - /// appears on is already showing the fresh, top-of-list state — no - /// flash of stale results. + /// Setting `queryText` schedules a debounced query, but we cancel + /// it and run the empty query synchronously instead. Without the + /// cancel we'd hit `store.search` twice — once now, once again + /// ~120 ms later — which is noticeable on large indexes. public func resetState() { queryText = "" + debounceTask?.cancel() + debounceTask = nil runQuery("") } diff --git a/Sources/Vista/SettingsView.swift b/Sources/Vista/SettingsView.swift index 930efea..67ca207 100644 --- a/Sources/Vista/SettingsView.swift +++ b/Sources/Vista/SettingsView.swift @@ -182,7 +182,7 @@ private struct BehaviourTab: View { Text(option.label).tag(option) } } - Text("Clear the search and jump back to the top when Vista has been hidden this long. Set to Never to keep your last search across sessions.") + Text("Clear the search and jump back to the top when Vista has been hidden this long. Set to Never to keep your last search between panel opens.") .font(.caption) .foregroundStyle(.secondary) }