From c39cbdf62bf3feade11daad55df4db62f0c1770b Mon Sep 17 00:00:00 2001 From: Colin Kim <121141519+colinvkim@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:44:40 -0700 Subject: [PATCH 01/22] feat(navigation): add direct path navigation --- Package.swift | 1 + Radix/App/RadixCommands.swift | 6 + Radix/ContentView.swift | 17 ++ Radix/Features/Workspace/GoToPathSheet.swift | 138 +++++++++ Radix/Localizable.xcstrings | 276 ++++++++++++++++++ Radix/Services/FileBrowserModel.swift | 6 + Radix/Services/ScanPathResolver.swift | 132 +++++++++ Radix/ViewModels/AppModel.swift | 69 +++++ .../AppPresentationCoordinator.swift | 12 + RadixCoreTests/GoToPathTests.swift | 207 +++++++++++++ 10 files changed, 864 insertions(+) create mode 100644 Radix/Features/Workspace/GoToPathSheet.swift create mode 100644 Radix/Services/ScanPathResolver.swift create mode 100644 RadixCoreTests/GoToPathTests.swift diff --git a/Package.swift b/Package.swift index 1a845c2..6cae018 100644 --- a/Package.swift +++ b/Package.swift @@ -89,6 +89,7 @@ let package = Package( "Services/ScanIntegerMath.swift", "Services/ScanMetadataLoader.swift", "Services/ScanPathIndex.swift", + "Services/ScanPathResolver.swift", "Services/ScanSnapshotTransformService.swift", "Services/ScanWarningFactory.swift", "Services/SunburstChartModel.swift", diff --git a/Radix/App/RadixCommands.swift b/Radix/App/RadixCommands.swift index 6994c04..06c54c5 100644 --- a/Radix/App/RadixCommands.swift +++ b/Radix/App/RadixCommands.swift @@ -236,6 +236,12 @@ struct RadixCommands: Commands { Divider() + Button("Go to Path…") { + appModel.presentGoToPath() + } + .keyboardShortcut("g", modifiers: [.command, .shift]) + .disabled(!canUseWorkspaceCommands || !appModel.canGoToPath) + Button("Go to Parent", systemImage: "arrow.up") { appModel.navigateToParent() } diff --git a/Radix/ContentView.swift b/Radix/ContentView.swift index 9df0e24..020881a 100644 --- a/Radix/ContentView.swift +++ b/Radix/ContentView.swift @@ -166,6 +166,23 @@ struct ContentView: View { ) .sheet(item: activeSheetBinding) { sheet in switch sheet { + case .goToPath(let request): + if let snapshot = appModel.scanState.snapshot, + snapshot.id == request.snapshotID, snapshot.treeStore.contentID == request.treeContentID { + GoToPathSheet( + resolver: ScanPathResolver( + treeStore: snapshot.treeStore, + hiddenNodeIDs: appModel.workspaceHiddenNodeIDs, + baseDirectoryID: request.directoryID + ), + onCancel: { appModel.dismissGoToPath() }, + onGo: { path in + let succeeded = await appModel.goToPath(path, request: request) + if succeeded { focusedWorkspaceTarget = .contents } + return succeeded + } + ) + } case .onboarding: OnboardingView() case .discardPileReview: diff --git a/Radix/Features/Workspace/GoToPathSheet.swift b/Radix/Features/Workspace/GoToPathSheet.swift new file mode 100644 index 0000000..70a5605 --- /dev/null +++ b/Radix/Features/Workspace/GoToPathSheet.swift @@ -0,0 +1,138 @@ +import SwiftUI + +struct GoToPathSheet: View { + let resolver: ScanPathResolver + let onCancel: () -> Void + let onGo: (String) async -> Bool + + @State private var path: String + @State private var completions: [FileNodeRecord] = [] + @State private var selectedCompletionID: FileNodeRecord.ID? + @State private var showsError = false + @State private var submissionTask: Task? + @FocusState private var isPathFocused: Bool + + init(resolver: ScanPathResolver, onCancel: @escaping () -> Void, onGo: @escaping (String) async -> Bool) { + self.resolver = resolver + self.onCancel = onCancel + self.onGo = onGo + _path = State(initialValue: resolver.initialPath) + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Go to Path") + .font(.headline) + Text("Enter a path in this scan.") + .foregroundStyle(.secondary) + TextField("Path", text: $path) + .textFieldStyle(.roundedBorder) + .focused($isPathFocused) + .accessibilityLabel("Path") + .disabled(isSubmitting) + .onSubmit { submit() } + .onKeyPress(.downArrow) { moveSelection(by: 1) } + .onKeyPress(.upArrow) { moveSelection(by: -1) } + .onKeyPress(.tab, phases: .down) { press in + guard press.modifiers.isEmpty, let completion = selectedCompletion ?? completions.first else { + return .ignored + } + accept(completion) + return .handled + } + if !completions.isEmpty { + VStack(spacing: 2) { + ForEach(completions) { node in + Button { + accept(node) + } label: { + Label { + Text(verbatim: ScanPathResolver.completionPath(for: node)) + .lineLimit(1) + .truncationMode(.middle) + } icon: { + Image(systemName: node.systemImageName) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(selectedCompletionID == node.id ? Color.accentColor.opacity(0.18) : .clear) + .clipShape(RoundedRectangle(cornerRadius: 5)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityAddTraits(selectedCompletionID == node.id ? .isSelected : []) + } + } + .disabled(isSubmitting) + } + if showsError { + Text("This path is not available in the current scan.") + .font(.callout) + .foregroundStyle(.red) + } + HStack { + Spacer() + Button("Cancel", action: onCancel) + .keyboardShortcut(.cancelAction) + Button("Go") { submit() } + .keyboardShortcut(.defaultAction) + .disabled(path.isEmpty || isSubmitting) + } + } + .padding(20) + .frame(width: 480) + .onDisappear { submissionTask?.cancel() } + .task { isPathFocused = true } + .task(id: path) { + showsError = false + selectedCompletionID = nil + completions = [] + let input = path + let resolver = resolver + let query = Task.detached(priority: .userInitiated) { + try resolver.completions(for: input, cancellationCheck: { try Task.checkCancellation() }) + } + let matches = await withTaskCancellationHandler { + try? await query.value + } onCancel: { + query.cancel() + } + guard !Task.isCancelled, path == input else { return } + completions = matches ?? [] + } + } + + private var isSubmitting: Bool { submissionTask != nil } + + private var selectedCompletion: FileNodeRecord? { + completions.first { $0.id == selectedCompletionID } + } + + private func moveSelection(by offset: Int) -> KeyPress.Result { + guard !completions.isEmpty else { return .ignored } + let index = selectedCompletionID.flatMap { id in completions.firstIndex { $0.id == id } } + let next = index.map { min(max($0 + offset, 0), completions.count - 1) } + ?? (offset > 0 ? 0 : completions.count - 1) + selectedCompletionID = completions[next].id + return .handled + } + + private func accept(_ node: FileNodeRecord) { + path = ScanPathResolver.completionPath(for: node) + selectedCompletionID = nil + isPathFocused = true + } + + private func submit() { + guard !isSubmitting else { return } + let input = selectedCompletion.map(ScanPathResolver.completionPath) ?? path + submissionTask = Task { @MainActor in + let succeeded = await onGo(input) + submissionTask = nil + guard !Task.isCancelled else { return } + showsError = !succeeded + if !succeeded { isPathFocused = true } + } + } +} diff --git a/Radix/Localizable.xcstrings b/Radix/Localizable.xcstrings index 6f3992d..1458d69 100644 --- a/Radix/Localizable.xcstrings +++ b/Radix/Localizable.xcstrings @@ -1,6 +1,282 @@ { "sourceLanguage" : "en", "strings" : { + "This path is not available in the current scan." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This path is not available in the current scan." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dieser Pfad ist im aktuellen Scan nicht verfügbar." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Esta ruta no está disponible en el análisis actual." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ce chemin n’est pas disponible dans l’analyse actuelle." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Questo percorso non è disponibile nella scansione attuale." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Этот путь недоступен в текущем сканировании." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当前扫描中没有此路径。" + } + } + } + }, + "Go" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Go" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gehe zu" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ir" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aller" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vai" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перейти" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "前往" + } + } + } + }, + "Path" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Path" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pfad" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ruta" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chemin" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Percorso" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Путь" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "路径" + } + } + } + }, + "Enter a path in this scan." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enter a path in this scan." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gib einen Pfad in diesem Scan ein." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Introduce una ruta de este análisis." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Saisissez un chemin dans cette analyse." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inserisci un percorso di questa scansione." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Введите путь в этом сканировании." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "输入此扫描中的路径。" + } + } + } + }, + "Go to Path" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Go to Path" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gehe zu Pfad" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ir a la ruta" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aller au chemin" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vai al percorso" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перейти к пути" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "前往路径" + } + } + } + }, + "Go to Path…" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Go to Path…" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gehe zu Pfad…" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ir a la ruta…" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aller au chemin…" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vai al percorso…" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перейти к пути…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "前往路径…" + } + } + } + }, "-" : { "localizations" : { "de" : { diff --git a/Radix/Services/FileBrowserModel.swift b/Radix/Services/FileBrowserModel.swift index 4ceff1a..614fa7e 100644 --- a/Radix/Services/FileBrowserModel.swift +++ b/Radix/Services/FileBrowserModel.swift @@ -131,6 +131,12 @@ final class FileBrowserModel: ObservableObject { } } + func showCurrentContents() { + currentContentsQuery = FileBrowserQuery() + searchScope = .currentContents + refreshDisplayedNodes() + } + func setSearchScope(_ scope: FileBrowserFindTarget) { guard searchScope != scope else { return } searchScope = scope diff --git a/Radix/Services/ScanPathResolver.swift b/Radix/Services/ScanPathResolver.swift new file mode 100644 index 0000000..2deeb20 --- /dev/null +++ b/Radix/Services/ScanPathResolver.swift @@ -0,0 +1,132 @@ +import Foundation + +nonisolated struct ScanPathResolver: Sendable { + let treeStore: FileTreeStore + let baseDirectoryID: FileNodeRecord.ID? + private let hiddenNodes: PreparedFileTreeNodeSet + + init( + treeStore: FileTreeStore, + hiddenNodeIDs: Set = [], + baseDirectoryID: FileNodeRecord.ID? = nil + ) { + self.treeStore = treeStore + self.baseDirectoryID = baseDirectoryID + hiddenNodes = treeStore.preparedNodeSet(for: hiddenNodeIDs) + } + + var initialPath: String { + let base = treeStore.node(id: baseDirectoryID) ?? treeStore.root + return base.url.path.hasSuffix("/") ? base.url.path : base.url.path + "/" + } + + func resolve( + _ input: String, + cancellationCheck: () throws -> Void = {} + ) throws -> FileNodeRecord? { + guard let path = absolutePath(input), + let node = try node(at: path, cancellationCheck: cancellationCheck), + !input.hasSuffix("/") || node.isDirectory else { return nil } + return node + } + + func completions( + for input: String, + limit: Int = 8, + cancellationCheck: () throws -> Void = {} + ) throws -> [FileNodeRecord] { + guard limit > 0, let path = absolutePath(input) else { return [] } + let rootPath = normalizedPath(treeStore.root.url.path) + if path != rootPath, + rootPath.range(of: path, options: [.anchored, .caseInsensitive]) != nil, + isVisible(treeStore.root) { + return [treeStore.root] + } + + let listsChildren = input.hasSuffix("/") || input == "~" + || input.split(separator: "/").last.map { $0 == "." || $0 == ".." } == true + let components = path.split(separator: "/").map(String.init) + let prefix = listsChildren ? "" : components.last ?? "" + let parentPath = listsChildren ? path : "/" + components.dropLast().joined(separator: "/") + guard let parent = try node(at: parentPath, cancellationCheck: cancellationCheck), + parent.isDirectory else { return [] } + var matches: [FileNodeRecord] = [] + for child in try treeStore.children(of: parent.id, cancellationCheck: cancellationCheck) { + try cancellationCheck() + guard isVisible(child), + prefix.isEmpty || child.url.lastPathComponent.range( + of: prefix, options: [.anchored, .caseInsensitive] + ) != nil else { continue } + let insertion = matches.firstIndex { precedes(child, $0) } ?? matches.endIndex + if insertion < limit { + matches.insert(child, at: insertion) + if matches.count > limit { matches.removeLast() } + } + } + return matches + } + + static func completionPath(for node: FileNodeRecord) -> String { + let path = node.url.path + return node.isDirectory && !path.hasSuffix("/") ? path + "/" : path + } + + private func node(at path: String, cancellationCheck: () throws -> Void) throws -> FileNodeRecord? { + try cancellationCheck() + let rootComponents = normalizedPath(treeStore.root.url.path).split(separator: "/") + let components = path.split(separator: "/") + guard components.starts(with: rootComponents), isVisible(treeStore.root) else { return nil } + var current = treeStore.root + for component in components.dropFirst(rootComponents.count) { + guard current.isDirectory else { return nil } + let children = try treeStore.children(of: current.id, cancellationCheck: cancellationCheck) + var match: FileNodeRecord? + for child in children { + try cancellationCheck() + if child.url.lastPathComponent == component, isVisible(child) { + match = child + break + } + } + guard let match else { return nil } + current = match + } + return current + } + + private func isVisible(_ node: FileNodeRecord) -> Bool { + !node.isSynthetic && !treeStore.isNodeOrDescendant(node.id, of: hiddenNodes) + } + + private func absolutePath(_ input: String) -> String? { + guard !input.isEmpty, !input.contains("\0") else { return nil } + let expanded: String + if input == "~" || input.hasPrefix("~/") { + expanded = FileManager.default.homeDirectoryForCurrentUser.path + input.dropFirst() + } else if input.hasPrefix("~") { + return nil + } else { + expanded = input + } + let base = treeStore.node(id: baseDirectoryID) ?? treeStore.root + return normalizedPath(expanded.hasPrefix("/") ? expanded : base.url.path + "/" + expanded) + } + + private func normalizedPath(_ path: String) -> String { + var components: [Substring] = [] + for component in path.split(separator: "/") { + switch component { + case ".": continue + case "..": + if !components.isEmpty { components.removeLast() } + default: components.append(component) + } + } + return "/" + components.joined(separator: "/") + } + + private func precedes(_ lhs: FileNodeRecord, _ rhs: FileNodeRecord) -> Bool { + let order = lhs.url.lastPathComponent.localizedStandardCompare(rhs.url.lastPathComponent) + return order == .orderedSame ? lhs.url.path < rhs.url.path : order == .orderedAscending + } +} diff --git a/Radix/ViewModels/AppModel.swift b/Radix/ViewModels/AppModel.swift index 5da260c..f1da790 100644 --- a/Radix/ViewModels/AppModel.swift +++ b/Radix/ViewModels/AppModel.swift @@ -208,9 +208,12 @@ final class AppModel: ObservableObject { dependencies.preferences.saveReadOnlyMode(isEnabled) } + private weak var fileBrowser: FileBrowserModel? + func makeFileBrowserModel() -> FileBrowserModel { let model = tourSession.makeFileBrowser(snapshotID: scanCoordinator.snapshot?.id) quickLookController.fileBrowser = model + fileBrowser = model return model } @@ -764,6 +767,8 @@ final class AppModel: ObservableObject { func dismissActiveSheet() { switch presentationCoordinator.activeSheet { + case .goToPath: + dismissGoToPath() case .onboarding: dismissOnboarding() case .discardPileReview: @@ -1970,6 +1975,66 @@ final class AppModel: ObservableObject { performNavigationAction(.clearSelection) } + var canGoToPath: Bool { + scanCoordinator.snapshot != nil && canUseWorkspaceCommands && !tourSession.isActive + && !workspaceTour.isActive && !scanCoordinator.isForegroundScanOperationInProgress + && !isArchiveOperationInProgress && !isExportPanelPresented + && presentationCoordinator.activeDestination == nil + } + + func presentGoToPath() { + guard canGoToPath, let snapshot = scanCoordinator.snapshot else { return } + presentationCoordinator.present(.sheet(.goToPath(GoToPathRequest( + snapshotID: snapshot.id, + treeContentID: snapshot.treeStore.contentID, + directoryID: navigationModel.currentFocusNode?.id ?? snapshot.root.id + )))) + } + + func dismissGoToPath() { + resumeDeferredArchiveImport(presentationCoordinator.cancel(kind: .goToPath)) + } + + func goToPath( + _ path: String, + request: GoToPathRequest + ) async -> Bool { + let sheet = AppPresentationCoordinator.Sheet.goToPath(request) + guard presentationCoordinator.activeSheet == sheet, + let snapshot = scanCoordinator.snapshot, + snapshot.id == request.snapshotID, snapshot.treeStore.contentID == request.treeContentID else { return false } + let resolver = ScanPathResolver( + treeStore: snapshot.treeStore, + hiddenNodeIDs: workspaceHiddenNodeIDs, + baseDirectoryID: request.directoryID + ) + let resolution = Task.detached(priority: .userInitiated) { + try resolver.resolve(path, cancellationCheck: { try Task.checkCancellation() }) + } + let node = await withTaskCancellationHandler { + try? await resolution.value + } onCancel: { + resolution.cancel() + } + guard !Task.isCancelled, let node, + presentationCoordinator.activeSheet == sheet, + scanCoordinator.snapshot?.id == request.snapshotID, + scanCoordinator.fileTreeStore?.contentID == request.treeContentID, + !scanCoordinator.isForegroundScanOperationInProgress, + canUseWorkspaceCommands, !workspaceTour.isActive, + isVisibleNavigationNode(node.id) else { return false } + cancelDeferredNavigationAction() + fileBrowser?.showCurrentContents() + if node.isDirectory { + navigationModel.clearSelection() + navigationModel.focus(nodeID: node.id) + } else { + navigationModel.reveal(nodeID: node.id) + } + dismissGoToPath() + return true + } + func zoomIntoSelection() { do { let node = try validatedSelection(requiresDirectory: true, requiresLivePath: false) @@ -3204,6 +3269,10 @@ final class AppModel: ObservableObject { scanCoordinator.$snapshot .sink { [weak self] snapshot in guard let self else { return } + if case .goToPath(let request) = presentationCoordinator.activeSheet, + snapshot?.id != request.snapshotID || snapshot?.treeStore.contentID != request.treeContentID { + dismissGoToPath() + } quickLookController.closePreview() refreshDiskFreeSpaceCapacity(for: snapshot) syncOptimisticTrashVisibility(with: snapshot) diff --git a/Radix/ViewModels/AppPresentationCoordinator.swift b/Radix/ViewModels/AppPresentationCoordinator.swift index 9c52877..8af64bf 100644 --- a/Radix/ViewModels/AppPresentationCoordinator.swift +++ b/Radix/ViewModels/AppPresentationCoordinator.swift @@ -1,16 +1,26 @@ import Combine import Foundation +struct GoToPathRequest: Hashable, Sendable { + let id = UUID() + let snapshotID: UUID + let treeContentID: UUID + let directoryID: FileNodeRecord.ID +} + @MainActor final class AppPresentationCoordinator: ObservableObject { enum Sheet: Hashable, Identifiable { case onboarding + case goToPath(GoToPathRequest) case discardPileReview case importPreview(URL) case comparisonSetup(UUID) var id: String { switch self { + case .goToPath(let request): + "go-to-path-\(request.id)" case .onboarding: "onboarding" case .discardPileReview: @@ -24,6 +34,7 @@ final class AppPresentationCoordinator: ObservableObject { fileprivate var kind: DestinationKind { switch self { + case .goToPath: .goToPath case .onboarding: .onboarding case .discardPileReview: .discardPileReview case .importPreview: .importPreview @@ -64,6 +75,7 @@ final class AppPresentationCoordinator: ObservableObject { } enum DestinationKind: Hashable { + case goToPath case onboarding case discardPileReview case importPreview diff --git a/RadixCoreTests/GoToPathTests.swift b/RadixCoreTests/GoToPathTests.swift new file mode 100644 index 0000000..d9e9507 --- /dev/null +++ b/RadixCoreTests/GoToPathTests.swift @@ -0,0 +1,207 @@ +import Foundation +import Testing + +@testable import RadixCore + +struct ScanPathResolverTests { + @Test(arguments: [ + ("/scan", "root"), + ("/scan/Reports/Empty/", "empty"), + ("./notes.txt", "file"), + ("../Reports/./notes.txt", "file"), + ("/scan/Reports/space name.txt", "spaces"), + ("/scan/Reports/末尾 ", "unicode"), + ("/scan//Reports/Empty/../notes.txt", "file"), + ]) + func resolvesScannedPathsWithIndependentRecordIDs(input: String, expectedID: String) throws { + let fixture = PathFixture() + let resolver = ScanPathResolver( + treeStore: fixture.store, baseDirectoryID: fixture.folder.id + ) + #expect(try resolver.resolve(input)?.id == expectedID) + } + + @Test(arguments: ["", "/scan-other/Reports", "/scan/Reports/missing", "/scan/Reports/notes.txt/", "/scan/reports", "~someone/Reports"]) + func unavailablePathsDoNotResolve(input: String) throws { + #expect(try ScanPathResolver(treeStore: PathFixture().store).resolve(input) == nil) + } + + @Test + func completionsStayInsideTheScanAndExcludeHiddenAndSyntheticNodes() throws { + let fixture = PathFixture() + let resolver = ScanPathResolver(treeStore: fixture.store, hiddenNodeIDs: [fixture.folder.id]) + #expect(try resolver.resolve("/scan/Reports/notes.txt") == nil) + #expect(try resolver.resolve("/scan/System") == nil) + #expect(try resolver.completions(for: "/scan/").isEmpty) + #expect(try resolver.completions(for: "/sc").map(\.id) == [fixture.root.id]) + let visible = ScanPathResolver(treeStore: fixture.store) + #expect(try visible.completions(for: "/scan/rep").map(\.id) == [fixture.folder.id]) + #expect(try visible.completions(for: "/scan/Reports/Empty/").isEmpty) + } + + @Test + func completionIsBoundedNaturallyOrderedAndCancellable() throws { + let children = (1...20).reversed().map { pathRecord(id: "id-\($0)", path: "/scan/item\($0)") } + let root = pathRecord(id: "root", path: "/scan", directory: true) + let resolver = ScanPathResolver(treeStore: FileTreeStore(root: root, childrenByID: [root.id: children])) + #expect(try resolver.completions(for: "/scan/item").map(\.id) == (1...8).map { "id-\($0)" }) + let probe = CancellationProbe(throwOnCheck: 5) + #expect(throws: CancellationError.self) { + try resolver.completions(for: "/scan/", cancellationCheck: probe.check) + } + } + + @Test + func tildeUsesTheCurrentHomeDirectory() throws { + let home = FileManager.default.homeDirectoryForCurrentUser.path + let root = pathRecord(id: "home", path: home, directory: true) + let file = pathRecord(id: "file", path: home + "/scanned-only.txt") + let resolver = ScanPathResolver(treeStore: FileTreeStore(root: root, childrenByID: [root.id: [file]])) + #expect(try resolver.resolve("~/scanned-only.txt")?.id == file.id) + } + + @Test + func privatePathComponentsAndFilenameWhitespaceArePreserved() throws { + let file = pathRecord(id: "file", path: "/private/tmp/scan/ padded ") + let root = pathRecord(id: "root", path: "/private/tmp/scan", directory: true) + let resolver = ScanPathResolver(treeStore: FileTreeStore(root: root, childrenByID: [root.id: [file]])) + #expect(try resolver.resolve("/private/tmp/scan/ padded ")?.id == file.id) + #expect(try resolver.resolve("/tmp/scan/ padded ") == nil) + } +} + +@MainActor +struct GoToPathTests { + @Test + func folderJumpClearsSearchAndSelectionAndRecordsThePreviousLocation() async throws { + let fixture = PathFixture() + let model = makePathApp(fixture) + defer { model.cleanup() } + let browser = model.makeFileBrowserModel() + browser.updateContent(model.navigation.browserContent()) + browser.setActiveQuery(FileBrowserQuery(text: "unmatched", itemKind: .file)) + browser.setSearchScope(.entireScan) + browser.setActiveQuery(FileBrowserQuery(text: "unmatched")) + model.select(nodeID: fixture.folder.id) + model.presentGoToPath() + let request = try pathRequest(model) + + #expect(await model.goToPath("/scan/Reports/Empty/", request: request)) + #expect(model.navigation.currentFocusNode?.id == fixture.empty.id) + #expect(model.navigation.tableNodes.isEmpty) + #expect(model.navigation.breadcrumbNodes.map(\.id) == [fixture.root.id, fixture.folder.id, fixture.empty.id]) + #expect(model.navigation.selectedNodeIDs.isEmpty) + #expect(browser.searchScope == .currentContents) + #expect(browser.activeQuery == FileBrowserQuery()) + #expect(model.presentationCoordinator.activeSheet == nil) + model.navigateBack() + #expect(model.navigation.currentFocusNode?.id == fixture.root.id) + } + + @Test(arguments: [false, true]) + func fileJumpOpensItsParentAndSelectsIt(startInParent: Bool) async throws { + let fixture = PathFixture() + let model = makePathApp(fixture) + defer { model.cleanup() } + let base = startInParent ? fixture.folder.id : fixture.root.id + model.navigation.setFocusedNodeID(base) + model.presentGoToPath() + let request = try pathRequest(model) + #expect(await model.goToPath(fixture.file.url.path, request: request)) + #expect(model.navigation.currentFocusNode?.id == fixture.folder.id) + #expect(model.navigation.selectedNodeIDs == [fixture.file.id]) + #expect(model.navigation.tableNodes.contains { $0.id == fixture.file.id }) + #expect(model.navigation.canNavigateBack == !startInParent) + if !startInParent { + model.navigateBack() + #expect(model.navigation.currentFocusNode?.id == fixture.root.id) + } + } + + @Test + func invalidAndCancelledJumpsPreserveBrowsingAndOldScansCannotNavigate() async throws { + let fixture = PathFixture() + let model = makePathApp(fixture) + defer { model.cleanup() } + let browser = model.makeFileBrowserModel() + browser.setActiveQuery(FileBrowserQuery(text: "notes")) + model.presentGoToPath() + let request = try pathRequest(model) + #expect(!model.canGoToPath) + #expect(await !model.goToPath("/scan/missing", request: request)) + #expect(model.navigation.currentFocusNode?.id == fixture.root.id) + #expect(!model.navigation.canNavigateBack) + #expect(browser.activeQuery.text == "notes") + model.dismissGoToPath() + #expect(browser.activeQuery.text == "notes") + #expect(model.canGoToPath) + + model.presentGoToPath() + let reopenedRequest = try pathRequest(model) + #expect(await !model.goToPath(fixture.file.url.path, request: request)) + #expect(model.presentationCoordinator.activeSheet == .goToPath(reopenedRequest)) + #expect(model.navigation.currentFocusNode?.id == fixture.root.id) + #expect(!model.navigation.canNavigateBack) + #expect(browser.activeQuery.text == "notes") + let replacement = PathFixture() + model.scanState.replaceCurrentSnapshot(makeTestSnapshot(root: replacement.root, store: replacement.store)) + #expect(model.presentationCoordinator.activeSheet == nil) + #expect(await !model.goToPath(fixture.file.url.path, request: reopenedRequest)) + #expect(model.navigation.currentFocusNode?.id == replacement.root.id) + #expect(model.navigation.selectedNodeIDs.isEmpty) + } +} + +private struct PathFixture { + let root = pathRecord(id: "root", path: "/scan", directory: true) + let folder = pathRecord(id: "reports", path: "/scan/Reports", directory: true) + let empty = pathRecord(id: "empty", path: "/scan/Reports/Empty", directory: true) + let file = pathRecord(id: "file", path: "/scan/Reports/notes.txt") + + var store: FileTreeStore { + FileTreeStore(root: root, childrenByID: [ + root.id: [folder, pathRecord(id: "synthetic", path: "/scan/System", synthetic: true)], + folder.id: [empty, file, + pathRecord(id: "spaces", path: "/scan/Reports/space name.txt"), + pathRecord(id: "unicode", path: "/scan/Reports/末尾 ")], + ]) + } +} + +private func pathRecord(id: String, path: String, directory: Bool = false, synthetic: Bool = false) -> FileNodeRecord { + FileNodeRecord( + id: id, url: URL(filePath: path), name: URL(filePath: path).lastPathComponent, + isDirectory: directory, isSymbolicLink: false, allocatedSize: 1, logicalSize: 1, + descendantFileCount: directory ? 0 : 1, lastModified: nil, + isPackage: false, isAccessible: true, isSelfAccessible: true, + isSynthetic: synthetic, isAutoSummarized: false + ) +} + +@MainActor +private func makePathApp(_ fixture: PathFixture) -> AppModel { + let model = AppModel(dependencies: AppDependencies( + preferences: TestAppPreferencesStore(preferences: AppPreferences(scan: .defaults, didCompleteOnboarding: true)), + recentTargets: RecentTargetStore(persistence: TestRecentTargetPersistence(), isAvailable: { _ in false }), + systemActions: .inert + )) + let snapshot = ScanSnapshot( + target: ScanTarget(url: fixture.root.url), treeStore: fixture.store, + startedAt: Date(), finishedAt: Date(), scanWarnings: [], isComplete: true, + source: .imported(ImportedSnapshotContext( + sourceURL: URL(filePath: "/saved.radixscan"), pathMode: .absolute, + liveActionCapability: .pathValidation + )) + ) + model.scanState.replaceCurrentSnapshot(snapshot) + model.navigation.reconcileAfterSnapshotApplied(snapshot) + return model +} + +@MainActor +private func pathRequest(_ model: AppModel) throws -> GoToPathRequest { + guard case .goToPath(let request) = model.presentationCoordinator.activeSheet else { + throw TestFixtureError("Go to Path was not presented.") + } + return request +} From ccf32a6b2bab236c30b988500ee8f009491729ae Mon Sep 17 00:00:00 2001 From: Colin Kim <121141519+colinvkim@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:04:02 -0700 Subject: [PATCH 02/22] feat(navigation): simplify Go to Path dialog --- Radix/Features/Workspace/GoToPathSheet.swift | 139 ++++++++++++------- Radix/Localizable.xcstrings | 46 ------ 2 files changed, 88 insertions(+), 97 deletions(-) diff --git a/Radix/Features/Workspace/GoToPathSheet.swift b/Radix/Features/Workspace/GoToPathSheet.swift index 70a5605..a97215c 100644 --- a/Radix/Features/Workspace/GoToPathSheet.swift +++ b/Radix/Features/Workspace/GoToPathSheet.swift @@ -20,68 +20,44 @@ struct GoToPathSheet: View { } var body: some View { - VStack(alignment: .leading, spacing: 12) { - Text("Go to Path") - .font(.headline) - Text("Enter a path in this scan.") - .foregroundStyle(.secondary) - TextField("Path", text: $path) - .textFieldStyle(.roundedBorder) - .focused($isPathFocused) - .accessibilityLabel("Path") - .disabled(isSubmitting) - .onSubmit { submit() } - .onKeyPress(.downArrow) { moveSelection(by: 1) } - .onKeyPress(.upArrow) { moveSelection(by: -1) } - .onKeyPress(.tab, phases: .down) { press in - guard press.modifiers.isEmpty, let completion = selectedCompletion ?? completions.first else { - return .ignored - } - accept(completion) - return .handled - } - if !completions.isEmpty { - VStack(spacing: 2) { + VStack(spacing: 0) { + pathHeader + Divider() + if showsError { + Text("This path is not available in the current scan.") + .font(.callout) + .foregroundStyle(.red) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + ScrollView { + VStack(alignment: .leading, spacing: 2) { ForEach(completions) { node in Button { accept(node) } label: { - Label { - Text(verbatim: ScanPathResolver.completionPath(for: node)) - .lineLimit(1) - .truncationMode(.middle) - } icon: { - Image(systemName: node.systemImageName) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 8) - .padding(.vertical, 5) - .background(selectedCompletionID == node.id ? Color.accentColor.opacity(0.18) : .clear) - .clipShape(RoundedRectangle(cornerRadius: 5)) - .contentShape(Rectangle()) + breadcrumbLabel(for: node) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(selectedCompletionID == node.id ? Color.accentColor.opacity(0.18) : .clear) + .clipShape(RoundedRectangle(cornerRadius: 5)) + .contentShape(Rectangle()) } .buttonStyle(.plain) + .disabled(isSubmitting) + .accessibilityLabel(Text(verbatim: ScanPathResolver.completionPath(for: node))) .accessibilityAddTraits(selectedCompletionID == node.id ? .isSelected : []) + .help(ScanPathResolver.completionPath(for: node)) } } - .disabled(isSubmitting) - } - if showsError { - Text("This path is not available in the current scan.") - .font(.callout) - .foregroundStyle(.red) - } - HStack { - Spacer() - Button("Cancel", action: onCancel) - .keyboardShortcut(.cancelAction) - Button("Go") { submit() } - .keyboardShortcut(.defaultAction) - .disabled(path.isEmpty || isSubmitting) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) } + .frame(height: 240) } - .padding(20) - .frame(width: 480) + .frame(width: 520) .onDisappear { submissionTask?.cancel() } .task { isPathFocused = true } .task(id: path) { @@ -103,6 +79,67 @@ struct GoToPathSheet: View { } } + private var pathHeader: some View { + HStack(spacing: 10) { + TextField("Go to Path", text: $path) + .textFieldStyle(.plain) + .font(.title3) + .focused($isPathFocused) + .accessibilityLabel("Path") + .disabled(isSubmitting) + .onSubmit { submit() } + .onKeyPress(.downArrow) { moveSelection(by: 1) } + .onKeyPress(.upArrow) { moveSelection(by: -1) } + .onKeyPress(.tab, phases: .down) { press in + guard press.modifiers.isEmpty, let completion = selectedCompletion ?? completions.first else { + return .ignored + } + accept(completion) + return .handled + } + Button("Go") { submit() } + .controlSize(.small) + .keyboardShortcut(.defaultAction) + .disabled(path.isEmpty || isSubmitting) + Button(action: onCancel) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 16)) + .foregroundStyle(.secondary) + .frame(width: 24, height: 24) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .keyboardShortcut(.cancelAction) + .accessibilityLabel("Cancel") + .help("Cancel") + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + + private func breadcrumbLabel(for node: FileNodeRecord) -> some View { + let components = node.url.pathComponents.filter { $0 != "/" } + let ancestors = components.dropLast().joined(separator: " › ") + return HStack(spacing: 6) { + if !ancestors.isEmpty { + Text(verbatim: ancestors) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.head) + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(.tertiary) + .accessibilityHidden(true) + } + Text(verbatim: components.last ?? node.url.path) + .fontWeight(.medium) + .foregroundStyle(.primary) + .lineLimit(1) + .truncationMode(.middle) + .layoutPriority(1) + } + } + private var isSubmitting: Bool { submissionTask != nil } private var selectedCompletion: FileNodeRecord? { diff --git a/Radix/Localizable.xcstrings b/Radix/Localizable.xcstrings index 1458d69..5f127c3 100644 --- a/Radix/Localizable.xcstrings +++ b/Radix/Localizable.xcstrings @@ -139,52 +139,6 @@ } } }, - "Enter a path in this scan." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Enter a path in this scan." - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Gib einen Pfad in diesem Scan ein." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Introduce una ruta de este análisis." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Saisissez un chemin dans cette analyse." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Inserisci un percorso di questa scansione." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Введите путь в этом сканировании." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "输入此扫描中的路径。" - } - } - } - }, "Go to Path" : { "localizations" : { "en" : { From 1010e9617fdf7f53d3acf465cade68f754a355ef Mon Sep 17 00:00:00 2001 From: Colin Kim <121141519+colinvkim@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:18:27 -0700 Subject: [PATCH 03/22] feat(navigation): match Finder path activation --- Radix/Features/Workspace/GoToPathSheet.swift | 27 ++++++++++-- Radix/Localizable.xcstrings | 46 -------------------- Radix/Services/ScanPathResolver.swift | 8 ++-- RadixCoreTests/GoToPathTests.swift | 7 ++- 4 files changed, 33 insertions(+), 55 deletions(-) diff --git a/Radix/Features/Workspace/GoToPathSheet.swift b/Radix/Features/Workspace/GoToPathSheet.swift index a97215c..51f4adb 100644 --- a/Radix/Features/Workspace/GoToPathSheet.swift +++ b/Radix/Features/Workspace/GoToPathSheet.swift @@ -47,8 +47,24 @@ struct GoToPathSheet: View { } .buttonStyle(.plain) .disabled(isSubmitting) + .highPriorityGesture( + TapGesture(count: 2) + .exclusively(before: TapGesture(count: 1)) + .onEnded { activation in + guard !isSubmitting else { return } + switch activation { + case .first: + navigate(to: node) + case .second: + accept(node) + } + } + ) .accessibilityLabel(Text(verbatim: ScanPathResolver.completionPath(for: node))) .accessibilityAddTraits(selectedCompletionID == node.id ? .isSelected : []) + .accessibilityAction(named: Text("Go to Path")) { + navigate(to: node) + } .help(ScanPathResolver.completionPath(for: node)) } } @@ -86,6 +102,7 @@ struct GoToPathSheet: View { .font(.title3) .focused($isPathFocused) .accessibilityLabel("Path") + .accessibilityAction(named: Text("Go to Path")) { submit() } .disabled(isSubmitting) .onSubmit { submit() } .onKeyPress(.downArrow) { moveSelection(by: 1) } @@ -97,10 +114,6 @@ struct GoToPathSheet: View { accept(completion) return .handled } - Button("Go") { submit() } - .controlSize(.small) - .keyboardShortcut(.defaultAction) - .disabled(path.isEmpty || isSubmitting) Button(action: onCancel) { Image(systemName: "xmark.circle.fill") .font(.system(size: 16)) @@ -161,6 +174,12 @@ struct GoToPathSheet: View { isPathFocused = true } + private func navigate(to node: FileNodeRecord) { + guard !isSubmitting else { return } + accept(node) + submit() + } + private func submit() { guard !isSubmitting else { return } let input = selectedCompletion.map(ScanPathResolver.completionPath) ?? path diff --git a/Radix/Localizable.xcstrings b/Radix/Localizable.xcstrings index 5f127c3..f6c4ab7 100644 --- a/Radix/Localizable.xcstrings +++ b/Radix/Localizable.xcstrings @@ -47,52 +47,6 @@ } } }, - "Go" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Go" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Gehe zu" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ir" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aller" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vai" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Перейти" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "前往" - } - } - } - }, "Path" : { "localizations" : { "en" : { diff --git a/Radix/Services/ScanPathResolver.swift b/Radix/Services/ScanPathResolver.swift index 2deeb20..1725c37 100644 --- a/Radix/Services/ScanPathResolver.swift +++ b/Radix/Services/ScanPathResolver.swift @@ -50,6 +50,8 @@ nonisolated struct ScanPathResolver: Sendable { let parentPath = listsChildren ? path : "/" + components.dropLast().joined(separator: "/") guard let parent = try node(at: parentPath, cancellationCheck: cancellationCheck), parent.isDirectory else { return [] } + if listsChildren, limit == 1 { return [parent] } + let childLimit = listsChildren ? limit - 1 : limit var matches: [FileNodeRecord] = [] for child in try treeStore.children(of: parent.id, cancellationCheck: cancellationCheck) { try cancellationCheck() @@ -58,12 +60,12 @@ nonisolated struct ScanPathResolver: Sendable { of: prefix, options: [.anchored, .caseInsensitive] ) != nil else { continue } let insertion = matches.firstIndex { precedes(child, $0) } ?? matches.endIndex - if insertion < limit { + if insertion < childLimit { matches.insert(child, at: insertion) - if matches.count > limit { matches.removeLast() } + if matches.count > childLimit { matches.removeLast() } } } - return matches + return listsChildren ? [parent] + matches : matches } static func completionPath(for node: FileNodeRecord) -> String { diff --git a/RadixCoreTests/GoToPathTests.swift b/RadixCoreTests/GoToPathTests.swift index d9e9507..4c9c6a9 100644 --- a/RadixCoreTests/GoToPathTests.swift +++ b/RadixCoreTests/GoToPathTests.swift @@ -32,11 +32,11 @@ struct ScanPathResolverTests { let resolver = ScanPathResolver(treeStore: fixture.store, hiddenNodeIDs: [fixture.folder.id]) #expect(try resolver.resolve("/scan/Reports/notes.txt") == nil) #expect(try resolver.resolve("/scan/System") == nil) - #expect(try resolver.completions(for: "/scan/").isEmpty) + #expect(try resolver.completions(for: "/scan/").map(\.id) == [fixture.root.id]) #expect(try resolver.completions(for: "/sc").map(\.id) == [fixture.root.id]) let visible = ScanPathResolver(treeStore: fixture.store) #expect(try visible.completions(for: "/scan/rep").map(\.id) == [fixture.folder.id]) - #expect(try visible.completions(for: "/scan/Reports/Empty/").isEmpty) + #expect(try visible.completions(for: "/scan/Reports/Empty/").map(\.id) == [fixture.empty.id]) } @Test @@ -45,6 +45,9 @@ struct ScanPathResolverTests { let root = pathRecord(id: "root", path: "/scan", directory: true) let resolver = ScanPathResolver(treeStore: FileTreeStore(root: root, childrenByID: [root.id: children])) #expect(try resolver.completions(for: "/scan/item").map(\.id) == (1...8).map { "id-\($0)" }) + #expect(try resolver.completions(for: "/scan/").map(\.id) == [root.id] + (1...7).map { "id-\($0)" }) + #expect(try resolver.completions(for: "/scan/", limit: 1).map(\.id) == [root.id]) + #expect(try resolver.completions(for: "/scan/", limit: 0).isEmpty) let probe = CancellationProbe(throwOnCheck: 5) #expect(throws: CancellationError.self) { try resolver.completions(for: "/scan/", cancellationCheck: probe.check) From f337640466f7e5b334df66827c791e215306125d Mon Sep 17 00:00:00 2001 From: Colin Kim <121141519+colinvkim@users.noreply.github.com> Date: Tue, 27 Oct 2026 13:32:59 -0700 Subject: [PATCH 04/22] fix(navigation): restore native path editing --- Radix/App/RadixCommands.swift | 1 + Radix/Features/Workspace/GoToPathSheet.swift | 70 ++++++++++++++------ 2 files changed, 51 insertions(+), 20 deletions(-) diff --git a/Radix/App/RadixCommands.swift b/Radix/App/RadixCommands.swift index 06c54c5..bd34e0e 100644 --- a/Radix/App/RadixCommands.swift +++ b/Radix/App/RadixCommands.swift @@ -265,6 +265,7 @@ struct RadixCommands: Commands { private var canUseWorkspaceCommands: Bool { isWorkspaceWindowFocused == true && appModel.canUseWorkspaceCommands + && appModel.presentationCoordinator.activeDestination == nil } private var addSelectionToDiscardPileTitle: String { diff --git a/Radix/Features/Workspace/GoToPathSheet.swift b/Radix/Features/Workspace/GoToPathSheet.swift index 51f4adb..0625a75 100644 --- a/Radix/Features/Workspace/GoToPathSheet.swift +++ b/Radix/Features/Workspace/GoToPathSheet.swift @@ -31,18 +31,15 @@ struct GoToPathSheet: View { .padding(.horizontal, 16) .padding(.vertical, 12) } - ScrollView { - VStack(alignment: .leading, spacing: 2) { + ScrollViewReader { proxy in + List(selection: $selectedCompletionID) { ForEach(completions) { node in Button { accept(node) } label: { breadcrumbLabel(for: node) .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 8) .padding(.vertical, 5) - .background(selectedCompletionID == node.id ? Color.accentColor.opacity(0.18) : .clear) - .clipShape(RoundedRectangle(cornerRadius: 5)) .contentShape(Rectangle()) } .buttonStyle(.plain) @@ -60,16 +57,21 @@ struct GoToPathSheet: View { } } ) + .tag(node.id) + .id(node.id) + .listRowSeparator(.hidden) .accessibilityLabel(Text(verbatim: ScanPathResolver.completionPath(for: node))) - .accessibilityAddTraits(selectedCompletionID == node.id ? .isSelected : []) .accessibilityAction(named: Text("Go to Path")) { navigate(to: node) } .help(ScanPathResolver.completionPath(for: node)) } } - .padding(8) - .frame(maxWidth: .infinity, alignment: .leading) + .listStyle(.inset) + .scrollContentBackground(.hidden) + .onChange(of: selectedCompletionID) { _, nodeID in + if let nodeID { proxy.scrollTo(nodeID) } + } } .frame(height: 240) } @@ -78,8 +80,6 @@ struct GoToPathSheet: View { .task { isPathFocused = true } .task(id: path) { showsError = false - selectedCompletionID = nil - completions = [] let input = path let resolver = resolver let query = Task.detached(priority: .userInitiated) { @@ -92,12 +92,28 @@ struct GoToPathSheet: View { } guard !Task.isCancelled, path == input else { return } completions = matches ?? [] + if let selectedCompletionID, + !completions.contains(where: { $0.id == selectedCompletionID }) { + self.selectedCompletionID = nil + } } } + private var editedPath: Binding { + Binding( + get: { path }, + set: { input in + guard path != input else { return } + path = input + selectedCompletionID = nil + completions = [] + } + ) + } + private var pathHeader: some View { HStack(spacing: 10) { - TextField("Go to Path", text: $path) + TextField("Go to Path", text: editedPath) .textFieldStyle(.plain) .font(.title3) .focused($isPathFocused) @@ -105,8 +121,18 @@ struct GoToPathSheet: View { .accessibilityAction(named: Text("Go to Path")) { submit() } .disabled(isSubmitting) .onSubmit { submit() } - .onKeyPress(.downArrow) { moveSelection(by: 1) } - .onKeyPress(.upArrow) { moveSelection(by: -1) } + .onKeyPress(.downArrow, phases: [.down, .repeat]) { press in + guard press.modifiers.intersection([.command, .option, .control, .shift]).isEmpty else { + return .ignored + } + return moveSelection(by: 1) + } + .onKeyPress(.upArrow, phases: [.down, .repeat]) { press in + guard press.modifiers.intersection([.command, .option, .control, .shift]).isEmpty else { + return .ignored + } + return moveSelection(by: -1) + } .onKeyPress(.tab, phases: .down) { press in guard press.modifiers.isEmpty, let completion = selectedCompletion ?? completions.first else { return .ignored @@ -135,18 +161,22 @@ struct GoToPathSheet: View { let ancestors = components.dropLast().joined(separator: " › ") return HStack(spacing: 6) { if !ancestors.isEmpty { - Text(verbatim: ancestors) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.head) + Group { + if selectedCompletionID == node.id { + Text(verbatim: ancestors) + } else { + Text(verbatim: ancestors) + .foregroundStyle(.secondary) + } + } + .lineLimit(1) + .truncationMode(.head) Image(systemName: "chevron.right") .font(.caption2) - .foregroundStyle(.tertiary) .accessibilityHidden(true) } Text(verbatim: components.last ?? node.url.path) .fontWeight(.medium) - .foregroundStyle(.primary) .lineLimit(1) .truncationMode(.middle) .layoutPriority(1) @@ -170,7 +200,7 @@ struct GoToPathSheet: View { private func accept(_ node: FileNodeRecord) { path = ScanPathResolver.completionPath(for: node) - selectedCompletionID = nil + selectedCompletionID = node.id isPathFocused = true } From c81f4651d35dddebb64894344385ab7ad15c74dc Mon Sep 17 00:00:00 2001 From: Colin Kim <121141519+colinvkim@users.noreply.github.com> Date: Tue, 27 Oct 2026 13:51:27 -0700 Subject: [PATCH 05/22] feat(navigation): emphasize path suggestions --- Radix/Features/Workspace/GoToPathSheet.swift | 45 ++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/Radix/Features/Workspace/GoToPathSheet.swift b/Radix/Features/Workspace/GoToPathSheet.swift index 0625a75..be7d179 100644 --- a/Radix/Features/Workspace/GoToPathSheet.swift +++ b/Radix/Features/Workspace/GoToPathSheet.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI struct GoToPathSheet: View { @@ -60,6 +61,15 @@ struct GoToPathSheet: View { .tag(node.id) .id(node.id) .listRowSeparator(.hidden) + .listRowBackground( + selectedCompletionID == node.id + ? PathSelectionMaterial() + .clipShape(RoundedRectangle(cornerRadius: 8)) + .padding(.horizontal, 10) + .allowsHitTesting(false) + .accessibilityHidden(true) + : nil + ) .accessibilityLabel(Text(verbatim: ScanPathResolver.completionPath(for: node))) .accessibilityAction(named: Text("Go to Path")) { navigate(to: node) @@ -181,6 +191,7 @@ struct GoToPathSheet: View { .truncationMode(.middle) .layoutPriority(1) } + .foregroundStyle(CompletionForeground(isSelected: selectedCompletionID == node.id)) } private var isSubmitting: Bool { submissionTask != nil } @@ -222,3 +233,37 @@ struct GoToPathSheet: View { } } } + +private struct PathSelectionMaterial: NSViewRepresentable { + func makeNSView(context: Context) -> NSVisualEffectView { + let view = NSVisualEffectView() + view.material = .selection + view.blendingMode = .withinWindow + view.state = .followsWindowActiveState + return view + } + + func updateNSView(_ view: NSVisualEffectView, context: Context) { + view.isEmphasized = context.environment.pathSelectionAppearsActive + } +} + +private struct CompletionForeground: ShapeStyle { + let isSelected: Bool + + func resolve(in environment: EnvironmentValues) -> some ShapeStyle { + guard isSelected else { return AnyShapeStyle(.primary) } + return AnyShapeStyle(Color(nsColor: environment.pathSelectionAppearsActive + ? .alternateSelectedControlTextColor : .unemphasizedSelectedTextColor)) + } +} + +private extension EnvironmentValues { + nonisolated var pathSelectionAppearsActive: Bool { + if #available(macOS 15.0, *) { + appearsActive + } else { + controlActiveState != .inactive + } + } +} From 1bf22c214c9046579a3b54759a691b72e8828a64 Mon Sep 17 00:00:00 2001 From: Colin Kim <121141519+colinvkim@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:46:51 -0700 Subject: [PATCH 06/22] feat(navigation): compact path suggestions --- Radix/Features/Workspace/GoToPathSheet.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Radix/Features/Workspace/GoToPathSheet.swift b/Radix/Features/Workspace/GoToPathSheet.swift index be7d179..6aceb06 100644 --- a/Radix/Features/Workspace/GoToPathSheet.swift +++ b/Radix/Features/Workspace/GoToPathSheet.swift @@ -40,7 +40,6 @@ struct GoToPathSheet: View { } label: { breadcrumbLabel(for: node) .frame(maxWidth: .infinity, alignment: .leading) - .padding(.vertical, 5) .contentShape(Rectangle()) } .buttonStyle(.plain) @@ -83,7 +82,7 @@ struct GoToPathSheet: View { if let nodeID { proxy.scrollTo(nodeID) } } } - .frame(height: 240) + .frame(height: 212) } .frame(width: 520) .onDisappear { submissionTask?.cancel() } @@ -186,7 +185,6 @@ struct GoToPathSheet: View { .accessibilityHidden(true) } Text(verbatim: components.last ?? node.url.path) - .fontWeight(.medium) .lineLimit(1) .truncationMode(.middle) .layoutPriority(1) From 550c248848c651d086c556328b86953406cb4a54 Mon Sep 17 00:00:00 2001 From: Colin Kim <121141519+colinvkim@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:21:52 -0700 Subject: [PATCH 07/22] test(navigation): cover path jump edge cases --- RadixCoreTests/GoToPathTests.swift | 42 ++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/RadixCoreTests/GoToPathTests.swift b/RadixCoreTests/GoToPathTests.swift index 4c9c6a9..0c61439 100644 --- a/RadixCoreTests/GoToPathTests.swift +++ b/RadixCoreTests/GoToPathTests.swift @@ -39,6 +39,13 @@ struct ScanPathResolverTests { #expect(try visible.completions(for: "/scan/Reports/Empty/").map(\.id) == [fixture.empty.id]) } + @Test + func exactScanRootCompletesWithoutATrailingSlash() throws { + let fixture = PathFixture() + let resolver = ScanPathResolver(treeStore: fixture.store) + #expect(try resolver.completions(for: "/scan").map(\.id) == [fixture.root.id]) + } + @Test func completionIsBoundedNaturallyOrderedAndCancellable() throws { let children = (1...20).reversed().map { pathRecord(id: "id-\($0)", path: "/scan/item\($0)") } @@ -121,6 +128,37 @@ struct GoToPathTests { } } + @Test(arguments: [false, true], [false, true]) + func directoryJumpUsesAvailableContents(isSummarized: Bool, hasMaterializedChildren: Bool) async throws { + let fixture = PathFixture() + let directory = FileNodeRecord( + id: "directory", url: URL(filePath: "/scan/Reports/Directory"), name: "Directory", + isDirectory: true, isSymbolicLink: false, allocatedSize: 1, logicalSize: 1, + descendantFileCount: 1, lastModified: nil, + isPackage: !isSummarized, isAccessible: true, isSelfAccessible: true, + isSynthetic: false, isAutoSummarized: isSummarized + ) + let child = pathRecord(id: "child", path: "/scan/Reports/Directory/child.txt") + var childrenByID = [ + fixture.root.id: [fixture.folder], + fixture.folder.id: [directory], + ] + if hasMaterializedChildren { childrenByID[directory.id] = [child] } + let store = FileTreeStore(root: fixture.root, childrenByID: childrenByID) + let model = makePathApp(fixture, store: store) + defer { model.cleanup() } + model.select(nodeID: fixture.folder.id) + model.presentGoToPath() + let request = try pathRequest(model) + + #expect(await model.goToPath(directory.url.path, request: request)) + #expect(model.navigation.currentFocusNode?.id == (hasMaterializedChildren ? directory.id : fixture.folder.id)) + #expect(model.navigation.selectedNodeIDs == (hasMaterializedChildren ? [] : [directory.id])) + #expect(model.navigation.tableNodes.map(\.id) == (hasMaterializedChildren ? [child.id] : [directory.id])) + model.navigateBack() + #expect(model.navigation.currentFocusNode?.id == fixture.root.id) + } + @Test func invalidAndCancelledJumpsPreserveBrowsingAndOldScansCannotNavigate() async throws { let fixture = PathFixture() @@ -182,14 +220,14 @@ private func pathRecord(id: String, path: String, directory: Bool = false, synth } @MainActor -private func makePathApp(_ fixture: PathFixture) -> AppModel { +private func makePathApp(_ fixture: PathFixture, store: FileTreeStore? = nil) -> AppModel { let model = AppModel(dependencies: AppDependencies( preferences: TestAppPreferencesStore(preferences: AppPreferences(scan: .defaults, didCompleteOnboarding: true)), recentTargets: RecentTargetStore(persistence: TestRecentTargetPersistence(), isAvailable: { _ in false }), systemActions: .inert )) let snapshot = ScanSnapshot( - target: ScanTarget(url: fixture.root.url), treeStore: fixture.store, + target: ScanTarget(url: fixture.root.url), treeStore: store ?? fixture.store, startedAt: Date(), finishedAt: Date(), scanWarnings: [], isComplete: true, source: .imported(ImportedSnapshotContext( sourceURL: URL(filePath: "/saved.radixscan"), pathMode: .absolute, From 17c1a9434c6c29f9c5b04fa43d7163d6e8969c9d Mon Sep 17 00:00:00 2001 From: Colin Kim <121141519+colinvkim@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:36:16 -0700 Subject: [PATCH 08/22] fix(navigation): handle path jump edge cases --- Radix/Services/ScanPathResolver.swift | 6 +++--- Radix/ViewModels/AppModel.swift | 4 +++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/Radix/Services/ScanPathResolver.swift b/Radix/Services/ScanPathResolver.swift index 1725c37..357763d 100644 --- a/Radix/Services/ScanPathResolver.swift +++ b/Radix/Services/ScanPathResolver.swift @@ -37,14 +37,14 @@ nonisolated struct ScanPathResolver: Sendable { ) throws -> [FileNodeRecord] { guard limit > 0, let path = absolutePath(input) else { return [] } let rootPath = normalizedPath(treeStore.root.url.path) - if path != rootPath, + let listsChildren = input.hasSuffix("/") || input == "~" + || input.split(separator: "/").last.map { $0 == "." || $0 == ".." } == true + if !listsChildren, rootPath.range(of: path, options: [.anchored, .caseInsensitive]) != nil, isVisible(treeStore.root) { return [treeStore.root] } - let listsChildren = input.hasSuffix("/") || input == "~" - || input.split(separator: "/").last.map { $0 == "." || $0 == ".." } == true let components = path.split(separator: "/").map(String.init) let prefix = listsChildren ? "" : components.last ?? "" let parentPath = listsChildren ? path : "/" + components.dropLast().joined(separator: "/") diff --git a/Radix/ViewModels/AppModel.swift b/Radix/ViewModels/AppModel.swift index f1da790..43055c6 100644 --- a/Radix/ViewModels/AppModel.swift +++ b/Radix/ViewModels/AppModel.swift @@ -2025,7 +2025,9 @@ final class AppModel: ObservableObject { isVisibleNavigationNode(node.id) else { return false } cancelDeferredNavigationAction() fileBrowser?.showCurrentContents() - if node.isDirectory { + let contentsAreUnavailable = FileBrowserPackageContents.areHidden(for: node, fileTreeStore: snapshot.treeStore) + || (node.isAutoSummarized && !snapshot.treeStore.containsChildren(id: node.id)) + if node.isDirectory && !contentsAreUnavailable { navigationModel.clearSelection() navigationModel.focus(nodeID: node.id) } else { From 9daf204f6c499a038017f4c9c5ca4e7c3654a376 Mon Sep 17 00:00:00 2001 From: Colin Kim <121141519+colinvkim@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:45:07 -0700 Subject: [PATCH 09/22] test(navigation): preserve scan-root prefix completion --- RadixCoreTests/GoToPathTests.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/RadixCoreTests/GoToPathTests.swift b/RadixCoreTests/GoToPathTests.swift index 0c61439..cfbfe98 100644 --- a/RadixCoreTests/GoToPathTests.swift +++ b/RadixCoreTests/GoToPathTests.swift @@ -46,6 +46,13 @@ struct ScanPathResolverTests { #expect(try resolver.completions(for: "/scan").map(\.id) == [fixture.root.id]) } + @Test(arguments: ["/", "/scan/"]) + func ancestorsOfScanRootCompleteToTheRoot(input: String) throws { + let root = pathRecord(id: "root", path: "/scan/nested", directory: true) + let resolver = ScanPathResolver(treeStore: FileTreeStore(root: root)) + #expect(try resolver.completions(for: input).map(\.id) == [root.id]) + } + @Test func completionIsBoundedNaturallyOrderedAndCancellable() throws { let children = (1...20).reversed().map { pathRecord(id: "id-\($0)", path: "/scan/item\($0)") } From 6a1e89538e495fb0d700fb1d063ba0a97a9774e6 Mon Sep 17 00:00:00 2001 From: Colin Kim <121141519+colinvkim@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:51:03 -0700 Subject: [PATCH 10/22] fix(navigation): retain ancestor path suggestions --- Radix/Services/ScanPathResolver.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Radix/Services/ScanPathResolver.swift b/Radix/Services/ScanPathResolver.swift index 357763d..561a78c 100644 --- a/Radix/Services/ScanPathResolver.swift +++ b/Radix/Services/ScanPathResolver.swift @@ -39,7 +39,7 @@ nonisolated struct ScanPathResolver: Sendable { let rootPath = normalizedPath(treeStore.root.url.path) let listsChildren = input.hasSuffix("/") || input == "~" || input.split(separator: "/").last.map { $0 == "." || $0 == ".." } == true - if !listsChildren, + if (path != rootPath || !listsChildren), rootPath.range(of: path, options: [.anchored, .caseInsensitive]) != nil, isVisible(treeStore.root) { return [treeStore.root] From 2cb2a8c9cb7592b8b15161f48217057e08f554d6 Mon Sep 17 00:00:00 2001 From: Colin Kim <121141519+colinvkim@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:51:03 -0700 Subject: [PATCH 11/22] feat(navigation): refine native path entry --- Radix/Features/Workspace/GoToPathSheet.swift | 204 +++++++++++++++---- 1 file changed, 168 insertions(+), 36 deletions(-) diff --git a/Radix/Features/Workspace/GoToPathSheet.swift b/Radix/Features/Workspace/GoToPathSheet.swift index 6aceb06..f47703d 100644 --- a/Radix/Features/Workspace/GoToPathSheet.swift +++ b/Radix/Features/Workspace/GoToPathSheet.swift @@ -11,7 +11,7 @@ struct GoToPathSheet: View { @State private var selectedCompletionID: FileNodeRecord.ID? @State private var showsError = false @State private var submissionTask: Task? - @FocusState private var isPathFocused: Bool + @State private var focusRequest = UUID() init(resolver: ScanPathResolver, onCancel: @escaping () -> Void, onGo: @escaping (String) async -> Bool) { self.resolver = resolver @@ -85,8 +85,8 @@ struct GoToPathSheet: View { .frame(height: 212) } .frame(width: 520) + .onExitCommand(perform: onCancel) .onDisappear { submissionTask?.cancel() } - .task { isPathFocused = true } .task(id: path) { showsError = false let input = path @@ -122,34 +122,11 @@ struct GoToPathSheet: View { private var pathHeader: some View { HStack(spacing: 10) { - TextField("Go to Path", text: editedPath) - .textFieldStyle(.plain) - .font(.title3) - .focused($isPathFocused) - .accessibilityLabel("Path") + NativePathField(text: editedPath, focusRequest: focusRequest, onCommand: handleInputCommand) + .frame(maxWidth: .infinity) .accessibilityAction(named: Text("Go to Path")) { submit() } .disabled(isSubmitting) - .onSubmit { submit() } - .onKeyPress(.downArrow, phases: [.down, .repeat]) { press in - guard press.modifiers.intersection([.command, .option, .control, .shift]).isEmpty else { - return .ignored - } - return moveSelection(by: 1) - } - .onKeyPress(.upArrow, phases: [.down, .repeat]) { press in - guard press.modifiers.intersection([.command, .option, .control, .shift]).isEmpty else { - return .ignored - } - return moveSelection(by: -1) - } - .onKeyPress(.tab, phases: .down) { press in - guard press.modifiers.isEmpty, let completion = selectedCompletion ?? completions.first else { - return .ignored - } - accept(completion) - return .handled - } - Button(action: onCancel) { + Button(action: clearOrCancel) { Image(systemName: "xmark.circle.fill") .font(.system(size: 16)) .foregroundStyle(.secondary) @@ -157,9 +134,8 @@ struct GoToPathSheet: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .keyboardShortcut(.cancelAction) - .accessibilityLabel("Cancel") - .help("Cancel") + .accessibilityLabel(path.isEmpty ? Text("Cancel") : Text("Clear")) + .help(path.isEmpty ? Text("Cancel") : Text("Clear")) } .padding(.horizontal, 16) .padding(.vertical, 12) @@ -198,19 +174,46 @@ struct GoToPathSheet: View { completions.first { $0.id == selectedCompletionID } } - private func moveSelection(by offset: Int) -> KeyPress.Result { - guard !completions.isEmpty else { return .ignored } + private func handleInputCommand(_ command: NativePathField.Command) -> Bool { + switch command { + case let .moveSelection(offset): + return moveSelection(by: offset) + case .complete: + guard let completion = selectedCompletion ?? completions.first else { return false } + accept(completion) + return true + case .submit: + submit() + return true + case .cancel: + onCancel() + return true + } + } + + private func moveSelection(by offset: Int) -> Bool { + guard !completions.isEmpty else { return false } let index = selectedCompletionID.flatMap { id in completions.firstIndex { $0.id == id } } let next = index.map { min(max($0 + offset, 0), completions.count - 1) } ?? (offset > 0 ? 0 : completions.count - 1) selectedCompletionID = completions[next].id - return .handled + return true } private func accept(_ node: FileNodeRecord) { path = ScanPathResolver.completionPath(for: node) selectedCompletionID = node.id - isPathFocused = true + focusRequest = UUID() + } + + private func clearOrCancel() { + guard !path.isEmpty else { onCancel(); return } + submissionTask?.cancel() + path = "" + selectedCompletionID = nil + completions = [] + showsError = false + focusRequest = UUID() } private func navigate(to node: FileNodeRecord) { @@ -227,7 +230,136 @@ struct GoToPathSheet: View { submissionTask = nil guard !Task.isCancelled else { return } showsError = !succeeded - if !succeeded { isPathFocused = true } + if !succeeded { focusRequest = UUID() } + } + } +} + +private struct NativePathField: NSViewRepresentable { + enum Command { + case moveSelection(Int) + case complete + case submit + case cancel + } + + @Binding var text: String + let focusRequest: UUID + let onCommand: (Command) -> Bool + + func makeCoordinator() -> Coordinator { Coordinator(parent: self) } + + func makeNSView(context: Context) -> Field { + let field = Field() + field.stringValue = text + field.placeholderString = String(localized: "Go to Path") + field.font = .preferredFont(forTextStyle: .title3) + field.textColor = .labelColor + field.isEditable = true + field.isSelectable = true + field.isBezeled = false + field.drawsBackground = false + field.focusRingType = .none + field.usesSingleLineMode = true + field.lineBreakMode = .byClipping + field.cell?.isScrollable = true + field.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + field.setAccessibilityLabel(String(localized: "Path")) + field.delegate = context.coordinator + return field + } + + func updateNSView(_ field: Field, context: Context) { + context.coordinator.parent = self + field.isEnabled = context.environment.isEnabled + context.coordinator.update(field) + } + + final class Field: NSTextField { + private var pendingFocus = true + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if let window { window.initialFirstResponder = self } + focusIfRequested() + } + + func requestFocus() { pendingFocus = true } + + func focusIfRequested() { + guard pendingFocus, isEnabled, let window else { return } + pendingFocus = !window.makeFirstResponder(self) + } + } + + final class Coordinator: NSObject, NSTextFieldDelegate { + var parent: NativePathField + private var appliedRequest: UUID? + private var applyingEdit = false + + init(parent: NativePathField) { self.parent = parent } + + func update(_ field: Field) { + let hasRequest = appliedRequest != parent.focusRequest + if hasRequest { field.requestFocus() } + field.focusIfRequested() + guard let editor = field.currentEditor() as? NSTextView else { + field.stringValue = parent.text + appliedRequest = parent.focusRequest + return + } + guard hasRequest else { return } + guard editor.string != parent.text else { + appliedRequest = parent.focusRequest + return + } + applyingEdit = true + defer { applyingEdit = false } + editor.breakUndoCoalescing() + if editor.hasMarkedText() { + editor.inputContext?.discardMarkedText() + editor.unmarkText() + } + let previous = editor.string + let prefixCount = zip(previous, parent.text).prefix { $0.0 == $0.1 }.count + let prefixLength = previous.prefix(prefixCount).utf16.count + let replacement = String(parent.text.dropFirst(prefixCount)) + let range = NSRange(location: prefixLength, length: previous.utf16.count - prefixLength) + let attributed = NSAttributedString(string: replacement, attributes: editor.typingAttributes) + if editor.performValidatedReplacement(in: range, with: attributed) { + editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0)) + appliedRequest = parent.focusRequest + } + editor.breakUndoCoalescing() + } + + func controlTextDidBeginEditing(_ notification: Notification) { + guard let field = notification.object as? NSTextField, + let editor = field.currentEditor() as? NSTextView else { return } + editor.allowsUndo = true + } + + func controlTextDidChange(_ notification: Notification) { + guard !applyingEdit, let field = notification.object as? NSTextField else { return } + parent.text = field.stringValue + } + + func control(_ control: NSControl, textView: NSTextView, doCommandBy selector: Selector) -> Bool { + guard !textView.hasMarkedText() else { return false } + switch selector { + case #selector(NSResponder.moveUp(_:)): + return parent.onCommand(.moveSelection(-1)) + case #selector(NSResponder.moveDown(_:)): + return parent.onCommand(.moveSelection(1)) + case #selector(NSResponder.insertTab(_:)): + return parent.onCommand(.complete) + case #selector(NSResponder.insertNewline(_:)): + return parent.onCommand(.submit) + case #selector(NSResponder.cancelOperation(_:)): + return parent.onCommand(.cancel) + default: + return false + } } } } From 5fed0bd3425f49a887a9b5e549663e7306405903 Mon Sep 17 00:00:00 2001 From: Colin Kim <121141519+colinvkim@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:05:53 -0700 Subject: [PATCH 12/22] test(navigation): cover path request lifecycle --- RadixCoreTests/GoToPathTests.swift | 60 +++++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/RadixCoreTests/GoToPathTests.swift b/RadixCoreTests/GoToPathTests.swift index cfbfe98..6ec3c7f 100644 --- a/RadixCoreTests/GoToPathTests.swift +++ b/RadixCoreTests/GoToPathTests.swift @@ -167,7 +167,7 @@ struct GoToPathTests { } @Test - func invalidAndCancelledJumpsPreserveBrowsingAndOldScansCannotNavigate() async throws { + func invalidPathsAndStaleRequestsPreserveBrowsing() async throws { let fixture = PathFixture() let model = makePathApp(fixture) defer { model.cleanup() } @@ -198,6 +198,64 @@ struct GoToPathTests { #expect(model.navigation.currentFocusNode?.id == replacement.root.id) #expect(model.navigation.selectedNodeIDs.isEmpty) } + + @Test + func preCancelledJumpPreservesBrowsingAndAllowsRetry() async throws { + let fixture = PathFixture() + let model = makePathApp(fixture) + defer { model.cleanup() } + let browser = model.makeFileBrowserModel() + browser.setActiveQuery(FileBrowserQuery(text: "notes")) + model.select(nodeID: fixture.folder.id) + model.presentGoToPath() + let request = try pathRequest(model) + + let cancelledJump = Task { @MainActor in + withUnsafeCurrentTask { $0?.cancel() } + return await model.goToPath(fixture.empty.url.path, request: request) + } + #expect(await !cancelledJump.value) + #expect(model.navigation.currentFocusNode?.id == fixture.root.id) + #expect(model.navigation.selectedNodeIDs == [fixture.folder.id]) + #expect(!model.navigation.canNavigateBack) + #expect(browser.activeQuery.text == "notes") + #expect(model.presentationCoordinator.activeSheet == .goToPath(request)) + + #expect(await model.goToPath(fixture.empty.url.path, request: request)) + #expect(model.navigation.currentFocusNode?.id == fixture.empty.id) + #expect(model.presentationCoordinator.activeSheet == nil) + } + + @Test + func changedTreeContentsDismissPathSheetWithoutChangingScanIdentity() async throws { + let fixture = PathFixture() + let model = makePathApp(fixture) + defer { model.cleanup() } + let browser = model.makeFileBrowserModel() + browser.setActiveQuery(FileBrowserQuery(text: "notes")) + model.select(nodeID: fixture.folder.id) + model.presentGoToPath() + let request = try pathRequest(model) + let snapshot = try #require(model.scanState.snapshot) + let replacement = try #require(snapshot.removingNode(id: fixture.empty.id)) + #expect(replacement.id == request.snapshotID) + #expect(replacement.treeStore.contentID != request.treeContentID) + + model.scanState.replaceCurrentSnapshot(replacement) + + #expect(model.presentationCoordinator.activeSheet == nil) + #expect(await !model.goToPath(fixture.file.url.path, request: request)) + #expect(model.navigation.currentFocusNode?.id == fixture.root.id) + #expect(model.navigation.selectedNodeIDs == [fixture.folder.id]) + #expect(!model.navigation.canNavigateBack) + #expect(browser.activeQuery.text == "notes") + + model.presentGoToPath() + let currentRequest = try pathRequest(model) + #expect(await model.goToPath(fixture.file.url.path, request: currentRequest)) + #expect(model.navigation.currentFocusNode?.id == fixture.folder.id) + #expect(model.navigation.selectedNodeIDs == [fixture.file.id]) + } } private struct PathFixture { From 1e3d42772c6825dfeba910f785a8736c0df4ae7f Mon Sep 17 00:00:00 2001 From: Colin Kim <121141519+colinvkim@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:10:05 -0700 Subject: [PATCH 13/22] fix(navigation): apply explicit path edit intent --- Radix/Features/Workspace/GoToPathSheet.swift | 79 ++++++++++++-------- 1 file changed, 46 insertions(+), 33 deletions(-) diff --git a/Radix/Features/Workspace/GoToPathSheet.swift b/Radix/Features/Workspace/GoToPathSheet.swift index f47703d..b9bf131 100644 --- a/Radix/Features/Workspace/GoToPathSheet.swift +++ b/Radix/Features/Workspace/GoToPathSheet.swift @@ -11,7 +11,7 @@ struct GoToPathSheet: View { @State private var selectedCompletionID: FileNodeRecord.ID? @State private var showsError = false @State private var submissionTask: Task? - @State private var focusRequest = UUID() + @State private var inputRequest = NativePathField.Request(.focus) init(resolver: ScanPathResolver, onCancel: @escaping () -> Void, onGo: @escaping (String) async -> Bool) { self.resolver = resolver @@ -122,7 +122,7 @@ struct GoToPathSheet: View { private var pathHeader: some View { HStack(spacing: 10) { - NativePathField(text: editedPath, focusRequest: focusRequest, onCommand: handleInputCommand) + NativePathField(text: editedPath, request: inputRequest, onCommand: handleInputCommand) .frame(maxWidth: .infinity) .accessibilityAction(named: Text("Go to Path")) { submit() } .disabled(isSubmitting) @@ -203,7 +203,7 @@ struct GoToPathSheet: View { private func accept(_ node: FileNodeRecord) { path = ScanPathResolver.completionPath(for: node) selectedCompletionID = node.id - focusRequest = UUID() + inputRequest = NativePathField.Request(.replaceAndMoveCaretToEnd) } private func clearOrCancel() { @@ -213,7 +213,7 @@ struct GoToPathSheet: View { selectedCompletionID = nil completions = [] showsError = false - focusRequest = UUID() + inputRequest = NativePathField.Request(.replaceAndMoveCaretToEnd) } private func navigate(to node: FileNodeRecord) { @@ -230,12 +230,24 @@ struct GoToPathSheet: View { submissionTask = nil guard !Task.isCancelled else { return } showsError = !succeeded - if !succeeded { focusRequest = UUID() } + if !succeeded { inputRequest = NativePathField.Request(.focus) } } } } private struct NativePathField: NSViewRepresentable { + struct Request { + enum Intent { + case focus + case replaceAndMoveCaretToEnd + } + + let id = UUID() + let intent: Intent + + init(_ intent: Intent) { self.intent = intent } + } + enum Command { case moveSelection(Int) case complete @@ -244,7 +256,7 @@ private struct NativePathField: NSViewRepresentable { } @Binding var text: String - let focusRequest: UUID + let request: Request let onCommand: (Command) -> Bool func makeCoordinator() -> Coordinator { Coordinator(parent: self) } @@ -294,43 +306,44 @@ private struct NativePathField: NSViewRepresentable { final class Coordinator: NSObject, NSTextFieldDelegate { var parent: NativePathField - private var appliedRequest: UUID? + private var appliedRequestID: UUID? private var applyingEdit = false init(parent: NativePathField) { self.parent = parent } func update(_ field: Field) { - let hasRequest = appliedRequest != parent.focusRequest + let hasRequest = appliedRequestID != parent.request.id if hasRequest { field.requestFocus() } field.focusIfRequested() - guard let editor = field.currentEditor() as? NSTextView else { - field.stringValue = parent.text - appliedRequest = parent.focusRequest - return - } guard hasRequest else { return } - guard editor.string != parent.text else { - appliedRequest = parent.focusRequest - return - } - applyingEdit = true - defer { applyingEdit = false } - editor.breakUndoCoalescing() - if editor.hasMarkedText() { - editor.inputContext?.discardMarkedText() - editor.unmarkText() - } - let previous = editor.string - let prefixCount = zip(previous, parent.text).prefix { $0.0 == $0.1 }.count - let prefixLength = previous.prefix(prefixCount).utf16.count - let replacement = String(parent.text.dropFirst(prefixCount)) - let range = NSRange(location: prefixLength, length: previous.utf16.count - prefixLength) - let attributed = NSAttributedString(string: replacement, attributes: editor.typingAttributes) - if editor.performValidatedReplacement(in: range, with: attributed) { + + switch parent.request.intent { + case .focus: + appliedRequestID = parent.request.id + case .replaceAndMoveCaretToEnd: + guard field.isEnabled, let editor = field.currentEditor() as? NSTextView else { return } + applyingEdit = true + editor.breakUndoCoalescing() + defer { + editor.breakUndoCoalescing() + applyingEdit = false + } + if editor.hasMarkedText() { + editor.inputContext?.discardMarkedText() + editor.unmarkText() + } + if editor.string != parent.text { + let previous = editor.string + let prefixCount = zip(previous, parent.text).prefix { $0.0 == $0.1 }.count + let prefixLength = previous.prefix(prefixCount).utf16.count + let replacement = String(parent.text.dropFirst(prefixCount)) + let range = NSRange(location: prefixLength, length: previous.utf16.count - prefixLength) + let attributed = NSAttributedString(string: replacement, attributes: editor.typingAttributes) + guard editor.performValidatedReplacement(in: range, with: attributed) else { return } + } editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0)) - appliedRequest = parent.focusRequest + appliedRequestID = parent.request.id } - editor.breakUndoCoalescing() } func controlTextDidBeginEditing(_ notification: Notification) { From 40f0b77d73d35e62667d73a970fbe247b0703935 Mon Sep 17 00:00:00 2001 From: Colin Kim <121141519+colinvkim@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:56:03 -0700 Subject: [PATCH 14/22] fix(navigation): preserve native path undo --- Radix/Features/Workspace/GoToPathSheet.swift | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/Radix/Features/Workspace/GoToPathSheet.swift b/Radix/Features/Workspace/GoToPathSheet.swift index b9bf131..639ff49 100644 --- a/Radix/Features/Workspace/GoToPathSheet.swift +++ b/Radix/Features/Workspace/GoToPathSheet.swift @@ -300,6 +300,10 @@ private struct NativePathField: NSViewRepresentable { func focusIfRequested() { guard pendingFocus, isEnabled, let window else { return } + if let editor = currentEditor(), window.firstResponder === editor { + pendingFocus = false + return + } pendingFocus = !window.makeFirstResponder(self) } } @@ -339,6 +343,20 @@ private struct NativePathField: NSViewRepresentable { let replacement = String(parent.text.dropFirst(prefixCount)) let range = NSRange(location: prefixLength, length: previous.utf16.count - prefixLength) let attributed = NSAttributedString(string: replacement, attributes: editor.typingAttributes) + let undoGroup: (manager: UndoManager, groupsByEvent: Bool)? + if let manager = editor.undoManager, manager.groupingLevel == 0 { + undoGroup = (manager, manager.groupsByEvent) + manager.groupsByEvent = false + manager.beginUndoGrouping() + } else { + undoGroup = nil + } + defer { + if let undoGroup { + undoGroup.manager.endUndoGrouping() + undoGroup.manager.groupsByEvent = undoGroup.groupsByEvent + } + } guard editor.performValidatedReplacement(in: range, with: attributed) else { return } } editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0)) From 2ef9583df891f48129d9e330d7fb227d55bdc863 Mon Sep 17 00:00:00 2001 From: Colin Kim <121141519+colinvkim@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:23:24 -0700 Subject: [PATCH 15/22] fix(navigation): select the first path suggestion --- Radix/Features/Workspace/GoToPathSheet.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Radix/Features/Workspace/GoToPathSheet.swift b/Radix/Features/Workspace/GoToPathSheet.swift index 639ff49..d0aa7fb 100644 --- a/Radix/Features/Workspace/GoToPathSheet.swift +++ b/Radix/Features/Workspace/GoToPathSheet.swift @@ -101,9 +101,8 @@ struct GoToPathSheet: View { } guard !Task.isCancelled, path == input else { return } completions = matches ?? [] - if let selectedCompletionID, - !completions.contains(where: { $0.id == selectedCompletionID }) { - self.selectedCompletionID = nil + if !completions.contains(where: { $0.id == selectedCompletionID }) { + selectedCompletionID = completions.first?.id } } } From 1ebe2dcf1a5ea7bc5236bdda0d1d51360e91513b Mon Sep 17 00:00:00 2001 From: Colin Kim <121141519+colinvkim@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:31:50 -0700 Subject: [PATCH 16/22] fix(navigation): keep path dialog height stable --- Radix/Features/Workspace/GoToPathSheet.swift | 108 ++++++++++--------- 1 file changed, 55 insertions(+), 53 deletions(-) diff --git a/Radix/Features/Workspace/GoToPathSheet.swift b/Radix/Features/Workspace/GoToPathSheet.swift index d0aa7fb..52c1943 100644 --- a/Radix/Features/Workspace/GoToPathSheet.swift +++ b/Radix/Features/Workspace/GoToPathSheet.swift @@ -24,62 +24,64 @@ struct GoToPathSheet: View { VStack(spacing: 0) { pathHeader Divider() - if showsError { - Text("This path is not available in the current scan.") - .font(.callout) - .foregroundStyle(.red) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 16) - .padding(.vertical, 12) - } - ScrollViewReader { proxy in - List(selection: $selectedCompletionID) { - ForEach(completions) { node in - Button { - accept(node) - } label: { - breadcrumbLabel(for: node) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(isSubmitting) - .highPriorityGesture( - TapGesture(count: 2) - .exclusively(before: TapGesture(count: 1)) - .onEnded { activation in - guard !isSubmitting else { return } - switch activation { - case .first: - navigate(to: node) - case .second: - accept(node) + VStack(spacing: 0) { + if showsError { + Text("This path is not available in the current scan.") + .font(.callout) + .foregroundStyle(.red) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + ScrollViewReader { proxy in + List(selection: $selectedCompletionID) { + ForEach(completions) { node in + Button { + accept(node) + } label: { + breadcrumbLabel(for: node) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(isSubmitting) + .highPriorityGesture( + TapGesture(count: 2) + .exclusively(before: TapGesture(count: 1)) + .onEnded { activation in + guard !isSubmitting else { return } + switch activation { + case .first: + navigate(to: node) + case .second: + accept(node) + } } - } - ) - .tag(node.id) - .id(node.id) - .listRowSeparator(.hidden) - .listRowBackground( - selectedCompletionID == node.id - ? PathSelectionMaterial() - .clipShape(RoundedRectangle(cornerRadius: 8)) - .padding(.horizontal, 10) - .allowsHitTesting(false) - .accessibilityHidden(true) - : nil - ) - .accessibilityLabel(Text(verbatim: ScanPathResolver.completionPath(for: node))) - .accessibilityAction(named: Text("Go to Path")) { - navigate(to: node) + ) + .tag(node.id) + .id(node.id) + .listRowSeparator(.hidden) + .listRowBackground( + selectedCompletionID == node.id + ? PathSelectionMaterial() + .clipShape(RoundedRectangle(cornerRadius: 8)) + .padding(.horizontal, 10) + .allowsHitTesting(false) + .accessibilityHidden(true) + : nil + ) + .accessibilityLabel(Text(verbatim: ScanPathResolver.completionPath(for: node))) + .accessibilityAction(named: Text("Go to Path")) { + navigate(to: node) + } + .help(ScanPathResolver.completionPath(for: node)) } - .help(ScanPathResolver.completionPath(for: node)) } - } - .listStyle(.inset) - .scrollContentBackground(.hidden) - .onChange(of: selectedCompletionID) { _, nodeID in - if let nodeID { proxy.scrollTo(nodeID) } + .listStyle(.inset) + .scrollContentBackground(.hidden) + .onChange(of: selectedCompletionID) { _, nodeID in + if let nodeID { proxy.scrollTo(nodeID) } + } } } .frame(height: 212) From 6420d130f4107f179b8fd8b8b08a8a80ed78888b Mon Sep 17 00:00:00 2001 From: Colin Kim <121141519+colinvkim@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:09:34 -0700 Subject: [PATCH 17/22] test(navigation): cover path case resolution --- RadixCoreTests/GoToPathTests.swift | 63 +++++++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/RadixCoreTests/GoToPathTests.swift b/RadixCoreTests/GoToPathTests.swift index 6ec3c7f..bfe5905 100644 --- a/RadixCoreTests/GoToPathTests.swift +++ b/RadixCoreTests/GoToPathTests.swift @@ -12,6 +12,9 @@ struct ScanPathResolverTests { ("/scan/Reports/space name.txt", "spaces"), ("/scan/Reports/末尾 ", "unicode"), ("/scan//Reports/Empty/../notes.txt", "file"), + ("/SCAN/reports/EMPTY/", "empty"), + ("/ScAn/REPORTS/NOTES.TXT", "file"), + ("/scan/reports", "reports"), ]) func resolvesScannedPathsWithIndependentRecordIDs(input: String, expectedID: String) throws { let fixture = PathFixture() @@ -21,7 +24,7 @@ struct ScanPathResolverTests { #expect(try resolver.resolve(input)?.id == expectedID) } - @Test(arguments: ["", "/scan-other/Reports", "/scan/Reports/missing", "/scan/Reports/notes.txt/", "/scan/reports", "~someone/Reports"]) + @Test(arguments: ["", "/scan-other/Reports", "/SCAN-other/Reports", "/scan/Reports/missing", "/scan/Reports/notes.txt/", "~someone/Reports"]) func unavailablePathsDoNotResolve(input: String) throws { #expect(try ScanPathResolver(treeStore: PathFixture().store).resolve(input) == nil) } @@ -31,8 +34,11 @@ struct ScanPathResolverTests { let fixture = PathFixture() let resolver = ScanPathResolver(treeStore: fixture.store, hiddenNodeIDs: [fixture.folder.id]) #expect(try resolver.resolve("/scan/Reports/notes.txt") == nil) + #expect(try resolver.resolve("/SCAN/reports/NOTES.TXT") == nil) #expect(try resolver.resolve("/scan/System") == nil) + #expect(try resolver.resolve("/SCAN/system") == nil) #expect(try resolver.completions(for: "/scan/").map(\.id) == [fixture.root.id]) + #expect(try resolver.completions(for: "/SCAN/").map(\.id) == [fixture.root.id]) #expect(try resolver.completions(for: "/sc").map(\.id) == [fixture.root.id]) let visible = ScanPathResolver(treeStore: fixture.store) #expect(try visible.completions(for: "/scan/rep").map(\.id) == [fixture.folder.id]) @@ -68,6 +74,60 @@ struct ScanPathResolverTests { } } + @Test + func exactCompletionPrecedesCaseVariantsBeforeApplyingTheLimit() throws { + let names = [ + "README", "Readme", "readme", "rEADME", "ReADME", "REaDME", "REAdME", "READmE", + "READMe", "rEadme", "reAdme", "reaDme", "readMe", "readmE", "ReAdMe", "rEaDmE", + ] + let children = names.enumerated().map { index, name in + pathRecord(id: "variant-\(index)", path: "/scan/\(name)") + } + let root = pathRecord(id: "root", path: "/scan", directory: true) + let resolver = ScanPathResolver(treeStore: FileTreeStore(root: root, childrenByID: [root.id: children])) + + for (index, name) in names.enumerated() { + #expect(try resolver.completions(for: "/scan/\(name)").first?.id == "variant-\(index)") + #expect(try resolver.resolve("/scan/\(name)")?.id == "variant-\(index)") + } + #expect(try resolver.resolve("/scan/ReAdmE") == nil) + } + + @Test + func caseInsensitiveCompletionsTraverseDirectoriesAndListTheRoot() throws { + let fixture = PathFixture() + let resolver = ScanPathResolver(treeStore: fixture.store) + + #expect(try resolver.completions(for: "/SCAN/").map(\.id) == [fixture.root.id, fixture.folder.id]) + #expect(try resolver.completions(for: "/SCAN/reports/no").map(\.id) == [fixture.file.id]) + #expect(try resolver.completions(for: "/SCAN/reports/empty/").map(\.id) == [fixture.empty.id]) + } + + @Test + func exactDirectorySpellingDisambiguatesCaseVariants() throws { + let root = pathRecord(id: "root", path: "/Scan", directory: true) + let upper = pathRecord(id: "upper", path: "/Scan/Reports", directory: true) + let lower = pathRecord(id: "lower", path: "/Scan/reports", directory: true) + let upperFile = pathRecord(id: "upper-file", path: "/Scan/Reports/notes.txt") + let lowerFile = pathRecord(id: "lower-file", path: "/Scan/reports/notes.txt") + let resolver = ScanPathResolver(treeStore: FileTreeStore(root: root, childrenByID: [ + root.id: [lower, upper], + upper.id: [upperFile], + lower.id: [lowerFile], + ])) + let cases: [(String, String?)] = [ + ("Reports", upperFile.id), + ("reports", lowerFile.id), + ("REPORTS", nil), + ] + + for (spelling, expectedID) in cases { + let expectedIDs = expectedID.map { [$0] } ?? [] + #expect(try resolver.resolve("/SCAN/\(spelling)/NOTES.TXT")?.id == expectedID) + #expect(try resolver.completions(for: "/SCAN/\(spelling)/no").map(\.id) == expectedIDs) + } + } + @Test func tildeUsesTheCurrentHomeDirectory() throws { let home = FileManager.default.homeDirectoryForCurrentUser.path @@ -83,6 +143,7 @@ struct ScanPathResolverTests { let root = pathRecord(id: "root", path: "/private/tmp/scan", directory: true) let resolver = ScanPathResolver(treeStore: FileTreeStore(root: root, childrenByID: [root.id: [file]])) #expect(try resolver.resolve("/private/tmp/scan/ padded ")?.id == file.id) + #expect(try resolver.resolve("/PRIVATE/TMP/SCAN/ padded ")?.id == file.id) #expect(try resolver.resolve("/tmp/scan/ padded ") == nil) } } From 62c71b39f8ed506bccd55742ab30ae1bbd01dd10 Mon Sep 17 00:00:00 2001 From: Colin Kim <121141519+colinvkim@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:40:05 -0700 Subject: [PATCH 18/22] fix(navigation): resolve path casing consistently --- Radix/Services/ScanPathResolver.swift | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/Radix/Services/ScanPathResolver.swift b/Radix/Services/ScanPathResolver.swift index 561a78c..74f8c6b 100644 --- a/Radix/Services/ScanPathResolver.swift +++ b/Radix/Services/ScanPathResolver.swift @@ -39,7 +39,7 @@ nonisolated struct ScanPathResolver: Sendable { let rootPath = normalizedPath(treeStore.root.url.path) let listsChildren = input.hasSuffix("/") || input == "~" || input.split(separator: "/").last.map { $0 == "." || $0 == ".." } == true - if (path != rootPath || !listsChildren), + if (path.caseInsensitiveCompare(rootPath) != .orderedSame || !listsChildren), rootPath.range(of: path, options: [.anchored, .caseInsensitive]) != nil, isVisible(treeStore.root) { return [treeStore.root] @@ -59,7 +59,7 @@ nonisolated struct ScanPathResolver: Sendable { prefix.isEmpty || child.url.lastPathComponent.range( of: prefix, options: [.anchored, .caseInsensitive] ) != nil else { continue } - let insertion = matches.firstIndex { precedes(child, $0) } ?? matches.endIndex + let insertion = matches.firstIndex { precedes(child, $0, prefix: prefix) } ?? matches.endIndex if insertion < childLimit { matches.insert(child, at: insertion) if matches.count > childLimit { matches.removeLast() } @@ -77,20 +77,31 @@ nonisolated struct ScanPathResolver: Sendable { try cancellationCheck() let rootComponents = normalizedPath(treeStore.root.url.path).split(separator: "/") let components = path.split(separator: "/") - guard components.starts(with: rootComponents), isVisible(treeStore.root) else { return nil } + guard components.starts(with: rootComponents, by: { + String($0).caseInsensitiveCompare(String($1)) == .orderedSame + }), isVisible(treeStore.root) else { return nil } var current = treeStore.root for component in components.dropFirst(rootComponents.count) { guard current.isDirectory else { return nil } + let requestedName = String(component) let children = try treeStore.children(of: current.id, cancellationCheck: cancellationCheck) var match: FileNodeRecord? + var isAmbiguous = false for child in children { try cancellationCheck() - if child.url.lastPathComponent == component, isVisible(child) { + guard isVisible(child) else { continue } + let name = child.url.lastPathComponent + if name == requestedName { match = child + isAmbiguous = false break } + if name.caseInsensitiveCompare(requestedName) == .orderedSame { + if match != nil { isAmbiguous = true } + match = child + } } - guard let match else { return nil } + guard let match, !isAmbiguous else { return nil } current = match } return current @@ -127,7 +138,10 @@ nonisolated struct ScanPathResolver: Sendable { return "/" + components.joined(separator: "/") } - private func precedes(_ lhs: FileNodeRecord, _ rhs: FileNodeRecord) -> Bool { + private func precedes(_ lhs: FileNodeRecord, _ rhs: FileNodeRecord, prefix: String) -> Bool { + let lhsIsExact = lhs.url.lastPathComponent == prefix + let rhsIsExact = rhs.url.lastPathComponent == prefix + if lhsIsExact != rhsIsExact { return lhsIsExact } let order = lhs.url.lastPathComponent.localizedStandardCompare(rhs.url.lastPathComponent) return order == .orderedSame ? lhs.url.path < rhs.url.path : order == .orderedAscending } From ca6435e9a27ff64393ea6968b6d4183a56eb2430 Mon Sep 17 00:00:00 2001 From: Colin Kim <121141519+colinvkim@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:40:05 -0700 Subject: [PATCH 19/22] fix(navigation): preserve path editing state --- Radix/Features/Workspace/GoToPathSheet.swift | 80 +++++++++++++++++--- 1 file changed, 70 insertions(+), 10 deletions(-) diff --git a/Radix/Features/Workspace/GoToPathSheet.swift b/Radix/Features/Workspace/GoToPathSheet.swift index 52c1943..c9ebe08 100644 --- a/Radix/Features/Workspace/GoToPathSheet.swift +++ b/Radix/Features/Workspace/GoToPathSheet.swift @@ -113,6 +113,7 @@ struct GoToPathSheet: View { Binding( get: { path }, set: { input in + submissionTask?.cancel() guard path != input else { return } path = input selectedCompletionID = nil @@ -126,7 +127,6 @@ struct GoToPathSheet: View { NativePathField(text: editedPath, request: inputRequest, onCommand: handleInputCommand) .frame(maxWidth: .infinity) .accessibilityAction(named: Text("Go to Path")) { submit() } - .disabled(isSubmitting) Button(action: clearOrCancel) { Image(systemName: "xmark.circle.fill") .font(.system(size: 16)) @@ -176,6 +176,10 @@ struct GoToPathSheet: View { } private func handleInputCommand(_ command: NativePathField.Command) -> Bool { + guard !isSubmitting else { + if case .cancel = command { onCancel() } + return true + } switch command { case let .moveSelection(offset): return moveSelection(by: offset) @@ -231,7 +235,6 @@ struct GoToPathSheet: View { submissionTask = nil guard !Task.isCancelled else { return } showsError = !succeeded - if !succeeded { inputRequest = NativePathField.Request(.focus) } } } } @@ -279,6 +282,9 @@ private struct NativePathField: NSViewRepresentable { field.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) field.setAccessibilityLabel(String(localized: "Path")) field.delegate = context.coordinator + field.didAcquireEditor = { [weak coordinator = context.coordinator] field in + coordinator?.observeEditor(of: field) + } return field } @@ -288,9 +294,22 @@ private struct NativePathField: NSViewRepresentable { context.coordinator.update(field) } + static func dismantleNSView(_ field: Field, coordinator: Coordinator) { + field.didAcquireEditor = nil + field.delegate = nil + coordinator.stopObservingEditor() + } + final class Field: NSTextField { + var didAcquireEditor: ((Field) -> Void)? private var pendingFocus = true + override func becomeFirstResponder() -> Bool { + let accepted = super.becomeFirstResponder() + if accepted { didAcquireEditor?(self) } + return accepted + } + override func viewDidMoveToWindow() { super.viewDidMoveToWindow() if let window { window.initialFirstResponder = self } @@ -303,9 +322,10 @@ private struct NativePathField: NSViewRepresentable { guard pendingFocus, isEnabled, let window else { return } if let editor = currentEditor(), window.firstResponder === editor { pendingFocus = false - return + } else { + pendingFocus = !window.makeFirstResponder(self) } - pendingFocus = !window.makeFirstResponder(self) + if !pendingFocus { didAcquireEditor?(self) } } } @@ -313,6 +333,9 @@ private struct NativePathField: NSViewRepresentable { var parent: NativePathField private var appliedRequestID: UUID? private var applyingEdit = false + private weak var observedField: Field? + private weak var observedEditor: NSTextView? + private weak var observedStorage: NSTextStorage? init(parent: NativePathField) { self.parent = parent } @@ -365,15 +388,52 @@ private struct NativePathField: NSViewRepresentable { } } - func controlTextDidBeginEditing(_ notification: Notification) { - guard let field = notification.object as? NSTextField, - let editor = field.currentEditor() as? NSTextView else { return } + func observeEditor(of field: Field) { + guard let editor = field.currentEditor() as? NSTextView, + let storage = editor.textStorage else { return } editor.allowsUndo = true + guard observedEditor !== editor || observedStorage !== storage else { return } + stopObservingEditor() + observedField = field + observedEditor = editor + observedStorage = storage + NotificationCenter.default.addObserver( + self, + selector: #selector(storageDidProcessEditing(_:)), + name: NSTextStorage.didProcessEditingNotification, + object: storage + ) + } + + func stopObservingEditor() { + NotificationCenter.default.removeObserver( + self, + name: NSTextStorage.didProcessEditingNotification, + object: observedStorage + ) + observedField = nil + observedEditor = nil + observedStorage = nil + } + + @objc private func storageDidProcessEditing(_ notification: Notification) { + guard !applyingEdit, + let storage = notification.object as? NSTextStorage, + storage === observedStorage, + storage.editedMask.contains(.editedCharacters), + let editor = observedEditor, + observedField?.currentEditor() === editor, + editor.textStorage === storage else { return } + parent.text = editor.string + } + + func controlTextDidBeginEditing(_ notification: Notification) { + guard let field = notification.object as? Field else { return } + observeEditor(of: field) } - func controlTextDidChange(_ notification: Notification) { - guard !applyingEdit, let field = notification.object as? NSTextField else { return } - parent.text = field.stringValue + func controlTextDidEndEditing(_ notification: Notification) { + stopObservingEditor() } func control(_ control: NSControl, textView: NSTextView, doCommandBy selector: Selector) -> Bool { From d2ad4256a7d3cf463a349d92e86d8bfe8eaeab86 Mon Sep 17 00:00:00 2001 From: Colin Kim <121141519+colinvkim@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:45:42 -0700 Subject: [PATCH 20/22] feat(navigation): emphasize path completion matches --- Radix/Features/Workspace/GoToPathSheet.swift | 22 +++++++-- Radix/Services/ScanPathResolver.swift | 50 +++++++++++++++++--- RadixCoreTests/GoToPathTests.swift | 49 +++++++++++++++++++ 3 files changed, 109 insertions(+), 12 deletions(-) diff --git a/Radix/Features/Workspace/GoToPathSheet.swift b/Radix/Features/Workspace/GoToPathSheet.swift index c9ebe08..cca6763 100644 --- a/Radix/Features/Workspace/GoToPathSheet.swift +++ b/Radix/Features/Workspace/GoToPathSheet.swift @@ -144,14 +144,26 @@ struct GoToPathSheet: View { private func breadcrumbLabel(for node: FileNodeRecord) -> some View { let components = node.url.pathComponents.filter { $0 != "/" } - let ancestors = components.dropLast().joined(separator: " › ") + let match = resolver.completionMatch(for: path, in: node) + let labels = components.enumerated().map { index, component in + var label = AttributedString(component) + if let match, match.componentIndex == index { + let end = label.characters.index(label.startIndex, offsetBy: match.matchedCharacterCount) + label[.. Void = {} ) throws -> [FileNodeRecord] { - guard limit > 0, let path = absolutePath(input) else { return [] } + guard limit > 0, let query = completionQuery(for: input) else { return [] } + let path = query.path + let listsChildren = query.listsChildren let rootPath = normalizedPath(treeStore.root.url.path) - let listsChildren = input.hasSuffix("/") || input == "~" - || input.split(separator: "/").last.map { $0 == "." || $0 == ".." } == true if (path.caseInsensitiveCompare(rootPath) != .orderedSame || !listsChildren), rootPath.range(of: path, options: [.anchored, .caseInsensitive]) != nil, isVisible(treeStore.root) { return [treeStore.root] } - let components = path.split(separator: "/").map(String.init) - let prefix = listsChildren ? "" : components.last ?? "" - let parentPath = listsChildren ? path : "/" + components.dropLast().joined(separator: "/") - guard let parent = try node(at: parentPath, cancellationCheck: cancellationCheck), + let prefix = query.prefix + guard let parent = try node(at: query.parentPath, cancellationCheck: cancellationCheck), parent.isDirectory else { return [] } if listsChildren, limit == 1 { return [parent] } let childLimit = listsChildren ? limit - 1 : limit @@ -68,6 +80,20 @@ nonisolated struct ScanPathResolver: Sendable { return listsChildren ? [parent] + matches : matches } + func completionMatch(for input: String, in node: FileNodeRecord) -> CompletionMatch? { + guard let query = completionQuery(for: input), !query.prefix.isEmpty else { return nil } + let componentIndex = query.components.count - 1 + let components = node.url.pathComponents.filter { $0 != "/" } + guard componentIndex < components.count, + let range = components[componentIndex].range( + of: query.prefix, options: [.anchored, .caseInsensitive] + ) else { return nil } + return CompletionMatch( + componentIndex: componentIndex, + matchedCharacterCount: components[componentIndex][.. String { let path = node.url.path return node.isDirectory && !path.hasSuffix("/") ? path + "/" : path @@ -111,6 +137,16 @@ nonisolated struct ScanPathResolver: Sendable { !node.isSynthetic && !treeStore.isNodeOrDescendant(node.id, of: hiddenNodes) } + private func completionQuery(for input: String) -> CompletionQuery? { + guard let path = absolutePath(input) else { return nil } + return CompletionQuery( + path: path, + components: path.split(separator: "/").map(String.init), + listsChildren: input.hasSuffix("/") || input == "~" + || input.split(separator: "/").last.map { $0 == "." || $0 == ".." } == true + ) + } + private func absolutePath(_ input: String) -> String? { guard !input.isEmpty, !input.contains("\0") else { return nil } let expanded: String diff --git a/RadixCoreTests/GoToPathTests.swift b/RadixCoreTests/GoToPathTests.swift index bfe5905..1577677 100644 --- a/RadixCoreTests/GoToPathTests.swift +++ b/RadixCoreTests/GoToPathTests.swift @@ -59,6 +59,52 @@ struct ScanPathResolverTests { #expect(try resolver.completions(for: input).map(\.id) == [root.id]) } + @Test(arguments: [ + ("/scan/rep", "reports", 1, 3), + ("/SCAN/REP", "reports", 1, 3), + ("no", "file", 2, 2), + ("./no", "file", 2, 2), + ("../Reports/no", "file", 2, 2), + ("/scan//Reports/Empty/../no", "file", 2, 2), + ]) + func completionMatchIdentifiesOnlyTheCurrentComponent( + input: String, candidateID: String, componentIndex: Int, characterCount: Int + ) throws { + let fixture = PathFixture() + let resolver = ScanPathResolver(treeStore: fixture.store, baseDirectoryID: fixture.folder.id) + let candidate = try #require(try resolver.completions(for: input).first { $0.id == candidateID }) + let match = try #require(resolver.completionMatch(for: input, in: candidate)) + #expect(match.componentIndex == componentIndex) + #expect(match.matchedCharacterCount == characterCount) + } + + @Test(arguments: ["", "/", "/scan/Reports/", "~", "~/", ".", "..", "/scan/Reports/.", "/scan/Reports/..", "~someone", "/scan/\0"]) + func childListingsAndInvalidInputsHaveNoCompletionMatch(input: String) { + let fixture = PathFixture() + let resolver = ScanPathResolver(treeStore: fixture.store, baseDirectoryID: fixture.folder.id) + #expect(resolver.completionMatch(for: input, in: fixture.folder) == nil) + } + + @Test(arguments: [("ßeta.txt", "SS", 1), ("Straße.txt", "STRASS", 5), ("e\u{301}clair.txt", "é", 1)]) + func completionMatchCountsCandidateGraphemes(name: String, input: String, characterCount: Int) throws { + let root = pathRecord(id: "root", path: "/scan", directory: true) + let file = pathRecord(id: "file", path: "/scan/\(name)") + let resolver = ScanPathResolver(treeStore: FileTreeStore(root: root, childrenByID: [root.id: [file]])) + let candidate = try #require(try resolver.completions(for: "/scan/\(input)").first) + let match = try #require(resolver.completionMatch(for: "/scan/\(input)", in: candidate)) + #expect(match.componentIndex == 1) + #expect(match.matchedCharacterCount == characterCount) + } + + @Test(arguments: [("/sc", 0, 2), ("/scan/ne", 1, 2)]) + func completionMatchCanIdentifyAnAncestorOfTheScanRoot(input: String, componentIndex: Int, characterCount: Int) throws { + let root = pathRecord(id: "root", path: "/scan/nested", directory: true) + let resolver = ScanPathResolver(treeStore: FileTreeStore(root: root)) + let match = try #require(resolver.completionMatch(for: input, in: root)) + #expect(match.componentIndex == componentIndex) + #expect(match.matchedCharacterCount == characterCount) + } + @Test func completionIsBoundedNaturallyOrderedAndCancellable() throws { let children = (1...20).reversed().map { pathRecord(id: "id-\($0)", path: "/scan/item\($0)") } @@ -135,6 +181,9 @@ struct ScanPathResolverTests { let file = pathRecord(id: "file", path: home + "/scanned-only.txt") let resolver = ScanPathResolver(treeStore: FileTreeStore(root: root, childrenByID: [root.id: [file]])) #expect(try resolver.resolve("~/scanned-only.txt")?.id == file.id) + let match = try #require(resolver.completionMatch(for: "~/sca", in: file)) + #expect(match.componentIndex == home.split(separator: "/").count) + #expect(match.matchedCharacterCount == 3) } @Test From 97663350588128bce930a43c348c48a1d2714a37 Mon Sep 17 00:00:00 2001 From: Colin Kim <121141519+colinvkim@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:53:00 -0700 Subject: [PATCH 21/22] fix(navigation): use bold path match emphasis --- Radix/Features/Workspace/GoToPathSheet.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Radix/Features/Workspace/GoToPathSheet.swift b/Radix/Features/Workspace/GoToPathSheet.swift index cca6763..47d0572 100644 --- a/Radix/Features/Workspace/GoToPathSheet.swift +++ b/Radix/Features/Workspace/GoToPathSheet.swift @@ -149,7 +149,7 @@ struct GoToPathSheet: View { var label = AttributedString(component) if let match, match.componentIndex == index { let end = label.characters.index(label.startIndex, offsetBy: match.matchedCharacterCount) - label[.. Date: Thu, 29 Oct 2026 19:44:14 -0700 Subject: [PATCH 22/22] fix(navigation): retain path suggestions during lookup --- Radix/Features/Workspace/GoToPathSheet.swift | 106 +++++++++++++------ 1 file changed, 72 insertions(+), 34 deletions(-) diff --git a/Radix/Features/Workspace/GoToPathSheet.swift b/Radix/Features/Workspace/GoToPathSheet.swift index 47d0572..c99026f 100644 --- a/Radix/Features/Workspace/GoToPathSheet.swift +++ b/Radix/Features/Workspace/GoToPathSheet.swift @@ -7,8 +7,7 @@ struct GoToPathSheet: View { let onGo: (String) async -> Bool @State private var path: String - @State private var completions: [FileNodeRecord] = [] - @State private var selectedCompletionID: FileNodeRecord.ID? + @State private var completionBatch = CompletionBatch(input: "", nodes: []) @State private var showsError = false @State private var submissionTask: Task? @State private var inputRequest = NativePathField.Request(.focus) @@ -34,10 +33,10 @@ struct GoToPathSheet: View { .padding(.vertical, 12) } ScrollViewReader { proxy in - List(selection: $selectedCompletionID) { - ForEach(completions) { node in + List(selection: completionSelection) { + ForEach(completionBatch.nodes) { node in Button { - accept(node) + accept(node.id) } label: { breadcrumbLabel(for: node) .frame(maxWidth: .infinity, alignment: .leading) @@ -52,9 +51,9 @@ struct GoToPathSheet: View { guard !isSubmitting else { return } switch activation { case .first: - navigate(to: node) + navigate(to: node.id) case .second: - accept(node) + accept(node.id) } } ) @@ -62,7 +61,7 @@ struct GoToPathSheet: View { .id(node.id) .listRowSeparator(.hidden) .listRowBackground( - selectedCompletionID == node.id + completionBatch.selectedID == node.id ? PathSelectionMaterial() .clipShape(RoundedRectangle(cornerRadius: 8)) .padding(.horizontal, 10) @@ -72,14 +71,15 @@ struct GoToPathSheet: View { ) .accessibilityLabel(Text(verbatim: ScanPathResolver.completionPath(for: node))) .accessibilityAction(named: Text("Go to Path")) { - navigate(to: node) + navigate(to: node.id) } .help(ScanPathResolver.completionPath(for: node)) } } .listStyle(.inset) .scrollContentBackground(.hidden) - .onChange(of: selectedCompletionID) { _, nodeID in + .allowsHitTesting(hasCurrentCompletions) + .onChange(of: completionBatch.selectedID) { _, nodeID in if let nodeID { proxy.scrollTo(nodeID) } } } @@ -102,10 +102,11 @@ struct GoToPathSheet: View { query.cancel() } guard !Task.isCancelled, path == input else { return } - completions = matches ?? [] - if !completions.contains(where: { $0.id == selectedCompletionID }) { - selectedCompletionID = completions.first?.id - } + completionBatch = CompletionBatch( + input: input, + nodes: matches ?? [], + preferredSelectionID: completionBatch.selectedID + ) } } @@ -116,8 +117,7 @@ struct GoToPathSheet: View { submissionTask?.cancel() guard path != input else { return } path = input - selectedCompletionID = nil - completions = [] + if input.isEmpty { completionBatch = CompletionBatch(input: input, nodes: []) } } ) } @@ -144,7 +144,7 @@ struct GoToPathSheet: View { private func breadcrumbLabel(for node: FileNodeRecord) -> some View { let components = node.url.pathComponents.filter { $0 != "/" } - let match = resolver.completionMatch(for: path, in: node) + let match = resolver.completionMatch(for: completionBatch.input, in: node) let labels = components.enumerated().map { index, component in var label = AttributedString(component) if let match, match.componentIndex == index { @@ -160,7 +160,7 @@ struct GoToPathSheet: View { return HStack(spacing: 6) { if !ancestors.characters.isEmpty { Group { - if selectedCompletionID == node.id { + if completionBatch.selectedID == node.id { Text(ancestors) } else { Text(ancestors) @@ -178,13 +178,25 @@ struct GoToPathSheet: View { .truncationMode(.middle) .layoutPriority(1) } - .foregroundStyle(CompletionForeground(isSelected: selectedCompletionID == node.id)) + .foregroundStyle(CompletionForeground(isSelected: completionBatch.selectedID == node.id)) } private var isSubmitting: Bool { submissionTask != nil } + private var hasCurrentCompletions: Bool { completionBatch.input == path } + + private var completionSelection: Binding { + Binding( + get: { completionBatch.selectedID }, + set: { nodeID in + guard hasCurrentCompletions else { return } + completionBatch.select(nodeID) + } + ) + } + private var selectedCompletion: FileNodeRecord? { - completions.first { $0.id == selectedCompletionID } + hasCurrentCompletions ? completionBatch.selectedNode : nil } private func handleInputCommand(_ command: NativePathField.Command) -> Bool { @@ -196,8 +208,9 @@ struct GoToPathSheet: View { case let .moveSelection(offset): return moveSelection(by: offset) case .complete: - guard let completion = selectedCompletion ?? completions.first else { return false } - accept(completion) + guard hasCurrentCompletions else { return true } + guard let completion = selectedCompletion ?? completionBatch.nodes.first else { return false } + accept(completion.id) return true case .submit: submit() @@ -209,33 +222,37 @@ struct GoToPathSheet: View { } private func moveSelection(by offset: Int) -> Bool { - guard !completions.isEmpty else { return false } - let index = selectedCompletionID.flatMap { id in completions.firstIndex { $0.id == id } } - let next = index.map { min(max($0 + offset, 0), completions.count - 1) } - ?? (offset > 0 ? 0 : completions.count - 1) - selectedCompletionID = completions[next].id + guard hasCurrentCompletions else { return true } + let nodes = completionBatch.nodes + guard !nodes.isEmpty else { return false } + let index = completionBatch.selectedID.flatMap { id in nodes.firstIndex { $0.id == id } } + let next = index.map { min(max($0 + offset, 0), nodes.count - 1) } + ?? (offset > 0 ? 0 : nodes.count - 1) + completionBatch.select(nodes[next].id) return true } - private func accept(_ node: FileNodeRecord) { + @discardableResult + private func accept(_ nodeID: FileNodeRecord.ID) -> Bool { + guard !isSubmitting, hasCurrentCompletions, + let node = completionBatch.nodes.first(where: { $0.id == nodeID }) else { return false } + completionBatch.select(node.id) path = ScanPathResolver.completionPath(for: node) - selectedCompletionID = node.id inputRequest = NativePathField.Request(.replaceAndMoveCaretToEnd) + return true } private func clearOrCancel() { guard !path.isEmpty else { onCancel(); return } submissionTask?.cancel() path = "" - selectedCompletionID = nil - completions = [] + completionBatch = CompletionBatch(input: path, nodes: []) showsError = false inputRequest = NativePathField.Request(.replaceAndMoveCaretToEnd) } - private func navigate(to node: FileNodeRecord) { - guard !isSubmitting else { return } - accept(node) + private func navigate(to nodeID: FileNodeRecord.ID) { + guard accept(nodeID) else { return } submit() } @@ -251,6 +268,27 @@ struct GoToPathSheet: View { } } +private struct CompletionBatch { + let input: String + let nodes: [FileNodeRecord] + private(set) var selectedID: FileNodeRecord.ID? + + init(input: String, nodes: [FileNodeRecord], preferredSelectionID: FileNodeRecord.ID? = nil) { + self.input = input + self.nodes = nodes + selectedID = nodes.contains { $0.id == preferredSelectionID } ? preferredSelectionID : nodes.first?.id + } + + var selectedNode: FileNodeRecord? { + nodes.first { $0.id == selectedID } + } + + mutating func select(_ nodeID: FileNodeRecord.ID?) { + guard nodeID == nil || nodes.contains(where: { $0.id == nodeID }) else { return } + selectedID = nodeID + } +} + private struct NativePathField: NSViewRepresentable { struct Request { enum Intent {