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. 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/KeyCommands.swift b/Sources/DigUpApp/KeyCommands.swift new file mode 100644 index 00000000..4860830c --- /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_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) + /// 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/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 diff --git a/Sources/DigUpApp/QuickLookController.swift b/Sources/DigUpApp/QuickLookController.swift new file mode 100644 index 00000000..d83eb83a --- /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.map { $0 as NSURL } } +} diff --git a/Sources/DigUpApp/SearchPanel.swift b/Sources/DigUpApp/SearchPanel.swift index ce158d3e..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() { @@ -296,20 +294,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]) - let previewing = QLPreviewPanel.sharedPreviewPanelExists() && QLPreviewPanel.shared().isVisible - switch event.keyCode { - case 53: // Esc - if previewing { QLPreviewPanel.shared().orderOut(nil) } else { dismiss() } + guard let press = KeyCommands.press(event, field: field) else { return false } + let modifiers = press.modifiers + let previewing = QuickLookController.isShowing + switch press.keyCode { + case KeyCommands.escape: // Esc + if previewing { QuickLookController.hide() } 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 +317,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 @@ -346,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) { @@ -422,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 @@ -512,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? @@ -553,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 4fc54cc6..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) } @@ -61,9 +60,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 +79,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 @@ -105,18 +102,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]) - let previewing = QLPreviewPanel.sharedPreviewPanelExists() && QLPreviewPanel.shared().isVisible - switch event.keyCode { - case 125, 126: // ↓ ↑ + guard let press = KeyCommands.press(event, field: search.field) else { return false } + let modifiers = press.modifiers + let previewing = QuickLookController.isShowing + 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,23 +121,22 @@ 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) + QuickLookController.hide() } else if !search.query.isEmpty { search.query = "" } else { @@ -150,30 +145,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 } @@ -187,11 +174,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) } } @@ -221,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 @@ -244,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 } @@ -264,6 +262,7 @@ final class SearchModel { } private func run() { + guard !batching else { return } generation += 1 let current = generation task?.cancel() diff --git a/Sources/DigUpApp/SettingsWindow.swift b/Sources/DigUpApp/SettingsWindow.swift index f56d40fd..a27a51db 100644 --- a/Sources/DigUpApp/SettingsWindow.swift +++ b/Sources/DigUpApp/SettingsWindow.swift @@ -257,6 +257,9 @@ 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? @@ -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 * folderRowHeight) + } else { + ForEach(summaries) { summary in + FolderStatusRow(summary: summary) { removing = summary } + } } } else { ProgressView().controlSize(.small).frame(maxWidth: .infinity) @@ -635,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")