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..bd34e0e 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() } @@ -259,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/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..c99026f --- /dev/null +++ b/Radix/Features/Workspace/GoToPathSheet.swift @@ -0,0 +1,541 @@ +import AppKit +import SwiftUI + +struct GoToPathSheet: View { + let resolver: ScanPathResolver + let onCancel: () -> Void + let onGo: (String) async -> Bool + + @State private var path: String + @State private var completionBatch = CompletionBatch(input: "", nodes: []) + @State private var showsError = false + @State private var submissionTask: Task? + @State private var inputRequest = NativePathField.Request(.focus) + + 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(spacing: 0) { + pathHeader + Divider() + 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: completionSelection) { + ForEach(completionBatch.nodes) { node in + Button { + accept(node.id) + } 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.id) + case .second: + accept(node.id) + } + } + ) + .tag(node.id) + .id(node.id) + .listRowSeparator(.hidden) + .listRowBackground( + completionBatch.selectedID == 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.id) + } + .help(ScanPathResolver.completionPath(for: node)) + } + } + .listStyle(.inset) + .scrollContentBackground(.hidden) + .allowsHitTesting(hasCurrentCompletions) + .onChange(of: completionBatch.selectedID) { _, nodeID in + if let nodeID { proxy.scrollTo(nodeID) } + } + } + } + .frame(height: 212) + } + .frame(width: 520) + .onExitCommand(perform: onCancel) + .onDisappear { submissionTask?.cancel() } + .task(id: path) { + showsError = false + 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 } + completionBatch = CompletionBatch( + input: input, + nodes: matches ?? [], + preferredSelectionID: completionBatch.selectedID + ) + } + } + + private var editedPath: Binding { + Binding( + get: { path }, + set: { input in + submissionTask?.cancel() + guard path != input else { return } + path = input + if input.isEmpty { completionBatch = CompletionBatch(input: input, nodes: []) } + } + ) + } + + private var pathHeader: some View { + HStack(spacing: 10) { + NativePathField(text: editedPath, request: inputRequest, onCommand: handleInputCommand) + .frame(maxWidth: .infinity) + .accessibilityAction(named: Text("Go to Path")) { submit() } + Button(action: clearOrCancel) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 16)) + .foregroundStyle(.secondary) + .frame(width: 24, height: 24) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(path.isEmpty ? Text("Cancel") : Text("Clear")) + .help(path.isEmpty ? Text("Cancel") : Text("Clear")) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + + private func breadcrumbLabel(for node: FileNodeRecord) -> some View { + let components = node.url.pathComponents.filter { $0 != "/" } + 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 { + let end = label.characters.index(label.startIndex, offsetBy: match.matchedCharacterCount) + label[.. { + Binding( + get: { completionBatch.selectedID }, + set: { nodeID in + guard hasCurrentCompletions else { return } + completionBatch.select(nodeID) + } + ) + } + + private var selectedCompletion: FileNodeRecord? { + hasCurrentCompletions ? completionBatch.selectedNode : nil + } + + 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) + case .complete: + guard hasCurrentCompletions else { return true } + guard let completion = selectedCompletion ?? completionBatch.nodes.first else { return false } + accept(completion.id) + return true + case .submit: + submit() + return true + case .cancel: + onCancel() + return true + } + } + + private func moveSelection(by offset: Int) -> Bool { + 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 + } + + @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) + inputRequest = NativePathField.Request(.replaceAndMoveCaretToEnd) + return true + } + + private func clearOrCancel() { + guard !path.isEmpty else { onCancel(); return } + submissionTask?.cancel() + path = "" + completionBatch = CompletionBatch(input: path, nodes: []) + showsError = false + inputRequest = NativePathField.Request(.replaceAndMoveCaretToEnd) + } + + private func navigate(to nodeID: FileNodeRecord.ID) { + guard accept(nodeID) else { return } + submit() + } + + 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 + } + } +} + +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 { + case focus + case replaceAndMoveCaretToEnd + } + + let id = UUID() + let intent: Intent + + init(_ intent: Intent) { self.intent = intent } + } + + enum Command { + case moveSelection(Int) + case complete + case submit + case cancel + } + + @Binding var text: String + let request: Request + 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 + field.didAcquireEditor = { [weak coordinator = context.coordinator] field in + coordinator?.observeEditor(of: field) + } + return field + } + + func updateNSView(_ field: Field, context: Context) { + context.coordinator.parent = self + field.isEnabled = context.environment.isEnabled + 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 } + focusIfRequested() + } + + func requestFocus() { pendingFocus = true } + + func focusIfRequested() { + guard pendingFocus, isEnabled, let window else { return } + if let editor = currentEditor(), window.firstResponder === editor { + pendingFocus = false + } else { + pendingFocus = !window.makeFirstResponder(self) + } + if !pendingFocus { didAcquireEditor?(self) } + } + } + + final class Coordinator: NSObject, NSTextFieldDelegate { + 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 } + + func update(_ field: Field) { + let hasRequest = appliedRequestID != parent.request.id + if hasRequest { field.requestFocus() } + field.focusIfRequested() + guard hasRequest else { return } + + 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) + 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)) + appliedRequestID = parent.request.id + } + } + + 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 controlTextDidEndEditing(_ notification: Notification) { + stopObservingEditor() + } + + 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 + } + } + } +} + +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 + } + } +} diff --git a/Radix/Localizable.xcstrings b/Radix/Localizable.xcstrings index 6f3992d..f6c4ab7 100644 --- a/Radix/Localizable.xcstrings +++ b/Radix/Localizable.xcstrings @@ -1,6 +1,190 @@ { "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" : "当前扫描中没有此路径。" + } + } + } + }, + "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" : "路径" + } + } + } + }, + "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..fca20f4 --- /dev/null +++ b/Radix/Services/ScanPathResolver.swift @@ -0,0 +1,184 @@ +import Foundation + +nonisolated struct ScanPathResolver: Sendable { + struct CompletionMatch: Sendable { + let componentIndex: Int + let matchedCharacterCount: Int + } + + private struct CompletionQuery { + let path: String + let components: [String] + let listsChildren: Bool + + var prefix: String { listsChildren ? "" : components.last ?? "" } + var parentPath: String { listsChildren ? path : "/" + components.dropLast().joined(separator: "/") } + } + + 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 query = completionQuery(for: input) else { return [] } + let path = query.path + let listsChildren = query.listsChildren + let rootPath = normalizedPath(treeStore.root.url.path) + if (path.caseInsensitiveCompare(rootPath) != .orderedSame || !listsChildren), + rootPath.range(of: path, options: [.anchored, .caseInsensitive]) != nil, + isVisible(treeStore.root) { + return [treeStore.root] + } + + 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 + 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, prefix: prefix) } ?? matches.endIndex + if insertion < childLimit { + matches.insert(child, at: insertion) + if matches.count > childLimit { matches.removeLast() } + } + } + 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 + } + + 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, 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() + 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, !isAmbiguous else { return nil } + current = match + } + return current + } + + private func isVisible(_ node: FileNodeRecord) -> Bool { + !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 + 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, 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 + } +} diff --git a/Radix/ViewModels/AppModel.swift b/Radix/ViewModels/AppModel.swift index 5da260c..43055c6 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,68 @@ 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() + 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 { + navigationModel.reveal(nodeID: node.id) + } + dismissGoToPath() + return true + } + func zoomIntoSelection() { do { let node = try validatedSelection(requiresDirectory: true, requiresLivePath: false) @@ -3204,6 +3271,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..1577677 --- /dev/null +++ b/RadixCoreTests/GoToPathTests.swift @@ -0,0 +1,423 @@ +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"), + ("/SCAN/reports/EMPTY/", "empty"), + ("/ScAn/REPORTS/NOTES.TXT", "file"), + ("/scan/reports", "reports"), + ]) + 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-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) + } + + @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/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]) + #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(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(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)") } + 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) + } + } + + @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 + 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) + let match = try #require(resolver.completionMatch(for: "~/sca", in: file)) + #expect(match.componentIndex == home.split(separator: "/").count) + #expect(match.matchedCharacterCount == 3) + } + + @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("/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(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 invalidPathsAndStaleRequestsPreserveBrowsing() 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) + } + + @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 { + 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, 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: store ?? 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 +}