From 329a4f7452ed240c5231432dbdf64ab66f0ddd19 Mon Sep 17 00:00:00 2001 From: dowjames <371653+dowjames@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:09:36 -0400 Subject: [PATCH 1/8] Onboarding: cache folder and code-folder candidate scans OnboardingState.init ran Self.candidates() and CodeFolders.candidates(searched:) synchronously on the main thread while the window was being built (probing up to 500 children per folder for .git). Cache both scans so the filesystem work runs once per process instead of on every window construction. --- Sources/DigUpApp/CodeFolders.swift | 55 +++++++++++++++++------------- Sources/DigUpApp/Onboarding.swift | 27 +++++++++------ 2 files changed, 49 insertions(+), 33 deletions(-) diff --git a/Sources/DigUpApp/CodeFolders.swift b/Sources/DigUpApp/CodeFolders.swift index ad1314d4..89673067 100644 --- a/Sources/DigUpApp/CodeFolders.swift +++ b/Sources/DigUpApp/CodeFolders.swift @@ -7,34 +7,43 @@ enum CodeFolders { /// or `-codeLocations a:b` (tests point it at the testbed; a dev run never offers real folders). Desktop, Documents /// and Downloads are only looked into when they're searched already (macOS would ask first), for the usual names /// people keep repos under there. + /// Cached by the searched-roots key: the scan probes up to 500 children per folder for `.git`, and callers + /// (onboarding, Settings) ask repeatedly while the main thread builds windows. + nonisolated(unsafe) private static var cache: [String: [URL]] = [:] + static func candidates(searched roots: [URL]) -> [URL] { + let key = roots.map(\.path).joined(separator: ":") + if let cached = cache[key] { return cached } + let found: [URL] if let list = UserDefaults.standard.string(forKey: "codeLocations") { - return list.split(separator: ":").map { AppSettings.folderURL(String($0)) } - } - if UserDefaults.standard.string(forKey: "defaultsSuite") != nil { + found = list.split(separator: ":").map { AppSettings.folderURL(String($0)) } + } else if UserDefaults.standard.string(forKey: "defaultsSuite") != nil { log("code: no -codeLocations in a dev run, so no code folders are offered") - return [] - } - let fm = FileManager.default - let home = fm.homeDirectoryForCurrentUser - let asked: Set = ["Desktop", "Documents", "Downloads", "Library", "Pictures", "Movies", "Music", "Public", - "Applications", "Sites"] - var found: [URL] = [] - for name in (try? fm.contentsOfDirectory(atPath: home.path)) ?? [] - where !name.hasPrefix(".") && !asked.contains(name) { - let folder = AppSettings.folderURL(home.appendingPathComponent(name).path) - if AppSettings.isFolder(folder), holdsCode(folder) { found.append(folder) } - } - for parent in ["Documents", "Desktop"] { - let folder = home.appendingPathComponent(parent) - guard roots.contains(where: { $0.path == folder.path || FolderSelection.isInside(folder.path, $0.path) }) - else { continue } - for name in ["GitHub", "Code", "Projects", "Developer", "dev", "src", "repos", "Repositories"] { - let inner = AppSettings.folderURL(folder.appendingPathComponent(name).path) - if AppSettings.isFolder(inner), holdsCode(inner) { found.append(inner) } + found = [] + } else { + let fm = FileManager.default + let home = fm.homeDirectoryForCurrentUser + let asked: Set = ["Desktop", "Documents", "Downloads", "Library", "Pictures", "Movies", "Music", "Public", + "Applications", "Sites"] + var urls: [URL] = [] + for name in (try? fm.contentsOfDirectory(atPath: home.path)) ?? [] + where !name.hasPrefix(".") && !asked.contains(name) { + let folder = AppSettings.folderURL(home.appendingPathComponent(name).path) + if AppSettings.isFolder(folder), holdsCode(folder) { urls.append(folder) } + } + for parent in ["Documents", "Desktop"] { + let folder = home.appendingPathComponent(parent) + guard roots.contains(where: { $0.path == folder.path || FolderSelection.isInside(folder.path, $0.path) }) + else { continue } + for name in ["GitHub", "Code", "Projects", "Developer", "dev", "src", "repos", "Repositories"] { + let inner = AppSettings.folderURL(folder.appendingPathComponent(name).path) + if AppSettings.isFolder(inner), holdsCode(inner) { urls.append(inner) } + } } + found = urls.sorted { $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending } } - return found.sorted { $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending } + cache[key] = found + return found } /// A repo, or a folder with one in it (one level down). diff --git a/Sources/DigUpApp/Onboarding.swift b/Sources/DigUpApp/Onboarding.swift index 548eecb0..42deb1e6 100644 --- a/Sources/DigUpApp/Onboarding.swift +++ b/Sources/DigUpApp/Onboarding.swift @@ -115,20 +115,27 @@ final class OnboardingState { } /// The folders it offers: the usual home folders that exist, or `-onboardingLocations a:b:c` (tests point it at - /// the testbed, never at real folders). + /// the testbed, never at real folders). Cached after the first call: the existence checks are filesystem work, + /// and `init` runs while the window is being built on the main thread. + private static var cachedCandidates: [URL]? + static func candidates() -> [URL] { + if let cached = cachedCandidates { return cached } + let found: [URL] if let list = UserDefaults.standard.string(forKey: "onboardingLocations") { - return list.split(separator: ":").map { AppSettings.folderURL(String($0)) } - } - // A dev or test run (its own defaults suite) never offers, and so never estimates, the real home folders. - if UserDefaults.standard.string(forKey: "defaultsSuite") != nil { + found = list.split(separator: ":").map { AppSettings.folderURL(String($0)) } + } else if UserDefaults.standard.string(forKey: "defaultsSuite") != nil { + // A dev or test run (its own defaults suite) never offers, and so never estimates, the real home folders. log("onboarding: no -onboardingLocations in a dev run, so no folders are offered") - return [] + found = [] + } else { + let home = FileManager.default.homeDirectoryForCurrentUser + found = ["Desktop", "Documents", "Downloads", "Pictures", "Movies", "Music"] + .map { AppSettings.folderURL(home.appendingPathComponent($0).path) } + .filter(AppSettings.isFolder) } - let home = FileManager.default.homeDirectoryForCurrentUser - return ["Desktop", "Documents", "Downloads", "Pictures", "Movies", "Music"] - .map { AppSettings.folderURL(home.appendingPathComponent($0).path) } - .filter(AppSettings.isFolder) + cachedCandidates = found + return found } // MARK: Steps From 05642f80df6f72738d3c1aeb08da82a659fe33b9 Mon Sep 17 00:00:00 2001 From: dowjames <371653+dowjames@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:07:19 -0400 Subject: [PATCH 2/8] Keys: route both search surfaces through KeyCommands (shared normalization, named key codes) --- Sources/DigUpApp/KeyCommands.swift | 41 ++++++++++++++++++++ Sources/DigUpApp/QuickLookController.swift | 45 ++++++++++++++++++++++ Sources/DigUpApp/SearchPanel.swift | 23 ++++++----- Sources/DigUpApp/SearchWindow.swift | 32 ++++++++------- 4 files changed, 112 insertions(+), 29 deletions(-) create mode 100644 Sources/DigUpApp/KeyCommands.swift create mode 100644 Sources/DigUpApp/QuickLookController.swift diff --git a/Sources/DigUpApp/KeyCommands.swift b/Sources/DigUpApp/KeyCommands.swift new file mode 100644 index 00000000..801bcd9f --- /dev/null +++ b/Sources/DigUpApp/KeyCommands.swift @@ -0,0 +1,41 @@ +import AppKit +import Carbon.HIToolbox + +/// The keys both search surfaces share, in one place: the key codes (named, from the same Carbon constants +/// `KeyCombo`'s name↔code table uses) and the normalization both handlers did (the input-method check and the +/// modifier-mask cleanup). Each surface keeps its own extra keys (the panel's ⌘O, the window's ⌘1…⌘7) and its +/// own selection movement, but reads every event through here. +enum KeyCommands { + /// A key press, normalized: the raw code plus the modifiers the user actually held. + struct Press { + let keyCode: Int + let modifiers: NSEvent.ModifierFlags + } + + /// The event as a normalized `Press`, or nil while an input method is composing (Japanese, Chinese…): until + /// it's done, ↩ and the arrows are its keys, not ours. + static func press(_ event: NSEvent, field: NSTextField?) -> Press? { + if (field?.currentEditor() as? NSTextView)?.hasMarkedText() == true { return nil } + return Press(keyCode: Int(event.keyCode), + modifiers: event.modifierFlags.intersection(.deviceIndependentFlagsMask) + .subtracting([.capsLock, .numericPad, .function])) + } + + // The named key codes both handlers switch on. + static let escape = Int(kVK_Escape) + static let enter = Int(kVK_Return) + static let keypadEnter = Int(kVK_ANSI_KeypadEnter) + static let space = Int(kVK_Space) + static let leftArrow = Int(kVK_ANSI_LeftArrow) + static let rightArrow = Int(kVK_ANSI_RightArrow) + static let upArrow = Int(kVK_ANSI_UpArrow) + static let downArrow = Int(kVK_ANSI_DownArrow) + static let c = Int(kVK_ANSI_C) + static let o = Int(kVK_ANSI_O) + static let y = Int(kVK_ANSI_Y) + /// The key codes of 1…6, in order (⌘1…⌘6 pick a result kind) — the same named Carbon constants + /// `KeyCombo`'s name↔code table is built from, not a second copy of the raw numbers. + static let numberKeys: [Int] = [Int(kVK_ANSI_1), Int(kVK_ANSI_2), Int(kVK_ANSI_3), + Int(kVK_ANSI_4), Int(kVK_ANSI_5), Int(kVK_ANSI_6)] + static let seven = Int(kVK_ANSI_7) +} diff --git a/Sources/DigUpApp/QuickLookController.swift b/Sources/DigUpApp/QuickLookController.swift new file mode 100644 index 00000000..1763e444 --- /dev/null +++ b/Sources/DigUpApp/QuickLookController.swift @@ -0,0 +1,45 @@ +import AppKit +import Quartz + +/// A window that can hand the shared Quick Look panel a file (both search windows conform; Quick Look addresses +/// the window itself as the panel's data source). +@MainActor +protocol QuickLookHost: AnyObject { + var previewURL: URL? { get set } +} + +/// The shared Quick Look panel, driven by whichever search surface is showing: one place for the toggle/update +/// logic and the item answers `SearchPanelWindow` and `QuickLookWindow` both duplicated. The windows keep their +/// `QLPreviewPanelDataSource` conformance (Quick Look addresses the window itself) and forward here. +@MainActor +enum QuickLookController { + /// Whether the shared panel exists and is visible. + static var isShowing: Bool { QLPreviewPanel.sharedPreviewPanelExists() && QLPreviewPanel.shared().isVisible } + + /// Space / ⌘Y: hide the panel, or show it with `row` previewed through `host`. + static func toggle(on host: QuickLookHost?, row: ResultRow?) { + guard let host else { return } + if isShowing { + QLPreviewPanel.shared().orderOut(nil) + } else if row != nil { + update(on: host, row: row) + QLPreviewPanel.shared().makeKeyAndOrderFront(nil) + } + } + + /// Point the panel at `row` (its page or moment) and refresh it. + static func update(on host: QuickLookHost?, row: ResultRow?) { + host?.previewURL = row?.url + QLPreviewPanel.shared().reloadData() + QuickLookMoment.show(row, in: QLPreviewPanel.shared()) + } + + /// Close the panel if it's open (both surfaces do this when their window hides or closes). + static func hide() { + if isShowing { QLPreviewPanel.shared().orderOut(nil) } + } + + // The data-source answers both windows shared, in one place. + static func itemCount(for url: URL?) -> Int { url == nil ? 0 : 1 } + static func item(for url: URL?) -> (any QLPreviewItem)? { url as NSURL } +} diff --git a/Sources/DigUpApp/SearchPanel.swift b/Sources/DigUpApp/SearchPanel.swift index ce158d3e..5c474d63 100644 --- a/Sources/DigUpApp/SearchPanel.swift +++ b/Sources/DigUpApp/SearchPanel.swift @@ -296,20 +296,19 @@ final class SearchPanelController { /// True when the key was handled here (it then doesn't reach the search field). private func handle(_ event: NSEvent) -> Bool { // An input method is composing (Japanese, Chinese…): ↩, the arrows and Esc are its keys until it's done. - if (field?.currentEditor() as? NSTextView)?.hasMarkedText() == true { return false } - let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - .subtracting([.capsLock, .numericPad, .function]) + guard let press = KeyCommands.press(event, field: field) else { return false } + let modifiers = press.modifiers let previewing = QLPreviewPanel.sharedPreviewPanelExists() && QLPreviewPanel.shared().isVisible - switch event.keyCode { - case 53: // Esc + switch press.keyCode { + case KeyCommands.escape: // Esc if previewing { QLPreviewPanel.shared().orderOut(nil) } else { dismiss() } return true - case 125, 126: // ↓ ↑ + case KeyCommands.downArrow, KeyCommands.upArrow: // ↓ ↑ guard modifiers.isEmpty else { return false } - moveSelection(by: event.keyCode == 125 ? 1 : -1) + moveSelection(by: press.keyCode == KeyCommands.downArrow ? 1 : -1) navigated = true return true - case 36, 76: // ↩, enter + case KeyCommands.enter, KeyCommands.keypadEnter: // ↩, enter if selection == Self.weakerToggle, modifiers.isEmpty { toggleWeaker() } else if modifiers == .command { @@ -320,16 +319,16 @@ final class SearchPanelController { return false } return true - case 49 where modifiers.isEmpty && (navigated || previewing) && selection != nil: // Space + case KeyCommands.space where modifiers.isEmpty && (navigated || previewing) && selection != nil: // Space if selection == Self.weakerToggle { toggleWeaker() } else { toggleQuickLook() } return true - case 16 where modifiers == .command: // ⌘Y + case KeyCommands.y where modifiers == .command: // ⌘Y toggleQuickLook() return true - case 31 where modifiers == .command: // ⌘O: the same search in the window, for a bigger look + case KeyCommands.o where modifiers == .command: // ⌘O: the same search in the window, for a bigger look openInWindow() return true - case 8 where modifiers == .command: + case KeyCommands.c where modifiers == .command: // ⌘C copies the selected file, except right after you selected some of the query text (the panel opens // with the last query selected, and arrowing to a result means you want the file). let textSelected = (field?.currentEditor()?.selectedRange.length ?? 0) > 0 diff --git a/Sources/DigUpApp/SearchWindow.swift b/Sources/DigUpApp/SearchWindow.swift index 4fc54cc6..6994066a 100644 --- a/Sources/DigUpApp/SearchWindow.swift +++ b/Sources/DigUpApp/SearchWindow.swift @@ -105,18 +105,17 @@ final class SearchWindowController: NSObject, NSWindowDelegate { /// ↩ opens, ⌘↩ reveals, Space previews, ⌘C copies, Esc closes the preview or clears the search. private func handle(_ event: NSEvent) -> Bool { // An input method is composing (Japanese, Chinese…): ↩, the arrows and Esc are its keys until it's done. - if (search.field?.currentEditor() as? NSTextView)?.hasMarkedText() == true { return false } - let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - .subtracting([.capsLock, .numericPad, .function]) + guard let press = KeyCommands.press(event, field: search.field) else { return false } + let modifiers = press.modifiers let previewing = QLPreviewPanel.sharedPreviewPanelExists() && QLPreviewPanel.shared().isVisible - switch event.keyCode { - case 125, 126: // ↓ ↑ + switch press.keyCode { + case KeyCommands.downArrow, KeyCommands.upArrow: // ↓ ↑ guard modifiers.isEmpty else { return false } - search.move(by: event.keyCode == 125 ? search.columns : -search.columns) - case 123, 124: // ← → + search.move(by: press.keyCode == KeyCommands.downArrow ? search.columns : -search.columns) + case KeyCommands.leftArrow, KeyCommands.rightArrow: // ← → guard modifiers.isEmpty, search.navigated || previewing else { return false } - search.move(by: event.keyCode == 124 ? 1 : -1) - case 36, 76: // ↩ + search.move(by: press.keyCode == KeyCommands.rightArrow ? 1 : -1) + case KeyCommands.enter, KeyCommands.keypadEnter: // ↩ guard let row = search.selectedRow else { return false } if modifiers == .command { NSWorkspace.shared.activateFileViewerSelecting([row.url]) @@ -125,21 +124,20 @@ final class SearchWindowController: NSObject, NSWindowDelegate { } else { return false } - case 49 where modifiers.isEmpty && (search.navigated || previewing) && search.selectedRow != nil: // Space + case KeyCommands.space where modifiers.isEmpty && (search.navigated || previewing) && search.selectedRow != nil: // Space toggleQuickLook() - case 16 where modifiers == .command: // ⌘Y + case KeyCommands.y where modifiers == .command: // ⌘Y toggleQuickLook() - case 18...23 where modifiers == .command: // ⌘1 all, ⌘2…⌘6 one kind + case let code where modifiers == .command && KeyCommands.numberKeys.contains(code): // ⌘1 all, ⌘2…⌘6 one kind let kinds: [ResultGroupKind?] = [nil] + ResultGroupKind.files - let index = [18, 19, 20, 21, 23, 22].firstIndex(of: Int(event.keyCode)) ?? 0 // key codes of 1…6 - search.filter = kinds[index] - case 26 where modifiers == .command && app.model.hasCode: // ⌘7: code + search.filter = kinds[KeyCommands.numberKeys.firstIndex(of: code) ?? 0] + case KeyCommands.seven where modifiers == .command && app.model.hasCode: // ⌘7: code search.filter = .code - case 8 where modifiers == .command && search.navigated: // ⌘C + case KeyCommands.c where modifiers == .command && search.navigated: // ⌘C guard let row = search.selectedRow else { return false } NSPasteboard.general.clearContents() NSPasteboard.general.writeObjects([row.url as NSURL]) - case 53: // Esc + case KeyCommands.escape: // Esc if previewing { QLPreviewPanel.shared().orderOut(nil) } else if !search.query.isEmpty { From 5ab4691a3feaed1443e181b8745db9e4040d67f3 Mon Sep 17 00:00:00 2001 From: dowjames <371653+dowjames@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:08:45 -0400 Subject: [PATCH 3/8] Quick Look: drive both surfaces through QuickLookController (shared toggle/update and data-source answers) --- Sources/DigUpApp/SearchPanel.swift | 32 ++++++++++------------------- Sources/DigUpApp/SearchWindow.swift | 30 +++++++++------------------ 2 files changed, 21 insertions(+), 41 deletions(-) diff --git a/Sources/DigUpApp/SearchPanel.swift b/Sources/DigUpApp/SearchPanel.swift index 5c474d63..973f9358 100644 --- a/Sources/DigUpApp/SearchPanel.swift +++ b/Sources/DigUpApp/SearchPanel.swift @@ -79,7 +79,7 @@ final class SearchPanelController { var isVisible: Bool { window?.isVisible == true && !fading } var windowNumber: Int { window?.windowNumber ?? 0 } - var isPreviewing: Bool { QLPreviewPanel.sharedPreviewPanelExists() && QLPreviewPanel.shared().isVisible } + var isPreviewing: Bool { QuickLookController.isShowing } /// The selectable rows in order: strong results group by group, the fold, then the weaker ones if unfolded. var rowIDs: [String] { results.groups.flatMap { $0.rows.map(\.id) } @@ -131,9 +131,7 @@ final class SearchPanelController { } func hide() { - if QLPreviewPanel.sharedPreviewPanelExists(), QLPreviewPanel.shared().isVisible { - QLPreviewPanel.shared().orderOut(nil) - } + QuickLookController.hide() removeKeyMonitor() PreviewPlayback.shared.stop() if let activity { ProcessInfo.processInfo.endActivity(activity) } @@ -230,7 +228,7 @@ final class SearchPanelController { } self.phase = phase resizeWindow() - if QLPreviewPanel.sharedPreviewPanelExists(), QLPreviewPanel.shared().isVisible { updateQuickLook() } + if QuickLookController.isShowing { updateQuickLook() } } func toggleWeaker() { @@ -298,10 +296,10 @@ final class SearchPanelController { // An input method is composing (Japanese, Chinese…): ↩, the arrows and Esc are its keys until it's done. guard let press = KeyCommands.press(event, field: field) else { return false } let modifiers = press.modifiers - let previewing = QLPreviewPanel.sharedPreviewPanelExists() && QLPreviewPanel.shared().isVisible + let previewing = QuickLookController.isShowing switch press.keyCode { case KeyCommands.escape: // Esc - if previewing { QLPreviewPanel.shared().orderOut(nil) } else { dismiss() } + if previewing { QuickLookController.hide() } else { dismiss() } return true case KeyCommands.downArrow, KeyCommands.upArrow: // ↓ ↑ guard modifiers.isEmpty else { return false } @@ -345,7 +343,7 @@ final class SearchPanelController { guard !ids.isEmpty else { return } let index = selection.flatMap { ids.firstIndex(of: $0) } ?? -1 selection = ids[max(0, min(ids.count - 1, index + step))] - if QLPreviewPanel.sharedPreviewPanelExists(), QLPreviewPanel.shared().isVisible { updateQuickLook() } + if QuickLookController.isShowing { updateQuickLook() } } func select(_ row: ResultRow) { @@ -421,20 +419,12 @@ final class SearchPanelController { // MARK: Quick Look func toggleQuickLook() { - let panel = QLPreviewPanel.shared()! - if QLPreviewPanel.sharedPreviewPanelExists(), panel.isVisible { - panel.orderOut(nil) - } else if selectedRow != nil { - updateQuickLook() - panel.makeKeyAndOrderFront(nil) - } + QuickLookController.toggle(on: window, row: selectedRow) } /// Quick Look shows the selected result at its page or moment. private func updateQuickLook() { - window?.previewURL = selectedRow?.url - QLPreviewPanel.shared().reloadData() - QuickLookMoment.show(selectedRow, in: QLPreviewPanel.shared()) + QuickLookController.update(on: window, row: selectedRow) } // MARK: Window @@ -511,7 +501,7 @@ final class SearchPanelController { } /// The floating panel. Unlike most floating panels it becomes key, because you type into it. -final class SearchPanelWindow: NSPanel, QLPreviewPanelDataSource { +final class SearchPanelWindow: NSPanel, QLPreviewPanelDataSource, QuickLookHost { /// What Space / ⌘Y previews. var previewURL: URL? @@ -552,10 +542,10 @@ final class SearchPanelWindow: NSPanel, QLPreviewPanelDataSource { } func numberOfPreviewItems(in panel: QLPreviewPanel!) -> Int { - previewURL == nil ? 0 : 1 + QuickLookController.itemCount(for: previewURL) } func previewPanel(_ panel: QLPreviewPanel!, previewItemAt index: Int) -> (any QLPreviewItem)! { - previewURL as NSURL? + QuickLookController.item(for: previewURL) } } diff --git a/Sources/DigUpApp/SearchWindow.swift b/Sources/DigUpApp/SearchWindow.swift index 6994066a..bcd3adf3 100644 --- a/Sources/DigUpApp/SearchWindow.swift +++ b/Sources/DigUpApp/SearchWindow.swift @@ -61,9 +61,7 @@ final class SearchWindowController: NSObject, NSWindowDelegate { } func windowWillClose(_ notification: Notification) { - if QLPreviewPanel.sharedPreviewPanelExists(), QLPreviewPanel.shared().isVisible { - QLPreviewPanel.shared().orderOut(nil) - } + QuickLookController.hide() removeKeyMonitor() PreviewPlayback.shared.stop() window?.contentView = nil @@ -82,7 +80,7 @@ final class SearchWindowController: NSObject, NSWindowDelegate { keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in guard let self, let window else { return event } // Keys typed here, or in Quick Look while this window is the one showing it (not the panel's). - let previewing = QLPreviewPanel.sharedPreviewPanelExists() && QLPreviewPanel.shared().isVisible + let previewing = QuickLookController.isShowing && event.window === QLPreviewPanel.shared() && QLPreviewPanel.shared().dataSource === window guard event.window === window || previewing else { return event } return handle(event) ? nil : event @@ -107,7 +105,7 @@ final class SearchWindowController: NSObject, NSWindowDelegate { // An input method is composing (Japanese, Chinese…): ↩, the arrows and Esc are its keys until it's done. guard let press = KeyCommands.press(event, field: search.field) else { return false } let modifiers = press.modifiers - let previewing = QLPreviewPanel.sharedPreviewPanelExists() && QLPreviewPanel.shared().isVisible + let previewing = QuickLookController.isShowing switch press.keyCode { case KeyCommands.downArrow, KeyCommands.upArrow: // ↓ ↑ guard modifiers.isEmpty else { return false } @@ -139,7 +137,7 @@ final class SearchWindowController: NSObject, NSWindowDelegate { NSPasteboard.general.writeObjects([row.url as NSURL]) case KeyCommands.escape: // Esc if previewing { - QLPreviewPanel.shared().orderOut(nil) + QuickLookController.hide() } else if !search.query.isEmpty { search.query = "" } else { @@ -148,30 +146,22 @@ final class SearchWindowController: NSObject, NSWindowDelegate { default: return false } - if QLPreviewPanel.sharedPreviewPanelExists(), QLPreviewPanel.shared().isVisible { updateQuickLook() } + if QuickLookController.isShowing { updateQuickLook() } return true } func toggleQuickLook() { - let panel = QLPreviewPanel.shared()! - if QLPreviewPanel.sharedPreviewPanelExists(), panel.isVisible { - panel.orderOut(nil) - } else if search.selectedRow != nil { - updateQuickLook() - panel.makeKeyAndOrderFront(nil) - } + QuickLookController.toggle(on: window, row: search.selectedRow) } /// Quick Look shows the selected result at its page or moment. private func updateQuickLook() { - window?.previewURL = search.selectedRow?.url - QLPreviewPanel.shared().reloadData() - QuickLookMoment.show(search.selectedRow, in: QLPreviewPanel.shared()) + QuickLookController.update(on: window, row: search.selectedRow) } } /// A window that hands Quick Look the selected file (Space / ⌘Y). -final class QuickLookWindow: NSWindow, QLPreviewPanelDataSource { +final class QuickLookWindow: NSWindow, QLPreviewPanelDataSource, QuickLookHost { var previewURL: URL? nonisolated override func acceptsPreviewPanelControl(_ panel: QLPreviewPanel!) -> Bool { true } @@ -185,11 +175,11 @@ final class QuickLookWindow: NSWindow, QLPreviewPanelDataSource { } func numberOfPreviewItems(in panel: QLPreviewPanel!) -> Int { - previewURL == nil ? 0 : 1 + QuickLookController.itemCount(for: previewURL) } func previewPanel(_ panel: QLPreviewPanel!, previewItemAt index: Int) -> (any QLPreviewItem)! { - previewURL as NSURL? + QuickLookController.item(for: previewURL) } } From 8de523fc412a6ee04c4a4fa11b1b25c142d1ea4f Mon Sep 17 00:00:00 2001 From: dowjames <371653+dowjames@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:09:12 -0400 Subject: [PATCH 4/8] Search window: show(query:kind:) sets filter and query in one search run --- Sources/DigUpApp/SearchWindow.swift | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/Sources/DigUpApp/SearchWindow.swift b/Sources/DigUpApp/SearchWindow.swift index bcd3adf3..e68c3395 100644 --- a/Sources/DigUpApp/SearchWindow.swift +++ b/Sources/DigUpApp/SearchWindow.swift @@ -24,8 +24,7 @@ final class SearchWindowController: NSObject, NSWindowDelegate { func show(query: String? = nil, kind: ResultGroupKind? = nil) { let window = self.window ?? makeWindow() if let query { - search.filter = kind - search.query = query + search.show(query: query, kind: kind) } app.present(window) if !AppController.offscreen { window.makeFirstResponder(search.field) } @@ -209,6 +208,8 @@ final class SearchModel { @ObservationIgnored private let engine: Engine @ObservationIgnored private var generation = 0 @ObservationIgnored private var task: Task? + /// `show(query:kind:)` is setting both properties: their `didSet`s skip `run()`, which it then runs once itself. + @ObservationIgnored private var batching = false init(engine: Engine) { self.engine = engine @@ -232,6 +233,15 @@ final class SearchModel { navigated = false } + /// The window's query and kind filter at once: one search run, not one per property. + func show(query newQuery: String, kind: ResultGroupKind?) { + batching = true + filter = kind + query = newQuery + batching = false + run() + } + /// Puts the cursor in the search field (a click on the empty window lands there). func focusField() { guard let field, field.currentEditor() == nil else { return } @@ -252,6 +262,7 @@ final class SearchModel { } private func run() { + guard !batching else { return } generation += 1 let current = generation task?.cancel() From d50ab78c21c2872befa2b7ac9be4e95953c26bf4 Mon Sep 17 00:00:00 2001 From: dowjames <371653+dowjames@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:09:45 -0400 Subject: [PATCH 5/8] AppController: setExcluded normalizes through FolderSelection like applyFolders --- Sources/DigUpApp/AppController.swift | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Sources/DigUpApp/AppController.swift b/Sources/DigUpApp/AppController.swift index f061d628..2ce93abe 100644 --- a/Sources/DigUpApp/AppController.swift +++ b/Sources/DigUpApp/AppController.swift @@ -144,9 +144,14 @@ final class AppController { } func setExcluded(_ folders: [URL]) { - AppSettings.save(excludedFolders: folders) - model.settings.excludedFolders = folders - engine.setFolders(roots: model.settings.roots, excluded: folders, excludedTypes: model.settings.excludedTypes) + // Normalize like `applyFolders`: paths go through `AppSettings.folderURL` and `FolderSelection` drops a + // skip with nothing around it to skip from, so both save paths agree. + let selection = FolderSelection(chosen: model.settings.roots.map { AppSettings.folderURL($0.path).path }, + skipped: folders.map(\.path)) + let excluded = selection.effectiveSkips.map(AppSettings.folderURL) + AppSettings.save(excludedFolders: excluded) + model.settings.excludedFolders = excluded + engine.setFolders(roots: model.settings.roots, excluded: excluded, excludedTypes: model.settings.excludedTypes) } /// File extensions never to index ("heic, .txt"); files of those types already indexed leave the index. From d7a88f4cbd8c85f0a9be3e8748a4395c8fc9e4a6 Mon Sep 17 00:00:00 2001 From: dowjames <371653+dowjames@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:54:39 -0400 Subject: [PATCH 6/8] KeyCommands/QuickLookController: compile fixes (kVK_LeftArrow et al. have no ANSI prefix; URL? to NSURL needs a map) --- Sources/DigUpApp/KeyCommands.swift | 8 ++++---- Sources/DigUpApp/QuickLookController.swift | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/DigUpApp/KeyCommands.swift b/Sources/DigUpApp/KeyCommands.swift index 801bcd9f..4860830c 100644 --- a/Sources/DigUpApp/KeyCommands.swift +++ b/Sources/DigUpApp/KeyCommands.swift @@ -26,10 +26,10 @@ enum KeyCommands { static let enter = Int(kVK_Return) static let keypadEnter = Int(kVK_ANSI_KeypadEnter) static let space = Int(kVK_Space) - static let leftArrow = Int(kVK_ANSI_LeftArrow) - static let rightArrow = Int(kVK_ANSI_RightArrow) - static let upArrow = Int(kVK_ANSI_UpArrow) - static let downArrow = Int(kVK_ANSI_DownArrow) + static let leftArrow = Int(kVK_LeftArrow) + static let rightArrow = Int(kVK_RightArrow) + static let upArrow = Int(kVK_UpArrow) + static let downArrow = Int(kVK_DownArrow) static let c = Int(kVK_ANSI_C) static let o = Int(kVK_ANSI_O) static let y = Int(kVK_ANSI_Y) diff --git a/Sources/DigUpApp/QuickLookController.swift b/Sources/DigUpApp/QuickLookController.swift index 1763e444..d83eb83a 100644 --- a/Sources/DigUpApp/QuickLookController.swift +++ b/Sources/DigUpApp/QuickLookController.swift @@ -41,5 +41,5 @@ enum QuickLookController { // The data-source answers both windows shared, in one place. static func itemCount(for url: URL?) -> Int { url == nil ? 0 : 1 } - static func item(for url: URL?) -> (any QLPreviewItem)? { url as NSURL } + static func item(for url: URL?) -> (any QLPreviewItem)? { url.map { $0 as NSURL } } } From 49934f03c50e16f0018095169064090390130421 Mon Sep 17 00:00:00 2001 From: dowjames <371653+dowjames@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:08:46 -0400 Subject: [PATCH 7/8] Settings: the folders list shows four at a time; more scroll inside it --- Sources/DigUpApp/SettingsWindow.swift | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/Sources/DigUpApp/SettingsWindow.swift b/Sources/DigUpApp/SettingsWindow.swift index f56d40fd..3bfbf528 100644 --- a/Sources/DigUpApp/SettingsWindow.swift +++ b/Sources/DigUpApp/SettingsWindow.swift @@ -261,6 +261,9 @@ private struct FolderSettings: View { @Bindable var state: SettingsState @State private var removing: Engine.RootSummary? + /// Height of a typical FolderStatusRow (icon, name, path, kind counts): four fit at once. + private static let folderRowHeight: CGFloat = 56 + var body: some View { Form { Section { @@ -271,9 +274,21 @@ private struct FolderSettings: View { if summaries.isEmpty { Text("No folders yet. Add the ones DigUp should search.") .foregroundStyle(.secondary) - } - ForEach(summaries) { summary in - FolderStatusRow(summary: summary) { removing = summary } + } else if summaries.count > 4 { + // Four folders show at once; the rest scroll inside the list. Rows vary in height + // (kind counts, status lines), so the cap is four typical rows, not a measured four. + ScrollView { + VStack(alignment: .leading, spacing: 10) { + ForEach(summaries) { summary in + FolderStatusRow(summary: summary) { removing = summary } + } + } + } + .frame(maxHeight: 4 * Self.folderRowHeight) + } else { + ForEach(summaries) { summary in + FolderStatusRow(summary: summary) { removing = summary } + } } } else { ProgressView().controlSize(.small).frame(maxWidth: .infinity) From 67a9822b5451882dce684b9f49dd329426f5e8c5 Mon Sep 17 00:00:00 2001 From: dowjames <371653+dowjames@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:18:51 -0400 Subject: [PATCH 8/8] Settings: the code folders list shows four at a time too; more scroll inside it --- Sources/DigUpApp/SettingsWindow.swift | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/Sources/DigUpApp/SettingsWindow.swift b/Sources/DigUpApp/SettingsWindow.swift index 3bfbf528..a27a51db 100644 --- a/Sources/DigUpApp/SettingsWindow.swift +++ b/Sources/DigUpApp/SettingsWindow.swift @@ -257,13 +257,13 @@ private struct GeneralSettings: View { } } +/// Height of a typical FolderStatusRow (icon, name, path, kind counts): four fit at once. +private let folderRowHeight: CGFloat = 56 + private struct FolderSettings: View { @Bindable var state: SettingsState @State private var removing: Engine.RootSummary? - /// Height of a typical FolderStatusRow (icon, name, path, kind counts): four fit at once. - private static let folderRowHeight: CGFloat = 56 - var body: some View { Form { Section { @@ -284,7 +284,7 @@ private struct FolderSettings: View { } } } - .frame(maxHeight: 4 * Self.folderRowHeight) + .frame(maxHeight: 4 * folderRowHeight) } else { ForEach(summaries) { summary in FolderStatusRow(summary: summary) { removing = summary } @@ -650,9 +650,20 @@ private struct CodeSettings: View { if state.codeSummaries.isEmpty { Text("No code folders yet. Add the folders that hold your projects.") .foregroundStyle(.secondary) - } - ForEach(state.codeSummaries) { summary in - FolderStatusRow(summary: summary) { removing = summary } + } else if state.codeSummaries.count > 4 { + // Same as the Folders tab: four at a time, the rest scroll inside the list. + ScrollView { + VStack(alignment: .leading, spacing: 10) { + ForEach(state.codeSummaries) { summary in + FolderStatusRow(summary: summary) { removing = summary } + } + } + } + .frame(maxHeight: 4 * folderRowHeight) + } else { + ForEach(state.codeSummaries) { summary in + FolderStatusRow(summary: summary) { removing = summary } + } } } header: { Text("Code folders")