diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 70523c3e3d75..7266b21d283f 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -105209,104 +105209,104 @@ } } }, - "fileExplorer.search.empty": { + "fileExplorer.search.failed": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Type to search" + "value": "Search failed: %@" } }, "ja": { "stringUnit": { "state": "translated", - "value": "入力して検索" + "value": "検索に失敗しました: %@" } } } }, - "fileExplorer.search.failed": { + "fileExplorer.search.limit": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Search failed: %@" + "value": "First %d matches" } }, "ja": { "stringUnit": { "state": "translated", - "value": "検索に失敗しました: %@" + "value": "最初の%d件" } } } }, - "fileExplorer.search.limit": { + "fileExplorer.search.matches": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "First %d matches" + "value": "%d matches" } }, "ja": { "stringUnit": { "state": "translated", - "value": "最初の%d件" + "value": "%d件" } } } }, - "fileExplorer.search.matches": { + "fileExplorer.search.noMatches": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "%d matches" + "value": "No matches" } }, "ja": { "stringUnit": { "state": "translated", - "value": "%d件" + "value": "一致なし" } } } }, - "fileExplorer.search.noMatches": { + "fileExplorer.search.placeholder": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "No matches" + "value": "Find in files" } }, "ja": { "stringUnit": { "state": "translated", - "value": "一致なし" + "value": "ファイル内を検索" } } } }, - "fileExplorer.search.placeholder": { + "fileExplorer.search.result.accessibilityLabel": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Search files" + "value": "%@: line %lld" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ファイルを検索" + "value": "%@: %lld行目" } } } diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 82a2fc179dce..e38a8e2c5e38 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -7056,7 +7056,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent @discardableResult func toggleRightSidebarKeyboardFocusInActiveMainWindow(preferredWindow: NSWindow? = nil) -> Bool { let context = preferredRegisteredMainWindowContext(preferredWindow: preferredWindow) - guard let context else { #if DEBUG dlog( @@ -7078,7 +7077,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if let window { mainWindowVisibilityController.focusForInWindowCommand(window, reason: .rightSidebarToggle) } - let result = context.keyboardFocusCoordinator.toggleRightSidebarOrTerminalFocus() + // The primary Cmd-Shift-E route is the Files shortcut; Find has its own customizable action. + let result = context.keyboardFocusCoordinator.toggleRightSidebarOrTerminalFocus(mode: .files) #if DEBUG let afterResponder = window?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" dlog( diff --git a/Sources/ContentView+RightSidebarCommandPalette.swift b/Sources/ContentView+RightSidebarCommandPalette.swift index b613368d9007..899eecaf7a07 100644 --- a/Sources/ContentView+RightSidebarCommandPalette.swift +++ b/Sources/ContentView+RightSidebarCommandPalette.swift @@ -102,7 +102,7 @@ extension ContentView { { _ in value } } - return RightSidebarMode.availableModes().map { mode in + return RightSidebarMode.availableActivationModes().map { mode in let title = mode.shortcutAction?.label ?? mode.label return CommandPaletteCommandContribution( commandId: Self.commandPaletteRightSidebarModeCommandID(mode), @@ -224,7 +224,7 @@ extension ContentView { private static func commandPaletteRightSidebarModeShortcutAction( forCommandID commandID: String ) -> KeyboardShortcutSettings.Action? { - guard let mode = RightSidebarMode.availableModes().first(where: { mode in + guard let mode = RightSidebarMode.availableActivationModes().first(where: { mode in Self.commandPaletteRightSidebarModeCommandID(mode) == commandID }) else { return nil diff --git a/Sources/FileExplorerContainerView+SearchResults.swift b/Sources/FileExplorerContainerView+SearchResults.swift new file mode 100644 index 000000000000..f45d4f1a7f01 --- /dev/null +++ b/Sources/FileExplorerContainerView+SearchResults.swift @@ -0,0 +1,177 @@ +import AppKit + +extension FileExplorerContainerView: NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate, NSMenuDelegate { + func moveSearchSelection(by delta: Int, focusResults: Bool) { + guard !searchSnapshot.results.isEmpty else { return } + let currentRow = searchResultsView.selectedRow >= 0 + ? searchResultsView.selectedRow + : (delta >= 0 ? -1 : searchSnapshot.results.count) + let targetRow = min(max(currentRow + delta, 0), searchSnapshot.results.count - 1) + searchResultsView.selectRowIndexes(IndexSet(integer: targetRow), byExtendingSelection: false) + searchResultsView.scrollRowToVisible(targetRow) + if focusResults, let window { _ = window.makeFirstResponder(searchResultsView) } + } + + @MainActor + func openSelectedSearchResult() { + let row = searchResultsView.selectedRow + guard row >= 0, row < searchSnapshot.results.count else { return } + let path = searchSnapshot.results[row].path + guard coordinator.store.provider is LocalFileExplorerProvider else { + coordinator.onOpenFilePreview(path) + return + } + performFileExplorerFileOpen(path: path, onOpenFilePreview: coordinator.onOpenFilePreview) + } + + func controlTextDidChange(_ notification: Notification) { + guard notification.object as? NSTextField === searchField else { return } + searchFieldTextDidChange() + } + + func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + guard control === searchField, !textView.hasMarkedText() else { return false } + if let event = NSApp.currentEvent, searchField.handleOpenSelectionShortcut(event) { return true } + return handleSearchFieldCommand(commandSelector, textView: textView) + } + + func numberOfRows(in tableView: NSTableView) -> Int { searchSnapshot.results.count } + + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + guard row >= 0, row < searchSnapshot.results.count else { return nil } + let result = searchSnapshot.results[row] + let startsFileGroup = row == 0 || searchSnapshot.results[row - 1].path != result.path + let identifier = NSUserInterfaceItemIdentifier("FileSearchResultCell") + let cellView = tableView.makeView(withIdentifier: identifier, owner: nil) as? FileExplorerSearchResultCellView + ?? FileExplorerSearchResultCellView(identifier: identifier) + cellView.configure(with: result, startsFileGroup: startsFileGroup) + return cellView + } + + func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat { + guard tableView === searchResultsView, row >= 0, row < searchSnapshot.results.count else { + return FileExplorerSearchResultCellView.preferredRowHeight + } + let startsFileGroup = row == 0 || searchSnapshot.results[row - 1].path != searchSnapshot.results[row].path + return FileExplorerSearchResultCellView.preferredRowHeight(startsFileGroup: startsFileGroup) + } + + func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> (any NSPasteboardWriting)? { + guard tableView === searchResultsView, row >= 0, row < searchSnapshot.results.count else { return nil } + let result = searchSnapshot.results[row] + return FilePreviewDragPasteboardWriter( + filePath: result.path, + displayTitle: (result.relativePath as NSString).lastPathComponent + ) + } + + func tableView( + _ tableView: NSTableView, + draggingSession session: NSDraggingSession, + endedAt screenPoint: NSPoint, + operation: NSDragOperation + ) { + guard tableView === searchResultsView else { return } + FilePreviewDragPasteboardWriter.discardRegisteredDrag(from: NSPasteboard(name: .drag)) + } + + func menuNeedsUpdate(_ menu: NSMenu) { + guard let searchMenu = searchResultsView.menu, menu === searchMenu else { return } + menu.removeAllItems() + let clickedRow = searchResultsView.clickedRow + let row = clickedRow >= 0 ? clickedRow : searchResultsView.selectedRow + guard row >= 0, row < searchSnapshot.results.count else { return } + if clickedRow >= 0 && !searchResultsView.selectedRowIndexes.contains(clickedRow) { + searchResultsView.selectRowIndexes(IndexSet(integer: clickedRow), byExtendingSelection: false) + } + let menuSelection = FileExplorerSearchMenuSelection( + clickedResult: searchSnapshot.results[row], + selectedResults: searchResultsView.selectedRowIndexes.compactMap { + searchSnapshot.results.indices.contains($0) ? searchSnapshot.results[$0] : nil + } + ) + + let openInCmuxItem = NSMenuItem( + title: String(localized: "fileExplorer.contextMenu.openInCmux", defaultValue: "Open in cmux"), + action: #selector(contextMenuOpenSearchResultInCmux(_:)), + keyEquivalent: "" + ) + openInCmuxItem.target = self + openInCmuxItem.representedObject = menuSelection + menu.addItem(openInCmuxItem) + + FileExplorerExternalOpenMenuItems( + fileURL: URL(fileURLWithPath: searchSnapshot.results[row].path), + target: self, + action: #selector(contextMenuOpenSearchResultExternally(_:)) + ).add(to: menu) + + let revealItem = NSMenuItem( + title: FileExternalOpenText.revealInFinder, + action: #selector(contextMenuRevealSearchResultInFinder(_:)), + keyEquivalent: "" + ) + revealItem.target = self + revealItem.representedObject = menuSelection + menu.addItem(revealItem) + menu.addItem(.separator()) + menu.addFileExplorerInsertPathItems( + target: self, + representedObject: menuSelection, + insertAction: #selector(contextMenuInsertSearchResultPath(_:)), + insertRelativeAction: #selector(contextMenuInsertSearchResultRelativePath(_:)) + ) + + for (title, action) in [ + (String(localized: "fileExplorer.contextMenu.copyPath", defaultValue: "Copy Path"), #selector(contextMenuCopySearchResultPath(_:))), + (String(localized: "fileExplorer.contextMenu.copyRelativePath", defaultValue: "Copy Relative Path"), #selector(contextMenuCopySearchResultRelativePath(_:))), + ] { + let item = NSMenuItem(title: title, action: action, keyEquivalent: "") + item.target = self + item.representedObject = menuSelection + menu.addItem(item) + } + } + + func scrollSearchFieldEditorToInsertionPoint() { + guard let editor = searchField.currentEditor() else { return } + let selection = editor.selectedRange + let textLength = (editor.string as NSString).length + editor.scrollRangeToVisible(NSRange(location: min(selection.location + selection.length, textLength), length: 0)) + } + + private func searchResult(forMenuItem sender: NSMenuItem) -> FileSearchResult? { + (sender.representedObject as? FileExplorerSearchMenuSelection)?.clickedResult + } + + @objc func openSelectedSearchResultFromTable(_ sender: NSTableView) { + openSelectedSearchResult() + } + + @objc private func contextMenuOpenSearchResultInCmux(_ sender: NSMenuItem) { + guard let result = searchResult(forMenuItem: sender) else { return } + coordinator.onOpenFilePreview(result.path) + } + + @objc private func contextMenuOpenSearchResultExternally(_ sender: NSMenuItem) { + guard let request = sender.representedObject as? FileExplorerExternalOpenRequest else { return } + FileExternalOpenAction.open(fileURL: request.fileURL, applicationURL: request.applicationURL) + } + + @objc private func contextMenuRevealSearchResultInFinder(_ sender: NSMenuItem) { + guard let result = searchResult(forMenuItem: sender) else { return } + FileExternalOpenAction.revealInFinder(fileURL: URL(fileURLWithPath: result.path)) + } + + @objc private func contextMenuCopySearchResultPath(_ sender: NSMenuItem) { + guard let result = searchResult(forMenuItem: sender) else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(result.path, forType: .string) + } + + @objc private func contextMenuCopySearchResultRelativePath(_ sender: NSMenuItem) { + guard let result = searchResult(forMenuItem: sender) else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(result.relativePath, forType: .string) + } +} diff --git a/Sources/FileExplorerKeyboardShortcuts.swift b/Sources/FileExplorerKeyboardShortcuts.swift index 628ee0b53062..f8c52b361f6e 100644 --- a/Sources/FileExplorerKeyboardShortcuts.swift +++ b/Sources/FileExplorerKeyboardShortcuts.swift @@ -23,8 +23,50 @@ func performFileExplorerFileOpen(path: String, onOpenFilePreview: (String) -> Vo @MainActor extension FileExplorerPanelView.Coordinator { func openSelectedNode(in outlineView: NSOutlineView) { - guard let row = resolvedSelectionRow(in: outlineView) else { return } - openNode(in: outlineView, at: row) + afterApplyingPendingFileFilter { [weak self, weak outlineView] in + guard let self, let outlineView, + let row = self.resolvedSelectionRow(in: outlineView) else { return } + self.openNode(in: outlineView, at: row) + } + } + + func openNodeAfterApplyingFileFilter(in outlineView: NSOutlineView, at row: Int) { + guard row >= 0, + let path = (outlineView.item(atRow: row) as? FileExplorerNode)?.path else { return } + afterApplyingPendingFileFilter { [weak self, weak outlineView] in + guard let self, let outlineView, + let resolvedRow = self.row(forPath: path, in: outlineView) else { return } + self.openNode(in: outlineView, at: resolvedRow) + } + } + + func moveSelection(in outlineView: NSOutlineView, by delta: Int) { + afterApplyingPendingFileFilter { [weak self, weak outlineView] in + guard let self, let outlineView else { return } + guard outlineView.numberOfRows > 0 else { + self.store.select(node: nil) + return + } + let currentRow = self.resolvedSelectionRow(in: outlineView) + ?? (delta >= 0 ? -1 : outlineView.numberOfRows) + let targetRow = min(max(currentRow + delta, 0), outlineView.numberOfRows - 1) + self.selectRow(targetRow, in: outlineView, scroll: true) + } + } + + func performDisclosureAction( + _ action: RightSidebarKeyboardNavigation.DisclosureAction, + in outlineView: NSOutlineView + ) { + afterApplyingPendingFileFilter { [weak self, weak outlineView] in + guard let self, let outlineView else { return } + switch action { + case .collapse: + self.collapseSelectedItemOrMoveToParent(in: outlineView) + case .expand: + self.expandSelectedItemOrMoveToChild(in: outlineView) + } + } } func openNode(in outlineView: NSOutlineView, at row: Int) { @@ -46,12 +88,25 @@ extension FileExplorerPanelView.Coordinator { } performFileExplorerFileOpen(path: node.path, onOpenFilePreview: onOpenFilePreview) } + + private func afterApplyingPendingFileFilter(_ action: @escaping () -> Void) { + guard let containerView else { + action() + return + } + containerView.applyPendingFileFilter(afterApplying: action) + } + + private func row(forPath path: String, in outlineView: NSOutlineView) -> Int? { + (0.. Bool { guard event.isFileExplorerOpenSelectionShortcut(in: fileExplorerPanelPlacement) else { return false } - endQuickSearch() fileExplorerCoordinator?.openSelectedNode(in: self) return true } diff --git a/Sources/FileExplorerNSOutlineView.swift b/Sources/FileExplorerNSOutlineView.swift index f655fed4706c..cc7b167b1a73 100644 --- a/Sources/FileExplorerNSOutlineView.swift +++ b/Sources/FileExplorerNSOutlineView.swift @@ -16,6 +16,8 @@ final class FileExplorerNSOutlineView: NSOutlineView { } } + // Bare letters remain query input while filtering. AppKit function-key + // scalars are non-printable and fall through to navigation below. if quickSearchActive, RightSidebarKeyboardNavigation.isPlainPrintableText(event), handleQuickSearchKey(event) { @@ -31,13 +33,11 @@ final class FileExplorerNSOutlineView: NSOutlineView { } if let delta = RightSidebarKeyboardNavigation.moveDelta(for: event) { - endQuickSearch() fileExplorerCoordinator?.moveSelection(in: self, by: delta) return } if let action = RightSidebarKeyboardNavigation.disclosureAction(for: event) { - endQuickSearch() fileExplorerCoordinator?.performDisclosureAction(action, in: self) return } @@ -66,15 +66,17 @@ final class FileExplorerNSOutlineView: NSOutlineView { return true } if let delta = RightSidebarKeyboardNavigation.moveDelta(for: event) { - endQuickSearch() fileExplorerCoordinator?.moveSelection(in: self, by: delta) return true } if let action = RightSidebarKeyboardNavigation.disclosureAction(for: event) { - endQuickSearch() fileExplorerCoordinator?.performDisclosureAction(action, in: self) return true } + if RightSidebarKeyboardNavigation.isPlainSlash(event) { + beginQuickSearch() + return true + } return super.performKeyEquivalent(with: event) } @@ -89,7 +91,6 @@ final class FileExplorerNSOutlineView: NSOutlineView { override func resignFirstResponder() -> Bool { let result = super.resignFirstResponder() if result { - endQuickSearch() redrawVisibleRows() } return result @@ -156,15 +157,10 @@ final class FileExplorerNSOutlineView: NSOutlineView { endQuickSearch() return true } - if event.keyCode == 36 || event.keyCode == 76 { - endQuickSearch() - return true - } if event.keyCode == 51 { if !quickSearchQuery.isEmpty { quickSearchQuery.removeLast() onQuickSearchChanged?(quickSearchQuery) - fileExplorerCoordinator?.selectBestQuickSearchMatch(in: self, query: quickSearchQuery) } return true } @@ -176,7 +172,6 @@ final class FileExplorerNSOutlineView: NSOutlineView { } quickSearchQuery += text onQuickSearchChanged?(quickSearchQuery) - fileExplorerCoordinator?.selectBestQuickSearchMatch(in: self, query: quickSearchQuery) return true } } diff --git a/Sources/FileExplorerPanelPlacement.swift b/Sources/FileExplorerPanelPlacement.swift new file mode 100644 index 000000000000..27d82f5d90eb --- /dev/null +++ b/Sources/FileExplorerPanelPlacement.swift @@ -0,0 +1,5 @@ +/// Location hosting a file explorer panel. +enum FileExplorerPanelPlacement: Equatable { + case rightSidebar + case pane +} diff --git a/Sources/FileExplorerPanelPresentation.swift b/Sources/FileExplorerPanelPresentation.swift new file mode 100644 index 000000000000..dca59528a859 --- /dev/null +++ b/Sources/FileExplorerPanelPresentation.swift @@ -0,0 +1,17 @@ +/// Visual presentation of the shared file explorer host. +enum FileExplorerPanelPresentation: Equatable { + case unified + case files + case find + + var rightSidebarMode: RightSidebarMode { + switch self { + case .unified, .files: .files + case .find: .find + } + } + + var keepsSearchFieldVisible: Bool { + self == .unified + } +} diff --git a/Sources/FileExplorerPanelView+TreeFilter.swift b/Sources/FileExplorerPanelView+TreeFilter.swift new file mode 100644 index 000000000000..506c8ff78c98 --- /dev/null +++ b/Sources/FileExplorerPanelView+TreeFilter.swift @@ -0,0 +1,187 @@ +import AppKit + +@MainActor +extension FileExplorerPanelView.Coordinator { + func suspendFileFilter() { + cancelFileFilterTask(discardingPendingActions: true) + } + + func invalidateFileFilterIndex() { + cancelFileFilterTask(discardingPendingActions: true) + fileFilter.invalidateIndex() + fileFilterTreeRevision = -1 + } + + func setFileFilterQuery( + _ query: String, + in outlineView: NSOutlineView, + afterApplying action: (() -> Void)? = nil + ) { + let normalizedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) + let wasActive = fileFilter.isActive + if !wasActive, !normalizedQuery.isEmpty { + preFilterTopVisiblePath = topVisibleNode(in: outlineView)?.path + } + + let queryChanged = fileFilter.setQuery(normalizedQuery) + if queryChanged { + cancelFileFilterTask(discardingPendingActions: true) + } + if let action { + pendingFileFilterAction = action + } + + let treeChanged = fileFilterTreeRevision != store.treeRevision + if treeChanged, fileFilter.isActive { + let treeRevision = store.treeRevision + let buildsCurrentIndex = fileFilterTask != nil + && fileFilterTaskQuery == fileFilter.query + && fileFilterTaskTreeRevision == treeRevision + if buildsCurrentIndex { + return + } + cancelFileFilterTask(discardingPendingActions: false) + let builder = FileExplorerTreeFilterSnapshotBuilder(nodes: store.rootNodes) + if let captured = builder.buildSynchronously( + upTo: FileExplorerTreeFilterSnapshot.synchronousNodeLimit + ) { + fileFilter.replaceIndex( + snapshot: captured.snapshot, + nodesByPath: captured.nodesByPath + ) + fileFilterTreeRevision = treeRevision + } else { + startFileFilterTask( + snapshot: nil, + builder: builder, + query: fileFilter.query, + treeRevision: treeRevision, + outlineView: outlineView + ) + return + } + } + + guard fileFilter.isActive else { + if queryChanged || treeChanged { + reloadFilteredTree(in: outlineView) + if wasActive { + restorePreFilterScroll(in: outlineView) + } + } else { + refreshFilteredRows(in: outlineView) + } + runPendingFileFilterActions() + return + } + + guard queryChanged || treeChanged || fileFilter.needsFiltering else { + refreshFilteredRows(in: outlineView) + runPendingFileFilterActions() + return + } + if !queryChanged, !treeChanged, fileFilterTask != nil { + return + } + + let snapshot = fileFilter.snapshot + if snapshot.nodeCount <= FileExplorerTreeFilterSnapshot.synchronousNodeLimit { + fileFilterTask?.cancel() + clearFileFilterTask() + let result = snapshot.filterSynchronously(query: fileFilter.query) + guard fileFilter.apply(result) else { return } + reloadFilteredTree(in: outlineView) + runPendingFileFilterActions() + return + } + + startFileFilterTask( + snapshot: snapshot, + builder: nil, + query: fileFilter.query, + treeRevision: fileFilterTreeRevision, + outlineView: outlineView + ) + } + + private func startFileFilterTask( + snapshot: FileExplorerTreeFilterSnapshot?, + builder: FileExplorerTreeFilterSnapshotBuilder?, + query: String, + treeRevision: Int, + outlineView: NSOutlineView + ) { + cancelFileFilterTask(discardingPendingActions: false) + let generation = fileFilterGeneration + fileFilterTaskQuery = query + fileFilterTaskTreeRevision = treeRevision + fileFilterTask = Task { [weak self, weak outlineView] in + do { + var filterSnapshot = snapshot + if let builder { + let captured = try await builder.build() + try Task.checkCancellation() + guard let coordinator = self, let outlineView, + coordinator.fileFilterGeneration == generation else { return } + guard coordinator.containerView?.displayedSearchScope == .names else { + coordinator.clearFileFilterTask() + return + } + guard treeRevision == coordinator.store.treeRevision else { + coordinator.clearFileFilterTask() + coordinator.setFileFilterQuery(coordinator.fileFilter.query, in: outlineView) + return + } + coordinator.fileFilter.replaceIndex( + snapshot: captured.snapshot, + nodesByPath: captured.nodesByPath + ) + coordinator.fileFilterTreeRevision = treeRevision + filterSnapshot = captured.snapshot + } + + guard let filterSnapshot else { return } + let result = try await filterSnapshot.filter(query: query) + try Task.checkCancellation() + guard let self, let outlineView, + self.fileFilterGeneration == generation else { return } + self.clearFileFilterTask() + guard self.containerView?.displayedSearchScope == .names else { return } + guard treeRevision == self.store.treeRevision else { + self.setFileFilterQuery(self.fileFilter.query, in: outlineView) + return + } + guard self.fileFilter.apply(result) else { return } + self.reloadFilteredTree(in: outlineView) + self.runPendingFileFilterActions() + } catch is CancellationError { + guard let self, self.fileFilterGeneration == generation else { return } + self.clearFileFilterTask() + } catch { + guard let self, self.fileFilterGeneration == generation else { return } + self.clearFileFilterTask() + } + } + } + + private func cancelFileFilterTask(discardingPendingActions: Bool) { + fileFilterTask?.cancel() + clearFileFilterTask() + fileFilterGeneration &+= 1 + if discardingPendingActions { + pendingFileFilterAction = nil + } + } + + private func runPendingFileFilterActions() { + let action = pendingFileFilterAction + pendingFileFilterAction = nil + action?() + } + + private func clearFileFilterTask() { + fileFilterTask = nil + fileFilterTaskQuery = nil + fileFilterTaskTreeRevision = nil + } +} diff --git a/Sources/FileExplorerSearchBarView.swift b/Sources/FileExplorerSearchBarView.swift new file mode 100644 index 000000000000..c6d094cd69db --- /dev/null +++ b/Sources/FileExplorerSearchBarView.swift @@ -0,0 +1,81 @@ +import AppKit +import CmuxFoundation + +/// The original Finder-style search chrome used by full-text search. +@MainActor +final class FileExplorerSearchBarView: NSView { + let searchField = FileExplorerSearchField() + let statusLabel = NSTextField(labelWithString: "") + + private var searchHeightConstraint: NSLayoutConstraint! + + var preferredHeight: CGFloat { + let baseHeight: CGFloat = 48 + return max(baseHeight, GlobalFontMagnification.scaled(baseHeight)) + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + configureViews() + configureLayout() + applyFonts() + apply(query: "") + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(query: String) { + if searchField.stringValue != query { + searchField.stringValue = query + } + searchField.placeholderString = String( + localized: "fileExplorer.search.placeholder", + defaultValue: "Find in files" + ) + } + + func applyFonts() { + searchField.font = GlobalFontMagnification.systemFont(ofSize: 12, weight: .regular) + statusLabel.font = GlobalFontMagnification.systemFont(ofSize: 11, weight: .medium) + searchHeightConstraint?.constant = max(24, GlobalFontMagnification.scaled(24)) + } + + private func configureViews() { + searchField.translatesAutoresizingMaskIntoConstraints = false + searchField.setAccessibilityIdentifier("FileExplorerSearchField") + searchField.focusRingType = .none + searchField.cell?.usesSingleLineMode = true + searchField.cell?.isScrollable = true + searchField.cell?.lineBreakMode = .byClipping + searchField.setContentHuggingPriority(.defaultLow, for: .horizontal) + searchField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + statusLabel.translatesAutoresizingMaskIntoConstraints = false + statusLabel.textColor = .secondaryLabelColor + statusLabel.lineBreakMode = .byTruncatingTail + statusLabel.maximumNumberOfLines = 1 + statusLabel.alignment = .left + statusLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + statusLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + addSubview(searchField) + addSubview(statusLabel) + } + + private func configureLayout() { + searchHeightConstraint = searchField.heightAnchor.constraint(equalToConstant: 24) + NSLayoutConstraint.activate([ + searchField.topAnchor.constraint(equalTo: topAnchor, constant: 4), + searchField.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8), + searchField.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), + searchField.widthAnchor.constraint(greaterThanOrEqualToConstant: 120), + searchHeightConstraint, + + statusLabel.topAnchor.constraint(equalTo: searchField.bottomAnchor, constant: 2), + statusLabel.leadingAnchor.constraint(equalTo: searchField.leadingAnchor, constant: 4), + statusLabel.trailingAnchor.constraint(equalTo: searchField.trailingAnchor), + ]) + } +} diff --git a/Sources/FileExplorerSearchController.swift b/Sources/FileExplorerSearchController.swift index 4b0c9d325c1f..502197299ea2 100644 --- a/Sources/FileExplorerSearchController.swift +++ b/Sources/FileExplorerSearchController.swift @@ -184,9 +184,21 @@ protocol FileSearchControlling: AnyObject { var onSnapshotChanged: ((FileSearchSnapshot) -> Void)? { get set } func search(query rawQuery: String, rootPath: String, isLocal: Bool, contentRevision: Int) + func search(request: FileSearchRequest) func cancel(clear: Bool) } +extension FileSearchControlling { + func search(request: FileSearchRequest) { + search( + query: request.query, + rootPath: request.rootPath, + isLocal: request.isLocal, + contentRevision: request.contentRevision + ) + } +} + struct FileSearchPipelineUpdate: Sendable { let results: [FileSearchResult] let status: FileSearchSnapshot.Status @@ -418,44 +430,28 @@ private enum FileSearchPipeReader { @MainActor final class FileSearchController: FileSearchControlling { - private struct Request: Equatable { - let query: String - let rootPath: String - let isLocal: Bool - let contentRevision: Int - } - var onSnapshotChanged: ((FileSearchSnapshot) -> Void)? private let maxResults = 500 private let snapshotInterval: TimeInterval = 0.05 - private let excludedSearchGlobs = [ - "!.git/**", - "!**/.git/**", - "!node_modules/**", - "!**/node_modules/**", - "!dist/**", - "!**/dist/**", - "!build/**", - "!**/build/**", - "!DerivedData/**", - "!**/DerivedData/**", - ] private var process: Process? private var generation = 0 - private var request: Request? + private var request: FileSearchRequest? private var results: [FileSearchResult] = [] private var pipeline: FileSearchOutputPipeline? private var searchTask: Task? func search(query rawQuery: String, rootPath: String, isLocal: Bool, contentRevision: Int = 0) { - let query = rawQuery.trimmingCharacters(in: .whitespacesAndNewlines) - let nextRequest = Request( - query: query, + search(request: FileSearchRequest( + query: rawQuery, rootPath: rootPath, isLocal: isLocal, contentRevision: contentRevision - ) + )) + } + + func search(request nextRequest: FileSearchRequest) { + let query = nextRequest.query if nextRequest == request, process?.isRunning == true { return } @@ -468,11 +464,11 @@ final class FileSearchController: FileSearchControlling { emit(status: .idle, isSearching: false) return } - guard isLocal else { + guard nextRequest.isLocal else { emit(status: .unsupported, isSearching: false) return } - guard !rootPath.isEmpty else { + guard !nextRequest.rootPath.isEmpty else { emit(status: .noMatches, isSearching: false) return } @@ -501,28 +497,14 @@ final class FileSearchController: FileSearchControlling { let process = Process() process.executableURL = executable.url - process.arguments = executable.prefixArguments + [ - "--json", - "--line-number", - "--column", - "--smart-case", - "--fixed-strings", - "--max-columns", "300", - "--max-columns-preview", - "--color", "never", - "--hidden", - ] + excludedSearchGlobs.flatMap { ["--glob", $0] } + [ - "--", - query, - rootPath, - ] + process.arguments = executable.prefixArguments + nextRequest.ripgrepArguments let stdout = Pipe() let stderr = Pipe() process.standardOutput = stdout process.standardError = stderr let pipeline = FileSearchOutputPipeline( - rootPath: rootPath, + rootPath: nextRequest.rootPath, maxResults: maxResults, snapshotInterval: snapshotInterval ) diff --git a/Sources/FileExplorerSearchMenuSelection.swift b/Sources/FileExplorerSearchMenuSelection.swift new file mode 100644 index 000000000000..9a181c72a6c2 --- /dev/null +++ b/Sources/FileExplorerSearchMenuSelection.swift @@ -0,0 +1,12 @@ +import Foundation + +/// Immutable search-result identities captured when a context menu opens. +final class FileExplorerSearchMenuSelection: NSObject { + let clickedResult: FileSearchResult + let selectedResults: [FileSearchResult] + + init(clickedResult: FileSearchResult, selectedResults: [FileSearchResult]) { + self.clickedResult = clickedResult + self.selectedResults = selectedResults.isEmpty ? [clickedResult] : selectedResults + } +} diff --git a/Sources/FileExplorerSearchQueryState.swift b/Sources/FileExplorerSearchQueryState.swift new file mode 100644 index 000000000000..33bc8e213b1b --- /dev/null +++ b/Sources/FileExplorerSearchQueryState.swift @@ -0,0 +1,28 @@ +import Foundation + +/// Independent query state for the unified explorer's two scopes. +struct FileExplorerSearchQueryState: Equatable, Sendable { + private(set) var namesQuery = "" + private(set) var contentsQuery = "" + + func query(for scope: FileExplorerSearchScope) -> String { + switch scope { + case .names: namesQuery + case .contents: contentsQuery + } + } + + mutating func setQuery(_ query: String, for scope: FileExplorerSearchScope) { + switch scope { + case .names: + namesQuery = query + case .contents: + contentsQuery = query + } + } + + mutating func clearQueries() { + namesQuery = "" + contentsQuery = "" + } +} diff --git a/Sources/FileExplorerSearchResultCellView.swift b/Sources/FileExplorerSearchResultCellView.swift index b57fd5562a4b..74f731936224 100644 --- a/Sources/FileExplorerSearchResultCellView.swift +++ b/Sources/FileExplorerSearchResultCellView.swift @@ -4,8 +4,18 @@ import CmuxFoundation final class FileExplorerSearchResultCellView: NSTableCellView { private let pathLabel = NSTextField(labelWithString: "") private let previewLabel = NSTextField(labelWithString: "") + private var previewBelowPathConstraint: NSLayoutConstraint! + private var previewAtTopConstraint: NSLayoutConstraint! + static var preferredRowHeight: CGFloat { - max( + preferredRowHeight(startsFileGroup: true) + } + + static func preferredRowHeight(startsFileGroup: Bool) -> CGFloat { + if !startsFileGroup { + return max(26, ceil(10 + lineHeight(for: GlobalFontMagnification.monospacedSystemFont(ofSize: 11, weight: .regular)))) + } + return max( 46, ceil( 13 + @@ -26,6 +36,7 @@ final class FileExplorerSearchResultCellView: NSTableCellView { } private func setupViews() { + setAccessibilityElement(true) pathLabel.translatesAutoresizingMaskIntoConstraints = false pathLabel.textColor = .labelColor pathLabel.lineBreakMode = .byTruncatingMiddle @@ -39,6 +50,9 @@ final class FileExplorerSearchResultCellView: NSTableCellView { addSubview(pathLabel) addSubview(previewLabel) + previewBelowPathConstraint = previewLabel.topAnchor.constraint(equalTo: pathLabel.bottomAnchor, constant: 2) + previewAtTopConstraint = previewLabel.topAnchor.constraint(equalTo: topAnchor, constant: 5) + NSLayoutConstraint.activate([ pathLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 6), pathLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4), @@ -46,15 +60,31 @@ final class FileExplorerSearchResultCellView: NSTableCellView { previewLabel.leadingAnchor.constraint(equalTo: pathLabel.leadingAnchor), previewLabel.trailingAnchor.constraint(equalTo: pathLabel.trailingAnchor), - previewLabel.topAnchor.constraint(equalTo: pathLabel.bottomAnchor, constant: 2), + previewBelowPathConstraint, ]) } - func configure(with result: FileSearchResult) { + func configure(with result: FileSearchResult, startsFileGroup: Bool) { pathLabel.font = GlobalFontMagnification.systemFont(ofSize: 12, weight: .semibold) previewLabel.font = GlobalFontMagnification.monospacedSystemFont(ofSize: 11, weight: .regular) - pathLabel.stringValue = "\(result.relativePath):\(result.lineNumber)" - previewLabel.stringValue = result.preview.isEmpty ? " " : result.preview + pathLabel.isHidden = !startsFileGroup + NSLayoutConstraint.deactivate([previewBelowPathConstraint, previewAtTopConstraint]) + (startsFileGroup ? previewBelowPathConstraint : previewAtTopConstraint).isActive = true + pathLabel.stringValue = result.relativePath + let preview = result.preview.isEmpty ? " " : result.preview + previewLabel.stringValue = "\(result.lineNumber): \(preview)" + let accessibilityFormat = String( + localized: "fileExplorer.search.result.accessibilityLabel", + defaultValue: "%@: line %lld" + ) + setAccessibilityLabel( + String.localizedStringWithFormat( + accessibilityFormat, + result.relativePath, + Int64(result.lineNumber) + ) + ) + setAccessibilityValue(result.preview) toolTip = "\(result.path):\(result.lineNumber):\(result.columnNumber)" } diff --git a/Sources/FileExplorerSearchScope.swift b/Sources/FileExplorerSearchScope.swift new file mode 100644 index 000000000000..56044ce58482 --- /dev/null +++ b/Sources/FileExplorerSearchScope.swift @@ -0,0 +1,19 @@ +import Foundation + +/// The two projections available inside the unified file explorer host. +enum FileExplorerSearchScope: Int, Sendable { + case names + case contents + + init(mode: RightSidebarMode) { + self = mode == .find ? .contents : .names + } + + var activationMode: RightSidebarMode { + switch self { + case .names: .files + case .contents: .find + } + } + +} diff --git a/Sources/FileExplorerState.swift b/Sources/FileExplorerState.swift index 7f92a0b86d42..e22bea1b80d3 100644 --- a/Sources/FileExplorerState.swift +++ b/Sources/FileExplorerState.swift @@ -6,23 +6,24 @@ import SwiftUI final class FileExplorerState: ObservableObject { private static let modeKey = "rightSidebar.mode" private static let customSidebarNameKey = "rightSidebar.customSidebarName" + private let defaults: UserDefaults @Published var isVisible: Bool { - didSet { UserDefaults.standard.set(isVisible, forKey: "fileExplorer.isVisible") } + didSet { defaults.set(isVisible, forKey: "fileExplorer.isVisible") } } @Published var width: CGFloat { - didSet { UserDefaults.standard.set(Double(width), forKey: "fileExplorer.width") } + didSet { defaults.set(Double(width), forKey: "fileExplorer.width") } } /// Proportion of sidebar height allocated to the tab list (0.0-1.0). /// The file explorer gets the remaining space below. @Published var dividerPosition: CGFloat { - didSet { UserDefaults.standard.set(Double(dividerPosition), forKey: "fileExplorer.dividerPosition") } + didSet { defaults.set(Double(dividerPosition), forKey: "fileExplorer.dividerPosition") } } /// Whether hidden files (dotfiles) are shown in the tree. @Published var showHiddenFiles: Bool { - didSet { UserDefaults.standard.set(showHiddenFiles, forKey: "fileExplorer.showHidden") } + didSet { defaults.set(showHiddenFiles, forKey: "fileExplorer.showHidden") } } @Published private var storedMode: RightSidebarMode @@ -36,18 +37,19 @@ final class FileExplorerState: ObservableObject { /// persisted). @Published var rightSidebarOwnsInputFocus: Bool = false - /// Active mode for the right sidebar (file tree, search, sessions, or enabled beta modes). + /// Active right-sidebar target. Activation aliases such as Find retain their + /// identity here while `registeredToolMode` selects the shared mounted tool. var mode: RightSidebarMode { get { storedMode } - set { setMode(newValue) } + set { setMode(newValue, defaults: defaults) } } var customSidebarName: String? { storedCustomSidebarName } - init() { - let defaults = UserDefaults.standard + init(defaults: UserDefaults = .standard) { + self.defaults = defaults self.isVisible = defaults.bool(forKey: "fileExplorer.isVisible") let storedWidth = defaults.double(forKey: "fileExplorer.width") self.width = storedWidth > 0 ? CGFloat(storedWidth) : 220 @@ -62,11 +64,12 @@ final class FileExplorerState: ObservableObject { defaults.set(self.storedMode.rawValue, forKey: Self.modeKey) } - func refreshModeAvailability(defaults: UserDefaults = .standard) { - setMode(storedMode, defaults: defaults) + func refreshModeAvailability(defaults: UserDefaults? = nil) { + setMode(storedMode, defaults: defaults ?? self.defaults) } - func selectCustomSidebar(name rawName: String, defaults: UserDefaults = .standard) { + func selectCustomSidebar(name rawName: String, defaults: UserDefaults? = nil) { + let defaults = defaults ?? self.defaults let name = rawName.trimmingCharacters(in: .whitespacesAndNewlines) guard !name.isEmpty else { return } storedCustomSidebarName = name @@ -99,7 +102,7 @@ final class FileExplorerState: ObservableObject { } } - private func setMode(_ mode: RightSidebarMode, defaults: UserDefaults = .standard) { + private func setMode(_ mode: RightSidebarMode, defaults: UserDefaults) { let nextMode = Self.availableMode(mode, defaults: defaults) guard storedMode != nextMode else { if defaults.string(forKey: Self.modeKey) != nextMode.rawValue { diff --git a/Sources/FileExplorerStore.swift b/Sources/FileExplorerStore.swift index f8ec384dc227..adc7a9ba78f5 100644 --- a/Sources/FileExplorerStore.swift +++ b/Sources/FileExplorerStore.swift @@ -740,15 +740,14 @@ enum FileExplorerSelectionRestoration { /// but are not annotated @MainActor. final class FileExplorerStore: ObservableObject { @Published var rootPath: String = "" - @Published var rootNodes: [FileExplorerNode] = [] + @Published var rootNodes: [FileExplorerNode] = [] { didSet { treeRevision &+= 1 } } + private(set) var treeRevision = 0 @Published private(set) var isRootLoading: Bool = false @Published private(set) var gitStatusByPath: [String: GitFileStatus] = [:] @Published private(set) var contentRevision = 0 @Published private(set) var rootStatusMessage: String? private(set) var workspaceRootIdentity: UUID? - var provider: FileExplorerProvider? - /// Whether hidden files are shown. Set from FileExplorerState externally. var showHiddenFiles: Bool = false @@ -1077,6 +1076,7 @@ final class FileExplorerStore: ObservableObject { if let parentNode { parentNode.children = children + treeRevision &+= 1 parentNode.isLoading = false parentNode.error = nil if pendingDescendIntoFirstChildPath == parentNode.path { diff --git a/Sources/FileExplorerTerminalPathInsertion.swift b/Sources/FileExplorerTerminalPathInsertion.swift index 9559d3818814..0262e84f718e 100644 --- a/Sources/FileExplorerTerminalPathInsertion.swift +++ b/Sources/FileExplorerTerminalPathInsertion.swift @@ -150,34 +150,20 @@ extension FileExplorerPanelView.Coordinator { } extension FileExplorerContainerView { - @MainActor - private func searchResultsForContextMenu(row: Int) -> [FileSearchResult] { - guard row >= 0, row < searchSnapshot.results.count else { return [] } - let selectedRows = searchResultsView.selectedRowIndexes - guard selectedRows.contains(row) else { - return [searchSnapshot.results[row]] - } - let results = selectedRows.compactMap { selectedRow -> FileSearchResult? in - guard selectedRow >= 0, selectedRow < searchSnapshot.results.count else { return nil } - return searchSnapshot.results[selectedRow] - } - return results.isEmpty ? [searchSnapshot.results[row]] : results - } - @MainActor @objc func contextMenuInsertSearchResultPath(_ sender: NSMenuItem) { - guard let row = (sender.representedObject as? NSNumber)?.intValue else { return } + guard let selection = sender.representedObject as? FileExplorerSearchMenuSelection else { return } FileExplorerTerminalPathInsertion.insert( - paths: searchResultsForContextMenu(row: row).map(\.path), + paths: selection.selectedResults.map(\.path), intoTerminalFor: window ) } @MainActor @objc func contextMenuInsertSearchResultRelativePath(_ sender: NSMenuItem) { - guard let row = (sender.representedObject as? NSNumber)?.intValue else { return } + guard let selection = sender.representedObject as? FileExplorerSearchMenuSelection else { return } FileExplorerTerminalPathInsertion.insert( - paths: searchResultsForContextMenu(row: row).map(\.relativePath), + paths: selection.selectedResults.map(\.relativePath), intoTerminalFor: window ) } diff --git a/Sources/FileExplorerTreeFilter.swift b/Sources/FileExplorerTreeFilter.swift new file mode 100644 index 000000000000..935341341b89 --- /dev/null +++ b/Sources/FileExplorerTreeFilter.swift @@ -0,0 +1,76 @@ +import Foundation + +/// Filters an immutable index of already-loaded nodes without loading directories. +struct FileExplorerTreeFilter { + private(set) var query = "" + private(set) var snapshot = FileExplorerTreeFilterSnapshot.empty + private var nodesByPath: [String: FileExplorerNode] = [:] + private var filteredRootNodes: [FileExplorerNode] = [] + private var filteredChildrenByPath: [String: [FileExplorerNode]] = [:] + private var matchingPaths: Set = [] + private(set) var needsFiltering = false + + var isActive: Bool { !query.isEmpty } + + @discardableResult + mutating func setQuery(_ rawQuery: String) -> Bool { + let nextQuery = rawQuery.trimmingCharacters(in: .whitespacesAndNewlines) + guard nextQuery != query else { return false } + query = nextQuery + needsFiltering = !nextQuery.isEmpty + if nextQuery.isEmpty { + filteredRootNodes.removeAll(keepingCapacity: true) + filteredChildrenByPath.removeAll(keepingCapacity: true) + matchingPaths.removeAll(keepingCapacity: true) + } + return true + } + + @MainActor + mutating func replaceIndex( + snapshot: FileExplorerTreeFilterSnapshot, + nodesByPath: [String: FileExplorerNode] + ) { + self.snapshot = snapshot + self.nodesByPath = nodesByPath + needsFiltering = isActive + } + + @discardableResult + mutating func apply(_ result: FileExplorerTreeFilterResult) -> Bool { + guard result.query == query else { return false } + filteredRootNodes = result.rootPaths.compactMap { nodesByPath[$0] } + filteredChildrenByPath.removeAll(keepingCapacity: true) + for (path, children) in result.childrenByPath { + filteredChildrenByPath[path] = children.compactMap { nodesByPath[$0] } + } + matchingPaths = result.matchingPaths + needsFiltering = false + return true + } + + func visibleRootNodes(in nodes: [FileExplorerNode]) -> [FileExplorerNode] { + isActive ? filteredRootNodes : nodes + } + + func visibleChildren(of node: FileExplorerNode) -> [FileExplorerNode] { + isActive ? filteredChildrenByPath[node.path] ?? [] : node.sortedChildren ?? [] + } + + func hasVisibleChildren(_ node: FileExplorerNode) -> Bool { + !visibleChildren(of: node).isEmpty + } + + func isDirectMatch(_ node: FileExplorerNode) -> Bool { + matchingPaths.contains(node.path) + } + + mutating func invalidateIndex() { + snapshot = .empty + nodesByPath.removeAll() + filteredRootNodes.removeAll() + filteredChildrenByPath.removeAll() + matchingPaths.removeAll() + needsFiltering = isActive + } +} diff --git a/Sources/FileExplorerTreeFilterResult.swift b/Sources/FileExplorerTreeFilterResult.swift new file mode 100644 index 000000000000..6bf03919e1f1 --- /dev/null +++ b/Sources/FileExplorerTreeFilterResult.swift @@ -0,0 +1,18 @@ +import Foundation + +/// Path-only filename filter output that can safely cross concurrency domains. +nonisolated struct FileExplorerTreeFilterResult: Sendable { + let query: String + let rootPaths: [String] + let childrenByPath: [String: [String]] + let matchingPaths: Set + + static func empty(query: String = "") -> FileExplorerTreeFilterResult { + FileExplorerTreeFilterResult( + query: query, + rootPaths: [], + childrenByPath: [:], + matchingPaths: [] + ) + } +} diff --git a/Sources/FileExplorerTreeFilterSnapshot.swift b/Sources/FileExplorerTreeFilterSnapshot.swift new file mode 100644 index 000000000000..ad65501095db --- /dev/null +++ b/Sources/FileExplorerTreeFilterSnapshot.swift @@ -0,0 +1,102 @@ +import Foundation + +/// Immutable, already-loaded filename index used by cancellable off-main filtering. +nonisolated struct FileExplorerTreeFilterSnapshot: Sendable { + static let synchronousNodeLimit = 2_048 + /// Bounds automatic outline expansion work for broad filename queries. + static let maximumVisibleNodeCount = 512 + + let namesByPath: [String: String] + let parentByPath: [String: String] + let rootPaths: [String] + let childrenByPath: [String: [String]] + + static let empty = FileExplorerTreeFilterSnapshot( + namesByPath: [:], + parentByPath: [:], + rootPaths: [], + childrenByPath: [:] + ) + + var nodeCount: Int { namesByPath.count } + + func filterSynchronously(query: String) -> FileExplorerTreeFilterResult { + do { + return try filteredResult(query: query, checksCancellation: false) + } catch { + return .empty(query: query) + } + } + + @concurrent + func filter(query: String) async throws -> FileExplorerTreeFilterResult { + try filteredResult(query: query, checksCancellation: true) + } + + init( + namesByPath: [String: String], + parentByPath: [String: String], + rootPaths: [String], + childrenByPath: [String: [String]] + ) { + self.namesByPath = namesByPath + self.parentByPath = parentByPath + self.rootPaths = rootPaths + self.childrenByPath = childrenByPath + } + + private func filteredResult( + query: String, + checksCancellation: Bool + ) throws -> FileExplorerTreeFilterResult { + guard !query.isEmpty else { return .empty(query: query) } + var visiblePaths: Set = [] + var matchingPaths: Set = [] + var pendingPaths = Array(rootPaths.reversed()) + var visitedNodeCount = 0 + while let path = pendingPaths.popLast() { + if checksCancellation, visitedNodeCount.isMultiple(of: 256) { + try Task.checkCancellation() + } + visitedNodeCount += 1 + if let name = namesByPath[path], name.localizedStandardContains(query) { + var lineage: [String] = [] + var currentPath: String? = path + while let candidate = currentPath, !visiblePaths.contains(candidate) { + lineage.append(candidate) + currentPath = parentByPath[candidate] + } + guard visiblePaths.count + lineage.count <= Self.maximumVisibleNodeCount else { + break + } + visiblePaths.formUnion(lineage) + matchingPaths.insert(path) + if visiblePaths.count == Self.maximumVisibleNodeCount { + break + } + } + if let children = childrenByPath[path] { + pendingPaths.append(contentsOf: children.reversed()) + } + } + let filteredRootPaths = rootPaths.filter(visiblePaths.contains) + var filteredChildrenByPath: [String: [String]] = [:] + for (index, path) in visiblePaths.enumerated() { + if checksCancellation, index.isMultiple(of: 256) { + try Task.checkCancellation() + } + guard let children = childrenByPath[path] else { continue } + let visibleChildren = children.filter(visiblePaths.contains) + if !visibleChildren.isEmpty { + filteredChildrenByPath[path] = visibleChildren + } + } + return FileExplorerTreeFilterResult( + query: query, + rootPaths: filteredRootPaths, + childrenByPath: filteredChildrenByPath, + matchingPaths: matchingPaths + ) + } + +} diff --git a/Sources/FileExplorerTreeFilterSnapshotBuilder.swift b/Sources/FileExplorerTreeFilterSnapshotBuilder.swift new file mode 100644 index 000000000000..da302b15deb6 --- /dev/null +++ b/Sources/FileExplorerTreeFilterSnapshotBuilder.swift @@ -0,0 +1,95 @@ +import Foundation + +/// Captures the main-actor file-node graph into a Sendable filename index in bounded chunks. +@MainActor +final class FileExplorerTreeFilterSnapshotBuilder { + private static let asynchronousChunkSize = 256 + + private var nodeGroups: [[FileExplorerNode]] + private var groupIndexes: [Int] + private var parentPaths: [String?] + private var namesByPath: [String: String] = [:] + private var parentByPath: [String: String] = [:] + private var rootPaths: [String] = [] + private var childrenByPath: [String: [String]] = [:] + private var nodesByPath: [String: FileExplorerNode] = [:] + + init(nodes: [FileExplorerNode]) { + nodeGroups = [nodes] + groupIndexes = [0] + parentPaths = [nil] + } + + func buildSynchronously( + upTo nodeLimit: Int + ) -> (snapshot: FileExplorerTreeFilterSnapshot, nodesByPath: [String: FileExplorerNode])? { + var processedNodeCount = 0 + while processedNodeCount < nodeLimit, processNextNode() { + processedNodeCount += 1 + } + return nodeGroups.isEmpty ? capturedIndex() : nil + } + + func build() async throws + -> (snapshot: FileExplorerTreeFilterSnapshot, nodesByPath: [String: FileExplorerNode]) + { + while !nodeGroups.isEmpty { + try Task.checkCancellation() + for _ in 0.. Bool { + while let nodes = nodeGroups.last, + let index = groupIndexes.last, + let parentPath = parentPaths.last { + guard index < nodes.count else { + nodeGroups.removeLast() + groupIndexes.removeLast() + parentPaths.removeLast() + continue + } + + groupIndexes[groupIndexes.count - 1] = index + 1 + let node = nodes[index] + nodesByPath[node.path] = node + namesByPath[node.path] = node.name + if node.isDirectory { + childrenByPath[node.path] = [] + } + if let parentPath { + parentByPath[node.path] = parentPath + childrenByPath[parentPath, default: []].append(node.path) + } else { + rootPaths.append(node.path) + } + + if let children = node.children, !children.isEmpty { + nodeGroups.append(children) + groupIndexes.append(0) + parentPaths.append(node.path) + } + return true + } + return false + } + + private func capturedIndex() + -> (snapshot: FileExplorerTreeFilterSnapshot, nodesByPath: [String: FileExplorerNode]) + { + let snapshot = FileExplorerTreeFilterSnapshot( + namesByPath: namesByPath, + parentByPath: parentByPath, + rootPaths: rootPaths, + childrenByPath: childrenByPath + ) + return (snapshot, nodesByPath) + } +} diff --git a/Sources/FileExplorerView.swift b/Sources/FileExplorerView.swift index 0c7c017ae904..ff3e50876b61 100644 --- a/Sources/FileExplorerView.swift +++ b/Sources/FileExplorerView.swift @@ -15,23 +15,6 @@ private func fileExplorerDebugResponder(_ responder: NSResponder?) -> String { // MARK: - File Explorer Panel (single NSViewRepresentable) -enum FileExplorerPanelPresentation: Equatable { - case files - case find - - var rightSidebarMode: RightSidebarMode { - switch self { - case .files: return .files - case .find: return .find - } - } -} - -enum FileExplorerPanelPlacement: Equatable { - case rightSidebar - case pane -} - /// The entire file explorer panel as one AppKit view hierarchy. /// Contains the header bar (path + controls) and NSOutlineView, with no SwiftUI intermediaries. struct FileExplorerPanelView: NSViewRepresentable { @@ -96,6 +79,14 @@ struct FileExplorerPanelView: NSViewRepresentable { private var observationCancellable: AnyCancellable? private var styleObserver: Any? private var isUpdatingOutlineProgrammatically = false + var fileFilter = FileExplorerTreeFilter() + var fileFilterTreeRevision = -1 + var fileFilterTask: Task? + var fileFilterTaskQuery: String? + var fileFilterTaskTreeRevision: Int? + var fileFilterGeneration = 0 + var pendingFileFilterAction: (() -> Void)? + var preFilterTopVisiblePath: String? init( store: FileExplorerStore, @@ -122,7 +113,7 @@ struct FileExplorerPanelView: NSViewRepresentable { outlineView.indentationPerLevel = style.indentation outlineView.noteHeightOfRows(withIndexesChanged: IndexSet(0.., in outlineView: NSOutlineView) { - for row in 0.. Int { if item == nil { - return store.rootNodes.count + return fileFilter.visibleRootNodes(in: store.rootNodes).count } guard let node = item as? FileExplorerNode else { return 0 } - return node.sortedChildren?.count ?? 0 + return fileFilter.visibleChildren(of: node).count } func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any { if item == nil { - return store.rootNodes[index] + return fileFilter.visibleRootNodes(in: store.rootNodes)[index] } - guard let node = item as? FileExplorerNode, - let children = node.sortedChildren else { + guard let node = item as? FileExplorerNode else { return FileExplorerNode(name: "", path: "", isDirectory: false) } - return children[index] + return fileFilter.visibleChildren(of: node)[index] } func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool { guard let node = item as? FileExplorerNode else { return false } + if fileFilter.isActive { return fileFilter.hasVisibleChildren(node) } return node.isExpandable } @@ -278,12 +287,14 @@ struct FileExplorerPanelView: NSViewRepresentable { func outlineView(_ outlineView: NSOutlineView, shouldExpandItem item: Any) -> Bool { guard let node = item as? FileExplorerNode, node.isDirectory else { return false } + if fileFilter.isActive { return fileFilter.hasVisibleChildren(node) } store.expand(node: node) return node.children != nil } func outlineView(_ outlineView: NSOutlineView, shouldCollapseItem item: Any) -> Bool { guard let node = item as? FileExplorerNode else { return false } + if fileFilter.isActive { return true } store.collapse(node: node) return true } @@ -299,6 +310,7 @@ struct FileExplorerPanelView: NSViewRepresentable { store.select(nodes: nodes, anchor: anchor ?? nodes.first) } func outlineViewItemDidExpand(_ notification: Notification) { + guard !fileFilter.isActive else { return } guard let node = notification.userInfo?["NSObject"] as? FileExplorerNode else { return } if !store.isExpanded(node) { store.expand(node: node) @@ -306,6 +318,7 @@ struct FileExplorerPanelView: NSViewRepresentable { } func outlineViewItemDidCollapse(_ notification: Notification) { + guard !fileFilter.isActive else { return } guard let node = notification.userInfo?["NSObject"] as? FileExplorerNode else { return } if store.isExpanded(node) { store.collapse(node: node) @@ -328,44 +341,55 @@ struct FileExplorerPanelView: NSViewRepresentable { } } - func moveSelection(in outlineView: NSOutlineView, by delta: Int) { - guard outlineView.numberOfRows > 0 else { - store.select(node: nil) - return - } - let currentRow = resolvedSelectionRow(in: outlineView) ?? (delta >= 0 ? -1 : outlineView.numberOfRows) - let targetRow = min(max(currentRow + delta, 0), outlineView.numberOfRows - 1) - selectRow(targetRow, in: outlineView, scroll: true) + func refreshFilteredRows(in outlineView: NSOutlineView) { + guard outlineView.numberOfRows > 0, outlineView.numberOfColumns > 0 else { return } + outlineView.reloadData( + forRowIndexes: IndexSet(0.. 0 else { return } - let lowerQuery = trimmedQuery.lowercased() - for row in 0.. FileExplorerNode? { + let rows = outlineView.rows(in: outlineView.visibleRect) + guard rows.location != NSNotFound, rows.location < outlineView.numberOfRows else { return nil } + return outlineView.item(atRow: rows.location) as? FileExplorerNode + } + + func restorePreFilterScroll(in outlineView: NSOutlineView) { + defer { preFilterTopVisiblePath = nil } + guard let path = preFilterTopVisiblePath, + let row = (0.. 0 else { return } - selectRow(0, in: outlineView, scroll: scroll) + let matchingRow = (0.. Int? { @@ -492,7 +524,7 @@ struct FileExplorerPanelView: NSViewRepresentable { return SelectionResolution(row: bestAncestor.row, isExact: false) } - private func selectRow( + func selectRow( _ row: Int, in outlineView: NSOutlineView, scroll: Bool, @@ -546,7 +578,7 @@ struct FileExplorerPanelView: NSViewRepresentable { @MainActor @objc func handleDoubleClick(_ sender: NSOutlineView) { let row = sender.clickedRow >= 0 ? sender.clickedRow : sender.selectedRow - openNode(in: sender, at: row) + openNodeAfterApplyingFileFilter(in: sender, at: row) } // MARK: - Context Menu (NSMenuDelegate) @@ -633,9 +665,7 @@ struct FileExplorerPanelView: NSViewRepresentable { @MainActor final class FileExplorerContainerView: NSView { private let headerView: FileExplorerHeaderView - private let searchBarView: NSView - private let searchField: FileExplorerSearchField - private let searchStatusLabel: NSTextField + private let searchBarView: FileExplorerSearchBarView private let scrollView: NSScrollView private let outlineView: FileExplorerNSOutlineView private let searchScrollView: NSScrollView @@ -644,31 +674,34 @@ final class FileExplorerContainerView: NSView { private let loadingIndicator: NSProgressIndicator private let searchController: any FileSearchControlling private var searchBarHeightConstraint: NSLayoutConstraint! - private var searchFieldHeightConstraint: NSLayoutConstraint! private(set) var searchSnapshot = FileSearchSnapshot.empty + private(set) var displayedSearchScope: FileExplorerSearchScope + private var queryState = FileExplorerSearchQueryState() + private var fileQuickSearchQuery: String? private var currentRootPath = "" private var currentProviderIsLocal = false private var currentWorkspaceRootIdentity: UUID? private var currentContentRevision = 0 + private var currentIsLoading = false + private var currentStatusMessage: String? private let searchDebounceSubject = PassthroughSubject() private var searchDebounceCancellable: AnyCancellable? private var searchDebounceGeneration = 0 + private let fileFilterDebounceSubject = PassthroughSubject() + private var fileFilterDebounceCancellable: AnyCancellable? + private var fileFilterDebounceGeneration = 0 private var pendingSearchRefreshAfterSettled = false - private var isSearchVisible = false { - didSet { - if !isSearchVisible { - cancelPendingSearchRefresh() - pendingSearchRefreshAfterSettled = false - } - } - } + private var preservedSearchNeedsRefresh = false private var presentation: FileExplorerPanelPresentation - private let coordinator: FileExplorerPanelView.Coordinator + let coordinator: FileExplorerPanelView.Coordinator private var fontMagnificationObserver: GlobalFontMagnificationChangeObserver? private let searchDebounceDelayMilliseconds = 200 - private var searchBarVisibleHeight: CGFloat { max(48, GlobalFontMagnification.scaled(48)) } - private var searchFieldVisibleHeight: CGFloat { max(24, GlobalFontMagnification.scaled(24)) } - + var searchField: FileExplorerSearchField { searchBarView.searchField } + var searchStatusLabel: NSTextField { searchBarView.statusLabel } + private var activeBarVisibleHeight: CGFloat { searchBarView.preferredHeight } + private var hasContentsQuery: Bool { + !queryState.contentsQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } #if DEBUG private var debugLastSearchTextChangeUptime: TimeInterval = 0 private var debugLastSearchLayoutFieldWidth: CGFloat = -1 @@ -676,16 +709,13 @@ final class FileExplorerContainerView: NSView { private var debugLastLoggedSearchResultCount = -1 private var debugLastLoggedSearchStatus = "" #endif - init( coordinator: FileExplorerPanelView.Coordinator, presentation: FileExplorerPanelPresentation, searchController: (any FileSearchControlling)? = nil ) { headerView = FileExplorerHeaderView() - searchBarView = NSView() - searchField = FileExplorerSearchField() - searchStatusLabel = NSTextField(labelWithString: "") + searchBarView = FileExplorerSearchBarView() scrollView = NSScrollView() outlineView = FileExplorerNSOutlineView() searchScrollView = NSScrollView() @@ -694,64 +724,42 @@ final class FileExplorerContainerView: NSView { loadingIndicator = NSProgressIndicator() self.searchController = searchController ?? FileSearchController() self.presentation = presentation + self.displayedSearchScope = FileExplorerSearchScope(mode: presentation.rightSidebarMode) self.coordinator = coordinator - super.init(frame: .zero) + searchBarView.apply(query: queryState.contentsQuery) updateShortcutPlacement(coordinator.placement) configureSearchDebounce() - + configureFileFilterDebounce() // Header headerView.translatesAutoresizingMaskIntoConstraints = false addSubview(headerView) - // Search bar searchBarView.translatesAutoresizingMaskIntoConstraints = false searchBarView.isHidden = true addSubview(searchBarView) - - searchField.translatesAutoresizingMaskIntoConstraints = false - searchField.setAccessibilityIdentifier("FileExplorerSearchField") - searchField.placeholderString = String(localized: "fileExplorer.search.placeholder", defaultValue: "Search files") - searchField.focusRingType = .none - searchField.cell?.usesSingleLineMode = true - searchField.cell?.isScrollable = true - searchField.cell?.lineBreakMode = .byClipping - searchField.setContentHuggingPriority(.defaultLow, for: .horizontal) - searchField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) searchField.delegate = self searchField.onCancel = { [weak self] in self?.closeSearchAndFocusOutline() } searchField.onMoveSelection = { [weak self] delta in - self?.moveSearchSelection(by: delta, focusResults: true) + self?.moveSelectionFromSearchField(by: delta) } searchField.onCommit = { [weak self] in - self?.openSelectedSearchResult() + self?.commitSearchFieldSelection() } searchField.onFocus = { [weak self] in guard let self else { return } - self.isSearchVisible = true - self.coordinator.noteKeyboardFocus(mode: self.representedRightSidebarMode(), in: self.window) + self.coordinator.noteKeyboardFocus(mode: .find, in: self.window) self.updateSearchLayout() + self.resumePreservedSearchIfNeeded() } - searchBarView.addSubview(searchField) - - searchStatusLabel.translatesAutoresizingMaskIntoConstraints = false - searchStatusLabel.textColor = .secondaryLabelColor - searchStatusLabel.lineBreakMode = .byTruncatingTail - searchStatusLabel.maximumNumberOfLines = 1 - searchStatusLabel.alignment = .left - searchStatusLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) - searchStatusLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - searchBarView.addSubview(searchStatusLabel) - // Empty state label emptyLabel.translatesAutoresizingMaskIntoConstraints = false emptyLabel.textColor = .secondaryLabelColor emptyLabel.alignment = .center emptyLabel.isHidden = true addSubview(emptyLabel) - // Loading indicator loadingIndicator.translatesAutoresizingMaskIntoConstraints = false loadingIndicator.style = .spinning @@ -765,7 +773,6 @@ final class FileExplorerContainerView: NSView { self?.searchResultsView.rowHeight = FileExplorerSearchResultCellView.preferredRowHeight self?.searchResultsView.reloadData() } - // Outline view setup outlineView.headerView = nil outlineView.usesAlternatingRowBackgroundColors = false @@ -778,7 +785,20 @@ final class FileExplorerContainerView: NSView { outlineView.floatsGroupRows = false outlineView.backgroundColor = .clear outlineView.onQuickSearchChanged = { [weak self] query in - self?.headerView.updateQuickSearch(query: query) + guard let self else { return } + if self.coordinator.state.mode != .files { + self.coordinator.noteKeyboardFocus(mode: .files, in: self.window) + } + self.fileQuickSearchQuery = query + self.queryState.setQuery(query ?? "", for: .names) + if self.displayedSearchScope == .names { + self.headerView.updateQuickSearch(query: query) + } + if query?.isEmpty == false { + self.scheduleFileFilterRefresh() + } else { + self.applyPendingFileFilter() + } } let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("name")) @@ -786,19 +806,16 @@ final class FileExplorerContainerView: NSView { column.resizingMask = .autoresizingMask outlineView.addTableColumn(column) outlineView.outlineTableColumn = column - outlineView.dataSource = coordinator outlineView.delegate = coordinator outlineView.target = coordinator outlineView.doubleAction = #selector(FileExplorerPanelView.Coordinator.handleDoubleClick(_:)) outlineView.setDraggingSourceOperationMask(.move, forLocal: true) coordinator.outlineView = outlineView - // Context menu let menu = NSMenu() menu.delegate = coordinator outlineView.menu = menu - // Scroll view scrollView.translatesAutoresizingMaskIntoConstraints = false scrollView.hasVerticalScroller = true @@ -809,7 +826,6 @@ final class FileExplorerContainerView: NSView { scrollView.drawsBackground = false scrollView.documentView = outlineView addSubview(scrollView) - // Streaming search results searchResultsView.headerView = nil searchResultsView.usesAlternatingRowBackgroundColors = false @@ -830,7 +846,7 @@ final class FileExplorerContainerView: NSView { } searchResultsView.onFocus = { [weak self] in guard let self else { return } - self.coordinator.noteKeyboardFocus(mode: self.representedRightSidebarMode(), in: self.window) + self.coordinator.noteKeyboardFocus(mode: .find, in: self.window) } searchResultsView.onModeShortcut = { [weak coordinator] mode, window in coordinator?.handleModeShortcut(mode, in: window) ?? false @@ -858,13 +874,10 @@ final class FileExplorerContainerView: NSView { searchScrollView.documentView = searchResultsView searchScrollView.isHidden = true addSubview(searchScrollView) - self.searchController.onSnapshotChanged = { [weak self] snapshot in self?.applySearchSnapshot(snapshot) } - searchBarHeightConstraint = searchBarView.heightAnchor.constraint(equalToConstant: 0) - searchFieldHeightConstraint = searchField.heightAnchor.constraint(equalToConstant: searchFieldVisibleHeight) NSLayoutConstraint.activate([ headerView.topAnchor.constraint(equalTo: topAnchor), headerView.leadingAnchor.constraint(equalTo: leadingAnchor), @@ -874,42 +887,26 @@ final class FileExplorerContainerView: NSView { searchBarView.leadingAnchor.constraint(equalTo: leadingAnchor), searchBarView.trailingAnchor.constraint(equalTo: trailingAnchor), searchBarHeightConstraint, - - searchField.leadingAnchor.constraint(equalTo: searchBarView.leadingAnchor, constant: 8), - searchField.trailingAnchor.constraint(equalTo: searchBarView.trailingAnchor, constant: -8), - searchField.topAnchor.constraint(equalTo: searchBarView.topAnchor, constant: 4), - searchFieldHeightConstraint, - searchField.widthAnchor.constraint(greaterThanOrEqualToConstant: 120), - - searchStatusLabel.leadingAnchor.constraint(equalTo: searchField.leadingAnchor, constant: 4), - searchStatusLabel.trailingAnchor.constraint(equalTo: searchField.trailingAnchor), - searchStatusLabel.topAnchor.constraint(equalTo: searchField.bottomAnchor, constant: 2), - scrollView.topAnchor.constraint(equalTo: searchBarView.bottomAnchor), scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), - searchScrollView.topAnchor.constraint(equalTo: searchBarView.bottomAnchor), searchScrollView.leadingAnchor.constraint(equalTo: leadingAnchor), searchScrollView.trailingAnchor.constraint(equalTo: trailingAnchor), searchScrollView.bottomAnchor.constraint(equalTo: bottomAnchor), - emptyLabel.centerXAnchor.constraint(equalTo: centerXAnchor), emptyLabel.centerYAnchor.constraint(equalTo: centerYAnchor), - loadingIndicator.centerXAnchor.constraint(equalTo: centerXAnchor), loadingIndicator.centerYAnchor.constraint(equalTo: centerYAnchor), ]) } private func applyChromeFonts() { - searchField.font = GlobalFontMagnification.systemFont(ofSize: 12, weight: .regular) - searchStatusLabel.font = GlobalFontMagnification.systemFont(ofSize: 11, weight: .medium) + searchBarView.applyFonts() emptyLabel.font = GlobalFontMagnification.systemFont(ofSize: 13) - searchFieldHeightConstraint?.constant = searchFieldVisibleHeight - if isSearchVisible { - searchBarHeightConstraint?.constant = searchBarVisibleHeight + if !searchBarView.isHidden { + searchBarHeightConstraint?.constant = activeBarVisibleHeight } headerView.applyFonts() } @@ -921,6 +918,7 @@ final class FileExplorerContainerView: NSView { override func viewWillMove(toWindow newWindow: NSWindow?) { if newWindow == nil { cancelPendingSearchRefresh() + cancelPendingFileFilterRefresh() searchController.cancel(clear: false) } super.viewWillMove(toWindow: newWindow) @@ -966,7 +964,22 @@ final class FileExplorerContainerView: NSView { currentRootPath = nextRootPath; currentProviderIsLocal = nextProviderIsLocal currentWorkspaceRootIdentity = nextWorkspaceRootIdentity; currentContentRevision = nextContentRevision headerView.update(displayPath: store.displayRootPath) - if workspaceRootChanged { cancelPendingSearchRefresh(); pendingSearchRefreshAfterSettled = false; searchController.cancel(clear: true); searchField.stringValue = ""; applySearchSnapshot(.empty) } + if searchScopeChanged { + coordinator.invalidateFileFilterIndex() + } + if workspaceRootChanged { + cancelPendingSearchRefresh() + cancelPendingFileFilterRefresh() + pendingSearchRefreshAfterSettled = false + searchController.cancel(clear: true) + outlineView.endQuickSearch() + fileQuickSearchQuery = nil + queryState.clearQueries() + searchBarView.apply(query: queryState.contentsQuery) + coordinator.setFileFilterQuery("", in: outlineView) + headerView.updateQuickSearch(query: nil) + applySearchSnapshot(.empty) + } if searchScopeChanged { pendingSearchRefreshAfterSettled = false refreshSearchIfNeeded() @@ -974,7 +987,6 @@ final class FileExplorerContainerView: NSView { refreshSearchAfterContentRevisionIfNeeded() } } - func representedRightSidebarMode() -> RightSidebarMode { presentation.rightSidebarMode } @@ -985,36 +997,142 @@ final class FileExplorerContainerView: NSView { searchResultsView.fileExplorerPanelPlacement = placement } - func updatePresentation(_ nextPresentation: FileExplorerPanelPresentation) { - guard presentation != nextPresentation else { - // Re-selecting the active presentation is a no-op unless visibility drifted. - if presentation == .find, !isSearchVisible { - isSearchVisible = true - updateSearchLayout() - } + func searchQuery(for scope: FileExplorerSearchScope) -> String { + queryState.query(for: scope) + } + + private func captureDisplayedQuery() { + guard displayedSearchScope == .contents else { return } + queryState.setQuery(searchField.stringValue, for: .contents) + } + + private func setDisplayedSearchScope(_ scope: FileExplorerSearchScope) { + captureDisplayedQuery() + let previousScope = displayedSearchScope + if previousScope == .names, scope != .names { + cancelPendingFileFilterRefresh() + coordinator.suspendFileFilter() + } + if previousScope == .contents, scope != .contents { + pauseSearchPreservingState() + } + displayedSearchScope = scope + if scope == .contents { + searchBarView.apply(query: queryState.contentsQuery) + } + headerView.updateQuickSearch(query: scope == .names ? fileQuickSearchQuery : nil) + if scope == .names, previousScope != .names { + coordinator.setFileFilterQuery(queryState.namesQuery, in: outlineView) + } + updateVisibility( + hasContent: !currentRootPath.isEmpty, + isLoading: currentIsLoading, + statusMessage: currentStatusMessage + ) + if previousScope != .contents, scope == .contents { + resumePreservedSearchIfNeeded() + } + } + + func searchFieldTextDidChange() { + guard displayedSearchScope == .contents else { return } + queryState.setQuery(searchField.stringValue, for: .contents) + if coordinator.state.mode != .find { + coordinator.noteKeyboardFocus(mode: .find, in: window) + } + scrollSearchFieldEditorToInsertionPoint() + Task { @MainActor [weak self] in + self?.scrollSearchFieldEditorToInsertionPoint() + } +#if DEBUG + let now = ProcessInfo.processInfo.systemUptime + let gapMs = debugLastSearchTextChangeUptime > 0 + ? debugSearchNumber((now - debugLastSearchTextChangeUptime) * 1000) + : "n/a" + debugLastSearchTextChangeUptime = now + dlog( + "file.search.input.changed scope=\(displayedSearchScope) queryLen=\(searchField.stringValue.count) " + + "gapMs=\(gapMs) results=\(searchSnapshot.results.count) " + + "status=\(debugSearchStatusName(searchSnapshot.status))" + ) +#endif + if !hasContentsQuery { + cancelPendingSearchRefresh() + pendingSearchRefreshAfterSettled = false + searchController.cancel(clear: true) + applySearchSnapshot(.empty) + updateVisibility( + hasContent: !currentRootPath.isEmpty, + isLoading: currentIsLoading, + statusMessage: currentStatusMessage + ) return } + scheduleSearchRefresh() + } - presentation = nextPresentation - switch presentation { - case .files: - isSearchVisible = false - searchController.cancel(clear: false) - case .find: - isSearchVisible = true - refreshSearchIfNeeded() + func handleSearchFieldCommand(_ commandSelector: Selector, textView: NSTextView) -> Bool { + guard !textView.hasMarkedText() else { return false } + switch commandSelector { + case #selector(NSResponder.insertNewline(_:)): + commitSearchFieldSelection() + return true + case #selector(NSResponder.cancelOperation(_:)): + closeSearchAndFocusOutline() + return true + case #selector(NSResponder.moveDown(_:)): + moveSelectionFromSearchField(by: 1) + return true + case #selector(NSResponder.moveUp(_:)): + moveSelectionFromSearchField(by: -1) + return true + default: + return false } - updateSearchLayout() - registerWithKeyboardFocusCoordinatorIfNeeded() } + private func moveSelectionFromSearchField(by delta: Int) { + moveSearchSelection(by: delta, focusResults: true) + } + + private func commitSearchFieldSelection() { + openSelectedSearchResult() + } + + func updatePresentation(_ nextPresentation: FileExplorerPanelPresentation) { + let presentationChanged = presentation != nextPresentation + presentation = nextPresentation + let activationMode: RightSidebarMode + if presentation == .unified { + activationMode = coordinator.state.mode + } else { + activationMode = presentation.rightSidebarMode + } + let requestedScope = FileExplorerSearchScope(mode: activationMode) + let focusedScope: FileExplorerSearchScope? + if let responder = window?.firstResponder, + let focusedMode = rightSidebarActivationMode(owning: responder) { + focusedScope = FileExplorerSearchScope(mode: focusedMode) + } else { + focusedScope = nil + } + let preservesFocusedUnifiedProjection = presentation == .unified + && focusedScope != nil + && focusedScope != requestedScope + if !preservesFocusedUnifiedProjection { + setDisplayedSearchScope(requestedScope) + } + if presentationChanged { registerWithKeyboardFocusCoordinatorIfNeeded() } + } func updateVisibility(hasContent: Bool, isLoading: Bool, statusMessage: String?) { + currentIsLoading = isLoading + currentStatusMessage = statusMessage let normalizedStatus = statusMessage?.trimmingCharacters(in: .whitespacesAndNewlines) let hasStatus = normalizedStatus?.isEmpty == false let canShowTree = hasContent && !hasStatus applyHidden(headerView, !hasContent && !hasStatus) updateSearchLayout(hasContent: canShowTree, isLoading: isLoading) - let searchCanShow = isSearchVisible && canShowTree && !isLoading + let searchCanShow = displayedSearchScope == .contents && canShowTree && !isLoading let nextEmptyText = hasStatus ? normalizedStatus! : String(localized: "fileExplorer.empty", defaultValue: "No folder open") @@ -1043,9 +1161,7 @@ final class FileExplorerContainerView: NSView { #endif return false } - isSearchVisible = true - updateSearchLayout() - refreshSearchIfNeeded() + setDisplayedSearchScope(.contents) let result = window.makeFirstResponder(searchField) searchField.selectText(nil) #if DEBUG @@ -1078,14 +1194,7 @@ final class FileExplorerContainerView: NSView { #endif return false } - if isSearchVisible { - isSearchVisible = false - searchController.cancel(clear: true) - searchField.stringValue = "" - searchSnapshot = .empty - searchResultsView.reloadData() - updateSearchLayout() - } + setDisplayedSearchScope(.names) (outlineView.dataSource as? FileExplorerPanelView.Coordinator)? .ensureSelection(in: outlineView, fallbackToFirstVisible: true, scroll: true) let result = window.makeFirstResponder(outlineView) @@ -1100,43 +1209,54 @@ final class FileExplorerContainerView: NSView { } func ownsKeyboardFocus(_ responder: NSResponder) -> Bool { - if responder === outlineView || responder === searchResultsView || responder === searchField { - return true - } + rightSidebarActivationMode(owning: responder) != nil + } + func rightSidebarActivationMode(owning responder: NSResponder) -> RightSidebarMode? { + if responder === outlineView { return .files } + if responder === searchResultsView { return .find } + if responder === searchField { return displayedSearchScope.activationMode } if let editor = searchField.currentEditor(), responder === editor { - return true + return displayedSearchScope.activationMode } var view = responder as? NSView while let candidate = view { - if candidate === searchBarView || candidate === searchScrollView || candidate === searchResultsView { - return true + if candidate === searchBarView { + return displayedSearchScope.activationMode + } + if candidate === searchScrollView || candidate === searchResultsView { + return .find } view = candidate.superview } - return false + return nil } private func refreshSearchIfNeeded() { - guard isSearchVisible else { return } + guard displayedSearchScope == .contents else { + if hasContentsQuery { preservedSearchNeedsRefresh = true } + return + } + preservedSearchNeedsRefresh = false cancelPendingSearchRefresh() #if DEBUG dlog( - "file.search.request queryLen=\(searchField.stringValue.count) " + + "file.search.request queryLen=\(queryState.contentsQuery.count) " + "rootReady=\(currentRootPath.isEmpty ? 0 : 1) local=\(currentProviderIsLocal ? 1 : 0) " + "revision=\(currentContentRevision) results=\(searchSnapshot.results.count) " + "fieldW=\(debugSearchNumber(searchField.frame.width)) statusW=\(debugSearchNumber(searchStatusLabel.frame.width))" ) #endif - searchController.search( - query: searchField.stringValue, + searchController.search(request: FileSearchRequest( + query: queryState.contentsQuery, rootPath: currentRootPath, isLocal: currentProviderIsLocal, contentRevision: currentContentRevision - ) + )) } private func refreshSearchAfterContentRevisionIfNeeded() { - guard isSearchVisible else { + guard displayedSearchScope == .contents else { + preservedSearchNeedsRefresh = hasContentsQuery pendingSearchRefreshAfterSettled = false return } @@ -1148,7 +1268,7 @@ final class FileExplorerContainerView: NSView { pendingSearchRefreshAfterSettled = true #if DEBUG dlog( - "file.search.contentRevision.defer queryLen=\(searchField.stringValue.count) " + + "file.search.contentRevision.defer queryLen=\(queryState.contentsQuery.count) " + "revision=\(currentContentRevision) results=\(searchSnapshot.results.count)" ) #endif @@ -1160,11 +1280,11 @@ final class FileExplorerContainerView: NSView { .sink { [weak self] debounceGeneration in Task { @MainActor [weak self] in guard let self, - self.isSearchVisible, + self.displayedSearchScope == .contents, self.searchDebounceGeneration == debounceGeneration else { return } #if DEBUG dlog( - "file.search.debounce.fire queryLen=\(self.searchField.stringValue.count) " + + "file.search.debounce.fire queryLen=\(self.queryState.contentsQuery.count) " + "delayMs=\(self.searchDebounceDelayMilliseconds)" ) #endif @@ -1174,13 +1294,13 @@ final class FileExplorerContainerView: NSView { } private func scheduleSearchRefresh() { - guard isSearchVisible else { return } + guard displayedSearchScope == .contents else { return } pendingSearchRefreshAfterSettled = false searchDebounceGeneration += 1 let debounceGeneration = searchDebounceGeneration #if DEBUG dlog( - "file.search.debounce.schedule queryLen=\(searchField.stringValue.count) " + + "file.search.debounce.schedule queryLen=\(queryState.contentsQuery.count) " + "delayMs=\(searchDebounceDelayMilliseconds)" ) #endif @@ -1191,22 +1311,79 @@ final class FileExplorerContainerView: NSView { searchDebounceGeneration += 1 } + private func configureFileFilterDebounce() { + fileFilterDebounceCancellable = fileFilterDebounceSubject + .debounce(for: .milliseconds(searchDebounceDelayMilliseconds), scheduler: RunLoop.main) + .sink { [weak self] debounceGeneration in + Task { @MainActor [weak self] in + guard let self, + self.displayedSearchScope == .names, + self.fileFilterDebounceGeneration == debounceGeneration else { return } + self.applyPendingFileFilter() + } + } + } + + private func scheduleFileFilterRefresh() { + guard displayedSearchScope == .names else { return } + fileFilterDebounceGeneration += 1 + fileFilterDebounceSubject.send(fileFilterDebounceGeneration) + } + + private func cancelPendingFileFilterRefresh() { + fileFilterDebounceGeneration += 1 + } + + func applyPendingFileFilter(afterApplying action: (() -> Void)? = nil) { + cancelPendingFileFilterRefresh() + guard displayedSearchScope == .names else { + action?() + return + } + coordinator.setFileFilterQuery( + queryState.namesQuery, + in: outlineView, + afterApplying: action + ) + updateVisibility( + hasContent: !currentRootPath.isEmpty, + isLoading: currentIsLoading, + statusMessage: currentStatusMessage + ) + } + + private func pauseSearchPreservingState() { + preservedSearchNeedsRefresh = preservedSearchNeedsRefresh || searchSnapshot.isSearching + cancelPendingSearchRefresh() + pendingSearchRefreshAfterSettled = false + searchController.cancel(clear: false) + } + + private func resumePreservedSearchIfNeeded() { + guard displayedSearchScope == .contents, hasContentsQuery else { return } + let normalizedQuery = queryState.contentsQuery.trimmingCharacters(in: .whitespacesAndNewlines) + if preservedSearchNeedsRefresh || searchSnapshot.query != normalizedQuery { + refreshSearchIfNeeded() + } + } + private func updateSearchLayout(hasContent: Bool? = nil, isLoading: Bool? = nil) { let effectiveHasContent = hasContent ?? !currentRootPath.isEmpty let effectiveIsLoading = isLoading ?? false - let showSearch = isSearchVisible && effectiveHasContent && !effectiveIsLoading - let nextSearchBarHeight = showSearch ? searchBarVisibleHeight : 0 - + let showSearchField = displayedSearchScope == .contents && effectiveHasContent && !effectiveIsLoading + let showSearchResults = showSearchField + let showTree = displayedSearchScope == .names && effectiveHasContent && !effectiveIsLoading + let nextSearchBarHeight = showSearchField ? activeBarVisibleHeight : 0 // Assigning isHidden/constraints unconditionally fires KVO even when unchanged, // which re-enters updateNSView and spins the main thread on macOS 26 (#4931). var changed = false - if applyHidden(searchBarView, !showSearch) { changed = true } + if applyHidden(searchBarView, !showSearchField) { changed = true } if searchBarHeightConstraint.constant != nextSearchBarHeight { searchBarHeightConstraint.constant = nextSearchBarHeight changed = true } - if applyHidden(searchScrollView, !showSearch) { changed = true } - if applyHidden(scrollView, showSearch || !effectiveHasContent || effectiveIsLoading) { changed = true } + if applyHidden(searchScrollView, !showSearchResults) { changed = true } + if applyHidden(scrollView, !showTree) { changed = true } if changed { needsLayout = true } @@ -1219,14 +1396,15 @@ final class FileExplorerContainerView: NSView { view.isHidden = hidden return true } - private func applySearchSnapshot(_ snapshot: FileSearchSnapshot) { + let snapshot = snapshot.groupingMatchesByFile() #if DEBUG let debugApplyStart = ProcessInfo.processInfo.systemUptime let previousStatusName = debugSearchStatusName(searchSnapshot.status) let previousStatusTextLength = searchStatusLabel.stringValue.count #endif let previousSelectedRow = searchResultsView.selectedRow + let previousSelectedResults = searchResultsView.selectedRowIndexes.compactMap { searchSnapshot.results.indices.contains($0) ? searchSnapshot.results[$0] : nil } let previousResults = searchSnapshot.results searchSnapshot = snapshot searchStatusLabel.stringValue = statusText(for: snapshot) @@ -1246,10 +1424,9 @@ final class FileExplorerContainerView: NSView { } if !snapshot.results.isEmpty { - let selectedRow = previousSelectedRow >= 0 - ? min(previousSelectedRow, snapshot.results.count - 1) - : 0 - searchResultsView.selectRowIndexes(IndexSet(integer: selectedRow), byExtendingSelection: false) + let preservedRows = IndexSet(previousSelectedResults.compactMap { snapshot.results.firstIndex(of: $0) }) + let fallbackRow = previousSelectedRow >= 0 ? min(previousSelectedRow, snapshot.results.count - 1) : 0 + searchResultsView.selectRowIndexes(preservedRows.isEmpty ? IndexSet(integer: fallbackRow) : preservedRows, byExtendingSelection: false) } if shouldRunDeferredContentRefresh { @@ -1342,7 +1519,7 @@ final class FileExplorerContainerView: NSView { } private func logSearchLayoutIfNeeded(startedAt: TimeInterval, reason: String) { - guard isSearchVisible else { return } + guard displayedSearchScope == .contents else { return } let now = ProcessInfo.processInfo.systemUptime let fieldWidth = searchField.frame.width let statusWidth = searchStatusLabel.frame.width @@ -1406,251 +1583,35 @@ final class FileExplorerContainerView: NSView { #endif private func closeSearchAndFocusOutline() { - if presentation == .find { - let hadQuery = !searchField.stringValue.isEmpty + captureDisplayedQuery() + switch displayedSearchScope { + case .names: + outlineView.endQuickSearch() + _ = focusOutline() + case .contents: + guard hasContentsQuery else { + if presentation == .find { + if AppDelegate.shared?.keyboardFocusCoordinator(for: window)?.focusTerminal() != true { + _ = window?.makeFirstResponder(nil) + } + } else { + _ = focusOutline() + } + return + } cancelPendingSearchRefresh() pendingSearchRefreshAfterSettled = false searchController.cancel(clear: true) - searchField.stringValue = "" + queryState.setQuery("", for: .contents) + searchBarView.apply(query: queryState.contentsQuery) applySearchSnapshot(.empty) - updateSearchLayout() - if hadQuery { - _ = focusSearchField() - return - } - if AppDelegate.shared?.keyboardFocusCoordinator(for: window)?.focusTerminal() == true { - return - } - window?.makeFirstResponder(nil) - return - } - - isSearchVisible = false - searchController.cancel(clear: true) - searchField.stringValue = "" - pendingSearchRefreshAfterSettled = false - searchSnapshot = .empty - searchResultsView.reloadData() - updateSearchLayout() - _ = focusOutline() - } - - private func moveSearchSelection(by delta: Int, focusResults: Bool) { - guard !searchSnapshot.results.isEmpty else { return } - let currentRow = searchResultsView.selectedRow >= 0 - ? searchResultsView.selectedRow - : (delta >= 0 ? -1 : searchSnapshot.results.count) - let targetRow = min(max(currentRow + delta, 0), searchSnapshot.results.count - 1) - searchResultsView.selectRowIndexes(IndexSet(integer: targetRow), byExtendingSelection: false) - searchResultsView.scrollRowToVisible(targetRow) - if focusResults, let window { - _ = window.makeFirstResponder(searchResultsView) - } - } - - private func searchResult(forMenuItem sender: NSMenuItem) -> FileSearchResult? { - guard let row = (sender.representedObject as? NSNumber)?.intValue, - row >= 0, - row < searchSnapshot.results.count else { - return nil - } - return searchSnapshot.results[row] - } - - @MainActor - fileprivate func openSelectedSearchResult() { - let row = searchResultsView.selectedRow - guard row >= 0, row < searchSnapshot.results.count else { return } - let path = searchSnapshot.results[row].path - // Editor/preferred-editor actions operate on local file paths via - // NSWorkspace; for non-local providers fall back to the cmux preview. - guard coordinator.store.provider is LocalFileExplorerProvider else { - coordinator.onOpenFilePreview(path) - return + updateVisibility( + hasContent: !currentRootPath.isEmpty, + isLoading: currentIsLoading, + statusMessage: currentStatusMessage + ) + _ = window?.makeFirstResponder(searchField) } - performFileExplorerFileOpen(path: path, onOpenFilePreview: coordinator.onOpenFilePreview) - } - - @objc private func openSelectedSearchResultFromTable(_ sender: NSTableView) { - openSelectedSearchResult() - } - - @objc private func contextMenuOpenSearchResultInCmux(_ sender: NSMenuItem) { - guard let result = searchResult(forMenuItem: sender) else { return } - coordinator.onOpenFilePreview(result.path) } - @objc private func contextMenuOpenSearchResultExternally(_ sender: NSMenuItem) { - guard let request = sender.representedObject as? FileExplorerExternalOpenRequest else { return } - FileExternalOpenAction.open(fileURL: request.fileURL, applicationURL: request.applicationURL) - } - - @objc private func contextMenuRevealSearchResultInFinder(_ sender: NSMenuItem) { - guard let result = searchResult(forMenuItem: sender) else { return } - FileExternalOpenAction.revealInFinder(fileURL: URL(fileURLWithPath: result.path)) - } - - @objc private func contextMenuCopySearchResultPath(_ sender: NSMenuItem) { - guard let result = searchResult(forMenuItem: sender) else { return } - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(result.path, forType: .string) - } - - @objc private func contextMenuCopySearchResultRelativePath(_ sender: NSMenuItem) { - guard let result = searchResult(forMenuItem: sender) else { return } - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(result.relativePath, forType: .string) - } } - -extension FileExplorerContainerView: NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate, NSMenuDelegate { - func controlTextDidChange(_ notification: Notification) { - guard notification.object as? NSTextField === searchField else { return } - scrollSearchFieldEditorToInsertionPoint() - Task { @MainActor [weak self] in - self?.scrollSearchFieldEditorToInsertionPoint() - } -#if DEBUG - let now = ProcessInfo.processInfo.systemUptime - let gapMs = debugLastSearchTextChangeUptime > 0 - ? debugSearchNumber((now - debugLastSearchTextChangeUptime) * 1000) - : "n/a" - debugLastSearchTextChangeUptime = now - dlog( - "file.search.input.changed queryLen=\(searchField.stringValue.count) gapMs=\(gapMs) " + - "fieldW=\(debugSearchNumber(searchField.frame.width)) statusW=\(debugSearchNumber(searchStatusLabel.frame.width)) " + - "statusIntrinsicW=\(debugSearchNumber(searchStatusLabel.intrinsicContentSize.width)) " + - "results=\(searchSnapshot.results.count) status=\(debugSearchStatusName(searchSnapshot.status)) " + - "fr=\(fileExplorerDebugResponder(window?.firstResponder))" - ) -#endif - scheduleSearchRefresh() - } - - private func scrollSearchFieldEditorToInsertionPoint() { - guard let editor = searchField.currentEditor() else { return } - let selection = editor.selectedRange - let textLength = (editor.string as NSString).length - let cursorLocation = min(selection.location + selection.length, textLength) - editor.scrollRangeToVisible(NSRange(location: cursorLocation, length: 0)) - } - - func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { - guard control === searchField, !textView.hasMarkedText() else { return false } - if let event = NSApp.currentEvent, searchField.handleOpenSelectionShortcut(event) { return true } - switch commandSelector { - case #selector(NSResponder.insertNewline(_:)): - guard !textView.hasMarkedText() else { return false } - openSelectedSearchResult() - return true - case #selector(NSResponder.cancelOperation(_:)): - closeSearchAndFocusOutline() - return true - case #selector(NSResponder.moveDown(_:)): - moveSearchSelection(by: 1, focusResults: true) - return true - case #selector(NSResponder.moveUp(_:)): - moveSearchSelection(by: -1, focusResults: true) - return true - default: - return false - } - } - - func numberOfRows(in tableView: NSTableView) -> Int { - searchSnapshot.results.count - } - - func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { - guard row >= 0, row < searchSnapshot.results.count else { return nil } - let identifier = NSUserInterfaceItemIdentifier("FileSearchResultCell") - let cellView: FileExplorerSearchResultCellView - if let existing = tableView.makeView(withIdentifier: identifier, owner: nil) as? FileExplorerSearchResultCellView { - cellView = existing - } else { - cellView = FileExplorerSearchResultCellView(identifier: identifier) - } - cellView.configure(with: searchSnapshot.results[row]) - return cellView - } - - func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> (any NSPasteboardWriting)? { - guard tableView === searchResultsView, - row >= 0, - row < searchSnapshot.results.count else { - return nil - } - let result = searchSnapshot.results[row] - return FilePreviewDragPasteboardWriter( - filePath: result.path, - displayTitle: (result.relativePath as NSString).lastPathComponent - ) - } - - func tableView( - _ tableView: NSTableView, - draggingSession session: NSDraggingSession, - endedAt screenPoint: NSPoint, - operation: NSDragOperation - ) { - guard tableView === searchResultsView else { return } - FilePreviewDragPasteboardWriter.discardRegisteredDrag(from: NSPasteboard(name: .drag)) - } - - func menuNeedsUpdate(_ menu: NSMenu) { - guard let searchMenu = searchResultsView.menu, menu === searchMenu else { return } - menu.removeAllItems() - let clickedRow = searchResultsView.clickedRow - let row = clickedRow >= 0 ? clickedRow : searchResultsView.selectedRow - guard row >= 0, row < searchSnapshot.results.count else { return } - if clickedRow >= 0 && !searchResultsView.selectedRowIndexes.contains(clickedRow) { - searchResultsView.selectRowIndexes(IndexSet(integer: clickedRow), byExtendingSelection: false) - } - - let openInCmuxItem = NSMenuItem( - title: String(localized: "fileExplorer.contextMenu.openInCmux", defaultValue: "Open in cmux"), - action: #selector(contextMenuOpenSearchResultInCmux(_:)), - keyEquivalent: "" - ) - openInCmuxItem.target = self - openInCmuxItem.representedObject = NSNumber(value: row) - menu.addItem(openInCmuxItem) - - FileExplorerExternalOpenMenuItems( - fileURL: URL(fileURLWithPath: searchSnapshot.results[row].path), - target: self, - action: #selector(contextMenuOpenSearchResultExternally(_:)) - ).add(to: menu) - - let revealItem = NSMenuItem( - title: FileExternalOpenText.revealInFinder, - action: #selector(contextMenuRevealSearchResultInFinder(_:)), - keyEquivalent: "" - ) - revealItem.target = self - revealItem.representedObject = NSNumber(value: row) - menu.addItem(revealItem) - - menu.addItem(.separator()) - - menu.addFileExplorerInsertPathItems(target: self, representedObject: NSNumber(value: row), insertAction: #selector(contextMenuInsertSearchResultPath(_:)), insertRelativeAction: #selector(contextMenuInsertSearchResultRelativePath(_:))) - - let copyPathItem = NSMenuItem( - title: String(localized: "fileExplorer.contextMenu.copyPath", defaultValue: "Copy Path"), - action: #selector(contextMenuCopySearchResultPath(_:)), - keyEquivalent: "" - ) - copyPathItem.target = self - copyPathItem.representedObject = NSNumber(value: row) - menu.addItem(copyPathItem) - - let copyRelativePathItem = NSMenuItem( - title: String(localized: "fileExplorer.contextMenu.copyRelativePath", defaultValue: "Copy Relative Path"), - action: #selector(contextMenuCopySearchResultRelativePath(_:)), - keyEquivalent: "" - ) - copyRelativePathItem.target = self - copyRelativePathItem.representedObject = NSNumber(value: row) - menu.addItem(copyRelativePathItem) - } -} \ No newline at end of file diff --git a/Sources/FileSearchRequest.swift b/Sources/FileSearchRequest.swift new file mode 100644 index 000000000000..4054902e429f --- /dev/null +++ b/Sources/FileSearchRequest.swift @@ -0,0 +1,53 @@ +import Foundation + +/// A normalized full-text search request and its ripgrep arguments. +struct FileSearchRequest: Equatable, Sendable { + let query: String + let rootPath: String + let isLocal: Bool + let contentRevision: Int + + init( + query: String, + rootPath: String, + isLocal: Bool, + contentRevision: Int + ) { + self.query = query.trimmingCharacters(in: .whitespacesAndNewlines) + self.rootPath = rootPath + self.isLocal = isLocal + self.contentRevision = contentRevision + } + + var ripgrepArguments: [String] { + var arguments = [ + "--json", + "--line-number", + "--column", + "--smart-case", + "--fixed-strings", + "--max-columns", "300", + "--max-columns-preview", + "--color", "never", + "--hidden", + ] + for pattern in Self.defaultExcludedGlobs { + arguments += ["--glob", pattern] + } + arguments += ["--", query, rootPath] + return arguments + } + + private static let defaultExcludedGlobs = [ + "!.git/**", + "!**/.git/**", + "!node_modules/**", + "!**/node_modules/**", + "!dist/**", + "!**/dist/**", + "!build/**", + "!**/build/**", + "!DerivedData/**", + "!**/DerivedData/**", + ] +} diff --git a/Sources/FileSearchSnapshot+Grouping.swift b/Sources/FileSearchSnapshot+Grouping.swift new file mode 100644 index 000000000000..526cff72d85f --- /dev/null +++ b/Sources/FileSearchSnapshot+Grouping.swift @@ -0,0 +1,23 @@ +extension FileSearchSnapshot { + /// Keeps matches for each file contiguous while preserving first-seen file and match order. + func groupingMatchesByFile() -> FileSearchSnapshot { + guard results.count > 1 else { return self } + + var groupIndexByPath: [String: Int] = [:] + var groups: [[FileSearchResult]] = [] + for result in results { + if let index = groupIndexByPath[result.path] { + groups[index].append(result) + } else { + groupIndexByPath[result.path] = groups.count + groups.append([result]) + } + } + + let groupedResults = groups.flatMap { $0 } + guard groupedResults != results else { return self } + var snapshot = self + snapshot.results = groupedResults + return snapshot + } +} diff --git a/Sources/MainWindowFocusController.swift b/Sources/MainWindowFocusController.swift index ac8183c134e9..7ba0ccfb8ee4 100644 --- a/Sources/MainWindowFocusController.swift +++ b/Sources/MainWindowFocusController.swift @@ -54,7 +54,6 @@ final class MainWindowFocusController { private weak var fileExplorerState: FileExplorerState? private weak var rightSidebarHost: RightSidebarKeyboardFocusView? private weak var fileExplorerHost: FileExplorerContainerView? - private weak var fileSearchHost: FileExplorerContainerView? private weak var feedHost: FeedKeyboardFocusView? private weak var dockHost: DockKeyboardFocusView? @@ -121,14 +120,9 @@ final class MainWindowFocusController { } func registerFileExplorerHost(_ host: FileExplorerContainerView) { - let mode = host.representedRightSidebarMode() - switch mode { - case .files: + let mode = host.representedRightSidebarMode().registeredToolMode + if mode == .files { fileExplorerHost = host - case .find: - fileSearchHost = host - case .sessions, .feed, .dock, .customSidebar: - break } focusRegisteredRightSidebarEndpointIfNeeded(mode: mode) } @@ -145,6 +139,7 @@ final class MainWindowFocusController { } func noteRightSidebarInteraction(mode: RightSidebarMode) { + synchronizeRightSidebarMode(mode) rememberedRightSidebarMode = mode rightSidebarFocusState = .focused(mode: mode, target: .host) intent = .rightSidebar(mode: mode) @@ -154,6 +149,11 @@ final class MainWindowFocusController { publishFeedFocusSnapshot() } + private func synchronizeRightSidebarMode(_ mode: RightSidebarMode) { + guard let fileExplorerState, fileExplorerState.mode != mode else { return } + fileExplorerState.mode = mode + } + func rememberRightSidebarMode(_ mode: RightSidebarMode) { rememberedRightSidebarMode = mode if mode != .feed { @@ -204,8 +204,7 @@ final class MainWindowFocusController { if responder is FeedKeyboardFocusResponder { return true } - if fileExplorerHost?.ownsKeyboardFocus(responder) == true || - fileSearchHost?.ownsKeyboardFocus(responder) == true { + if fileExplorerHost?.ownsKeyboardFocus(responder) == true { return true } if feedHost?.ownsKeyboardFocus(responder) == true { @@ -405,6 +404,7 @@ final class MainWindowFocusController { publishFeedFocusSnapshot() return } + synchronizeRightSidebarMode(mode) rememberedRightSidebarMode = mode completeRightSidebarFocusFromResponder(mode: mode, isFallbackSidebarHost: isFallbackSidebarHost) intent = .rightSidebar(mode: mode) @@ -464,9 +464,8 @@ final class MainWindowFocusController { guard requestedMode == nil else { return false } return focusRightSidebar(mode: .files, focusFirstItem: focusFirstItem) } - let mode = desiredMode - let target = rightSidebarFocusTarget(mode: mode, focusFirstItem: focusFirstItem) - return focusRightSidebar(mode: mode, target: target, terminalYieldReason: "rightSidebarFocus") + let target = rightSidebarFocusTarget(mode: desiredMode, focusFirstItem: focusFirstItem) + return focusRightSidebar(mode: desiredMode, target: target, terminalYieldReason: "rightSidebarFocus") } @discardableResult @@ -477,6 +476,7 @@ final class MainWindowFocusController { ) -> Bool { guard let state = fileExplorerState else { return false } guard mode.isAvailable() else { return false } + let toolMode = mode.registeredToolMode rememberedRightSidebarMode = mode beginRightSidebarFocusRequest(mode: mode, target: target) intent = .rightSidebar(mode: mode) @@ -490,7 +490,7 @@ final class MainWindowFocusController { state.mode = mode } - let modeResult = focusRightSidebarEndpoint(mode: mode, target: target) + let modeResult = focusRightSidebarEndpoint(mode: toolMode, target: target) if modeResult { rightSidebarFocusState = .focused(mode: mode, target: target) } @@ -505,11 +505,7 @@ final class MainWindowFocusController { @discardableResult func focusFileSearch() -> Bool { - return focusRightSidebar( - mode: .find, - target: .searchField, - terminalYieldReason: "fileSearchFocus" - ) + focusRightSidebar(mode: .find, focusFirstItem: true) } @discardableResult @@ -519,6 +515,9 @@ final class MainWindowFocusController { ) -> Bool { switch focusToggleDestination() { case .terminal: + if let requestedMode, activeRightSidebarMode != requestedMode { + return focusRightSidebar(mode: requestedMode, focusFirstItem: focusFirstItem) + } return restoreFocusedPanelFocusFromRightSidebarIfNeeded(currentResponder: window?.firstResponder) case .rightSidebar: return focusRightSidebar(mode: requestedMode, focusFirstItem: focusFirstItem) @@ -679,13 +678,13 @@ final class MainWindowFocusController { return } guard case .rightSidebar(let targetMode) = intent, - targetMode == mode, - request.mode == mode else { + targetMode.registeredToolMode == mode, + request.mode.registeredToolMode == mode else { return } let result = focusRightSidebarEndpoint(mode: mode, target: request.target) if result { - rightSidebarFocusState = .focused(mode: mode, target: request.target) + rightSidebarFocusState = .focused(mode: request.mode, target: request.target) } else if request.target == .host, focusFallbackRightSidebarHost() { rightSidebarFocusState = .focused(mode: mode, target: .host) } @@ -726,10 +725,11 @@ final class MainWindowFocusController { target: RightSidebarFocusTarget ) -> Bool { switch mode { - case .files: + case .files, .find: + if target == .searchField { + return fileExplorerHost?.focusSearchField() == true + } return fileExplorerHost?.focusOutline() == true - case .find: - return fileSearchHost?.focusSearchField() == true case .sessions, .customSidebar: return mode == .customSidebar ? focusFallbackRightSidebarHost() : false case .feed: @@ -800,11 +800,8 @@ final class MainWindowFocusController { if let host = rightSidebarHost, responder === host { return fileExplorerState?.mode ?? rememberedRightSidebarMode } - if fileExplorerHost?.ownsKeyboardFocus(responder) == true { - return .files - } - if fileSearchHost?.ownsKeyboardFocus(responder) == true { - return .find + if let mode = fileExplorerHost?.rightSidebarActivationMode(owning: responder) { + return mode } if feedHost?.ownsKeyboardFocus(responder) == true || responder is FeedKeyboardFocusResponder { return .feed diff --git a/Sources/RightSidebarMode+Availability.swift b/Sources/RightSidebarMode+Availability.swift index 26e8440683ac..dc3bfe0db397 100644 --- a/Sources/RightSidebarMode+Availability.swift +++ b/Sources/RightSidebarMode+Availability.swift @@ -2,6 +2,11 @@ import AppKit import Foundation extension RightSidebarMode { + /// The registered sidebar tool that owns this activation target. + var registeredToolMode: RightSidebarMode { + self == .find ? .files : self + } + static func from(cliArgument rawValue: String) -> RightSidebarMode? { switch rawValue.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { case "files": @@ -27,7 +32,21 @@ extension RightSidebarMode { } static func availableModes(feedEnabled: Bool, dockEnabled: Bool) -> [RightSidebarMode] { - allCases.filter { $0 != .customSidebar && $0.isAvailable(feedEnabled: feedEnabled, dockEnabled: dockEnabled) } + availableActivationModes(feedEnabled: feedEnabled, dockEnabled: dockEnabled) + .filter { $0.registeredToolMode == $0 } + } + + static func availableActivationModes(defaults: UserDefaults = .standard) -> [RightSidebarMode] { + availableActivationModes( + feedEnabled: RightSidebarBetaFeatureSettings.isFeedEnabled(defaults: defaults), + dockEnabled: RightSidebarBetaFeatureSettings.isDockEnabled(defaults: defaults) + ) + } + + static func availableActivationModes(feedEnabled: Bool, dockEnabled: Bool) -> [RightSidebarMode] { + allCases.filter { + $0 != .customSidebar && $0.isAvailable(feedEnabled: feedEnabled, dockEnabled: dockEnabled) + } } func isAvailable(defaults: UserDefaults = .standard) -> Bool { @@ -114,6 +133,7 @@ enum RightSidebarKeyboardNavigation { } return text.unicodeScalars.allSatisfy { !CharacterSet.controlCharacters.contains($0) + && !(0xF700...0xF8FF).contains($0.value) } } } diff --git a/Sources/RightSidebarPanelView.swift b/Sources/RightSidebarPanelView.swift index 9bfa99489e64..99598ea4e05b 100644 --- a/Sources/RightSidebarPanelView.swift +++ b/Sources/RightSidebarPanelView.swift @@ -224,7 +224,7 @@ struct RightSidebarPanelView: View { ModeBarButton( item: item, isSelected: item.isSelected( - mode: fileExplorerState.mode + mode: fileExplorerState.mode.registeredToolMode ), badgeCount: item.mode == .feed ? feedPendingCount : 0, shortcutHint: shortcut, @@ -378,19 +378,12 @@ struct RightSidebarPanelView: View { private var contentForMode: some View { if RightSidebarContentMountPolicy.shouldMountContent(isRightSidebarVisible: fileExplorerState.isVisible, hasMountedContent: hasMountedRightSidebarContent) { switch fileExplorerState.mode { - case .files: + case .files, .find: FileExplorerPanelView( store: fileExplorerStore, state: fileExplorerState, onOpenFilePreview: onOpenFilePreview, - presentation: .files - ) - case .find: - FileExplorerPanelView( - store: fileExplorerStore, - state: fileExplorerState, - onOpenFilePreview: onOpenFilePreview, - presentation: .find + presentation: .unified ) case .sessions: SessionIndexView(store: sessionIndexStore, onResume: onResumeSession) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 98752fadb914..956c8340b77c 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -724,6 +724,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources D0B10022A1B2C3D4E5F60001 /* FileDropOverlayViewHitTesting.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10023A1B2C3D4E5F60001 /* FileDropOverlayViewHitTesting.swift */; }; D0B10018A1B2C3D4E5F60001 /* FileDropOverlayViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10019A1B2C3D4E5F60001 /* FileDropOverlayViewTests.swift */; }; FE5996010000000000000002 /* FileExplorerCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5996010000000000000001 /* FileExplorerCellView.swift */; }; + FE8011020000000000000002 /* FileExplorerContainerView+SearchResults.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE8011020000000000000001 /* FileExplorerContainerView+SearchResults.swift */; }; C40410010000000000000021 /* FileExplorerDoubleClickActionSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40410010000000000000020 /* FileExplorerDoubleClickActionSettings.swift */; }; FE002110 /* FileExplorerDoubleClickActionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE002010 /* FileExplorerDoubleClickActionTests.swift */; }; FE5996080000000000000002 /* FileExplorerExternalOpenMenuItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5996080000000000000001 /* FileExplorerExternalOpenMenuItems.swift */; }; @@ -732,19 +733,30 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources FE5996020000000000000002 /* FileExplorerHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5996020000000000000001 /* FileExplorerHeaderView.swift */; }; FE001105 /* FileExplorerKeyboardShortcuts.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE001005 /* FileExplorerKeyboardShortcuts.swift */; }; FE5996030000000000000002 /* FileExplorerNSOutlineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5996030000000000000001 /* FileExplorerNSOutlineView.swift */; }; + 8011B0028011B0028011B002 /* FileExplorerPanelPlacement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8011B0018011B0018011B001 /* FileExplorerPanelPlacement.swift */; }; + 8011A0028011A0028011A002 /* FileExplorerPanelPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8011A0018011A0018011A001 /* FileExplorerPanelPresentation.swift */; }; + A8011F010000000000000002 /* FileExplorerPanelView+TreeFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8011F010000000000000001 /* FileExplorerPanelView+TreeFilter.swift */; }; FE002101 /* FileExplorerRootResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE002001 /* FileExplorerRootResolverTests.swift */; }; 9F8A6669D6F54F0EA8E8BEB8 /* FileExplorerRootSyncPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 120A1EF846214C938416846E /* FileExplorerRootSyncPolicyTests.swift */; }; FE5996040000000000000002 /* FileExplorerRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5996040000000000000001 /* FileExplorerRowView.swift */; }; + FE8011050000000000000002 /* FileExplorerSearchBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE8011050000000000000001 /* FileExplorerSearchBarView.swift */; }; FE001103 /* FileExplorerSearchController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE001003 /* FileExplorerSearchController.swift */; }; FE5996050000000000000002 /* FileExplorerSearchField.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5996050000000000000001 /* FileExplorerSearchField.swift */; }; + FE8011080000000000000002 /* FileExplorerSearchMenuSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE8011080000000000000001 /* FileExplorerSearchMenuSelection.swift */; }; + FE8011040000000000000002 /* FileExplorerSearchQueryState.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE8011040000000000000001 /* FileExplorerSearchQueryState.swift */; }; FE5996060000000000000002 /* FileExplorerSearchResultCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5996060000000000000001 /* FileExplorerSearchResultCellView.swift */; }; FE5996070000000000000002 /* FileExplorerSearchResultsTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5996070000000000000001 /* FileExplorerSearchResultsTableView.swift */; }; + FE8011030000000000000002 /* FileExplorerSearchScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE8011030000000000000001 /* FileExplorerSearchScope.swift */; }; F5996002A1B2C3D4E5F60001 /* FileExplorerShortcutSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5996002A1B2C3D4E5F60002 /* FileExplorerShortcutSettingsTests.swift */; }; B37A00000000000000000009 /* FileExplorerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B37A0000000000000000000A /* FileExplorerState.swift */; }; B37A0000000000000000000B /* FileExplorerStateModePersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B37A0000000000000000000C /* FileExplorerStateModePersistenceTests.swift */; }; FE001101 /* FileExplorerStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE001001 /* FileExplorerStore.swift */; }; FE002102 /* FileExplorerStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE002002 /* FileExplorerStoreTests.swift */; }; FE001104 /* FileExplorerTerminalPathInsertion.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE001004 /* FileExplorerTerminalPathInsertion.swift */; }; + FE8011010000000000000002 /* FileExplorerTreeFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE8011010000000000000001 /* FileExplorerTreeFilter.swift */; }; + A8011F020000000000000002 /* FileExplorerTreeFilterResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8011F020000000000000001 /* FileExplorerTreeFilterResult.swift */; }; + A8011F030000000000000002 /* FileExplorerTreeFilterSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8011F030000000000000001 /* FileExplorerTreeFilterSnapshot.swift */; }; + A8011F040000000000000002 /* FileExplorerTreeFilterSnapshotBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8011F040000000000000001 /* FileExplorerTreeFilterSnapshotBuilder.swift */; }; FE001102 /* FileExplorerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE001002 /* FileExplorerView.swift */; }; A5001441A5001441A5001441 /* FileOpenSocketSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001440A5001440A5001440 /* FileOpenSocketSupport.swift */; }; A5001432A5001432A5001432 /* FilePreviewFocusCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001431A5001431A5001431 /* FilePreviewFocusCoordinator.swift */; }; @@ -768,7 +780,9 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C0DEFC100000000000000001 /* FilePreviewTextEditorTextKitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFC100000000000000002 /* FilePreviewTextEditorTextKitTests.swift */; }; F11E52270000000000005227 /* FilePreviewWordWrapSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11E52270000000000005228 /* FilePreviewWordWrapSettings.swift */; }; A5001445A5001445A5001445 /* FilePreviewWorkspaceOpenSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001444A5001444A5001444 /* FilePreviewWorkspaceOpenSupport.swift */; }; + FE8011060000000000000002 /* FileSearchRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE8011060000000000000001 /* FileSearchRequest.swift */; }; FE002103 /* FileSearchRipgrepParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE002003 /* FileSearchRipgrepParserTests.swift */; }; + 8011D0028011D0028011D002 /* FileSearchSnapshot+Grouping.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8011D0018011D0018011D001 /* FileSearchSnapshot+Grouping.swift */; }; C35610000000000000000001 /* FinderFileDropRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C35610000000000000000002 /* FinderFileDropRegressionTests.swift */; }; D0E0F0B4A1B2C3D4E5F60718 /* FindSelectionShortcutUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B5A1B2C3D4E5F60718 /* FindSelectionShortcutUITests.swift */; }; F1A0C0DE0000000000000002 /* FindTextFieldSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A0C0DE0000000000000001 /* FindTextFieldSupport.swift */; }; @@ -1649,6 +1663,8 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C0DE64020000000000001004 /* TrackedQLPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE64020000000000001003 /* TrackedQLPreviewView.swift */; }; D3284001A1B2C3D4E5F60718 /* TraditionalChineseIMENumpadRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3284002A1B2C3D4E5F60718 /* TraditionalChineseIMENumpadRegressionTests.swift */; }; A5001501 /* UITestRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001511 /* UITestRecorder.swift */; }; + FE8011070000000000000002 /* UnifiedFileExplorerSearchScopeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE8011070000000000000001 /* UnifiedFileExplorerSearchScopeTests.swift */; }; + 8011C0028011C0028011C002 /* UnifiedFileExplorerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8011C0018011C0018011C001 /* UnifiedFileExplorerTests.swift */; }; A500120D /* UpdateLogStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001223 /* UpdateLogStore.swift */; }; F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */; }; C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */; }; @@ -2527,6 +2543,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D0B10023A1B2C3D4E5F60001 /* FileDropOverlayViewHitTesting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDropOverlayViewHitTesting.swift; sourceTree = ""; }; D0B10019A1B2C3D4E5F60001 /* FileDropOverlayViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDropOverlayViewTests.swift; sourceTree = ""; }; FE5996010000000000000001 /* FileExplorerCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerCellView.swift; sourceTree = ""; }; + FE8011020000000000000001 /* FileExplorerContainerView+SearchResults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileExplorerContainerView+SearchResults.swift"; sourceTree = ""; }; C40410010000000000000020 /* FileExplorerDoubleClickActionSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerDoubleClickActionSettings.swift; sourceTree = ""; }; FE002010 /* FileExplorerDoubleClickActionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerDoubleClickActionTests.swift; sourceTree = ""; }; FE5996080000000000000001 /* FileExplorerExternalOpenMenuItems.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerExternalOpenMenuItems.swift; sourceTree = ""; }; @@ -2535,19 +2552,30 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = FE5996020000000000000001 /* FileExplorerHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerHeaderView.swift; sourceTree = ""; }; FE001005 /* FileExplorerKeyboardShortcuts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerKeyboardShortcuts.swift; sourceTree = ""; }; FE5996030000000000000001 /* FileExplorerNSOutlineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerNSOutlineView.swift; sourceTree = ""; }; + 8011B0018011B0018011B001 /* FileExplorerPanelPlacement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerPanelPlacement.swift; sourceTree = ""; }; + 8011A0018011A0018011A001 /* FileExplorerPanelPresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerPanelPresentation.swift; sourceTree = ""; }; + A8011F010000000000000001 /* FileExplorerPanelView+TreeFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileExplorerPanelView+TreeFilter.swift"; sourceTree = ""; }; FE002001 /* FileExplorerRootResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerRootResolverTests.swift; sourceTree = ""; }; 120A1EF846214C938416846E /* FileExplorerRootSyncPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerRootSyncPolicyTests.swift; sourceTree = ""; }; FE5996040000000000000001 /* FileExplorerRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerRowView.swift; sourceTree = ""; }; + FE8011050000000000000001 /* FileExplorerSearchBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerSearchBarView.swift; sourceTree = ""; }; FE001003 /* FileExplorerSearchController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerSearchController.swift; sourceTree = ""; }; FE5996050000000000000001 /* FileExplorerSearchField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerSearchField.swift; sourceTree = ""; }; + FE8011080000000000000001 /* FileExplorerSearchMenuSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerSearchMenuSelection.swift; sourceTree = ""; }; + FE8011040000000000000001 /* FileExplorerSearchQueryState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerSearchQueryState.swift; sourceTree = ""; }; FE5996060000000000000001 /* FileExplorerSearchResultCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerSearchResultCellView.swift; sourceTree = ""; }; FE5996070000000000000001 /* FileExplorerSearchResultsTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerSearchResultsTableView.swift; sourceTree = ""; }; + FE8011030000000000000001 /* FileExplorerSearchScope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerSearchScope.swift; sourceTree = ""; }; F5996002A1B2C3D4E5F60002 /* FileExplorerShortcutSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerShortcutSettingsTests.swift; sourceTree = ""; }; B37A0000000000000000000A /* FileExplorerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerState.swift; sourceTree = ""; }; B37A0000000000000000000C /* FileExplorerStateModePersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerStateModePersistenceTests.swift; sourceTree = ""; }; FE001001 /* FileExplorerStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerStore.swift; sourceTree = ""; }; FE002002 /* FileExplorerStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerStoreTests.swift; sourceTree = ""; }; FE001004 /* FileExplorerTerminalPathInsertion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerTerminalPathInsertion.swift; sourceTree = ""; }; + FE8011010000000000000001 /* FileExplorerTreeFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerTreeFilter.swift; sourceTree = ""; }; + A8011F020000000000000001 /* FileExplorerTreeFilterResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerTreeFilterResult.swift; sourceTree = ""; }; + A8011F030000000000000001 /* FileExplorerTreeFilterSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerTreeFilterSnapshot.swift; sourceTree = ""; }; + A8011F040000000000000001 /* FileExplorerTreeFilterSnapshotBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerTreeFilterSnapshotBuilder.swift; sourceTree = ""; }; FE001002 /* FileExplorerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerView.swift; sourceTree = ""; }; A5001440A5001440A5001440 /* FileOpenSocketSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileOpenSocketSupport.swift; sourceTree = ""; }; A5001431A5001431A5001431 /* FilePreviewFocusCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/FilePreviewFocusCoordinator.swift; sourceTree = ""; }; @@ -2571,7 +2599,9 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C0DEFC100000000000000002 /* FilePreviewTextEditorTextKitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePreviewTextEditorTextKitTests.swift; sourceTree = ""; }; F11E52270000000000005228 /* FilePreviewWordWrapSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/FilePreviewWordWrapSettings.swift; sourceTree = ""; }; A5001444A5001444A5001444 /* FilePreviewWorkspaceOpenSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/FilePreviewWorkspaceOpenSupport.swift; sourceTree = ""; }; + FE8011060000000000000001 /* FileSearchRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSearchRequest.swift; sourceTree = ""; }; FE002003 /* FileSearchRipgrepParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSearchRipgrepParserTests.swift; sourceTree = ""; }; + 8011D0018011D0018011D001 /* FileSearchSnapshot+Grouping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileSearchSnapshot+Grouping.swift"; sourceTree = ""; }; C35610000000000000000002 /* FinderFileDropRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinderFileDropRegressionTests.swift; sourceTree = ""; }; D0E0F0B5A1B2C3D4E5F60718 /* FindSelectionShortcutUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FindSelectionShortcutUITests.swift; sourceTree = ""; }; F1A0C0DE0000000000000001 /* FindTextFieldSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/FindTextFieldSupport.swift; sourceTree = ""; }; @@ -3434,6 +3464,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C0DE64020000000000001003 /* TrackedQLPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/TrackedQLPreviewView.swift; sourceTree = ""; }; D3284002A1B2C3D4E5F60718 /* TraditionalChineseIMENumpadRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TraditionalChineseIMENumpadRegressionTests.swift; sourceTree = ""; }; A5001511 /* UITestRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestRecorder.swift; sourceTree = ""; }; + FE8011070000000000000001 /* UnifiedFileExplorerSearchScopeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnifiedFileExplorerSearchScopeTests.swift; sourceTree = ""; }; + 8011C0018011C0018011C001 /* UnifiedFileExplorerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnifiedFileExplorerTests.swift; sourceTree = ""; }; A5001223 /* UpdateLogStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateLogStore.swift; sourceTree = ""; }; F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillReleaseVisibilityTests.swift; sourceTree = ""; }; C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillUITests.swift; sourceTree = ""; }; @@ -4828,6 +4860,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A5FB1204 /* WorkspaceConfigActionCapture.swift */, A5001655 /* CmuxActionTrust.swift */, FE5996010000000000000001 /* FileExplorerCellView.swift */, + FE8011020000000000000001 /* FileExplorerContainerView+SearchResults.swift */, FE5996080000000000000001 /* FileExplorerExternalOpenMenuItems.swift */, FE5996090000000000000001 /* FileExplorerExternalOpenRequest.swift */, FE5996020000000000000001 /* FileExplorerHeaderView.swift */, @@ -4835,13 +4868,26 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = FE5996030000000000000001 /* FileExplorerNSOutlineView.swift */, FE5996040000000000000001 /* FileExplorerRowView.swift */, FE001003 /* FileExplorerSearchController.swift */, + FE8011050000000000000001 /* FileExplorerSearchBarView.swift */, + FE8011080000000000000001 /* FileExplorerSearchMenuSelection.swift */, FE5996050000000000000001 /* FileExplorerSearchField.swift */, + FE8011040000000000000001 /* FileExplorerSearchQueryState.swift */, + FE8011030000000000000001 /* FileExplorerSearchScope.swift */, + 8011A0018011A0018011A001 /* FileExplorerPanelPresentation.swift */, + 8011B0018011B0018011B001 /* FileExplorerPanelPlacement.swift */, + 8011D0018011D0018011D001 /* FileSearchSnapshot+Grouping.swift */, FE5996060000000000000001 /* FileExplorerSearchResultCellView.swift */, FE5996070000000000000001 /* FileExplorerSearchResultsTableView.swift */, B37A0000000000000000000A /* FileExplorerState.swift */, FE001001 /* FileExplorerStore.swift */, FE001004 /* FileExplorerTerminalPathInsertion.swift */, + A8011F010000000000000001 /* FileExplorerPanelView+TreeFilter.swift */, + FE8011010000000000000001 /* FileExplorerTreeFilter.swift */, + A8011F020000000000000001 /* FileExplorerTreeFilterResult.swift */, + A8011F030000000000000001 /* FileExplorerTreeFilterSnapshot.swift */, + A8011F040000000000000001 /* FileExplorerTreeFilterSnapshotBuilder.swift */, FE001002 /* FileExplorerView.swift */, + FE8011060000000000000001 /* FileSearchRequest.swift */, FE001007 /* GitFileStatus.swift */, FE001006 /* GitStatusProvider.swift */, FE003001 /* SessionIndexStore.swift */, @@ -5284,6 +5330,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = E50320010000000000000002 /* ExtensionWorktreeSpawnArgsTests.swift */, C0DE49950000000000000002 /* FilePreviewKindResolverTests.swift */, 120A1EF846214C938416846E /* FileExplorerRootSyncPolicyTests.swift */, + 8011C0018011C0018011C001 /* UnifiedFileExplorerTests.swift */, + FE8011070000000000000001 /* UnifiedFileExplorerSearchScopeTests.swift */, 4931A11B0000000000000001 /* HiddenRightSidebarContentMountingTests.swift */, A4880BD05924400E5279D632 /* MobilePairingConnectionTransitionTests.swift */, 4C1A7E10B2D34F56A8C90014 /* MobileHostStatusVerificationLimiterTests.swift */, @@ -6298,20 +6346,32 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D0B10020A1B2C3D4E5F60001 /* FileDropOverlayView.swift in Sources */, D0B10022A1B2C3D4E5F60001 /* FileDropOverlayViewHitTesting.swift in Sources */, FE5996010000000000000002 /* FileExplorerCellView.swift in Sources */, + FE8011020000000000000002 /* FileExplorerContainerView+SearchResults.swift in Sources */, C40410010000000000000021 /* FileExplorerDoubleClickActionSettings.swift in Sources */, FE5996080000000000000002 /* FileExplorerExternalOpenMenuItems.swift in Sources */, FE5996090000000000000002 /* FileExplorerExternalOpenRequest.swift in Sources */, FE5996020000000000000002 /* FileExplorerHeaderView.swift in Sources */, FE001105 /* FileExplorerKeyboardShortcuts.swift in Sources */, FE5996030000000000000002 /* FileExplorerNSOutlineView.swift in Sources */, + 8011B0028011B0028011B002 /* FileExplorerPanelPlacement.swift in Sources */, + 8011A0028011A0028011A002 /* FileExplorerPanelPresentation.swift in Sources */, + A8011F010000000000000002 /* FileExplorerPanelView+TreeFilter.swift in Sources */, FE5996040000000000000002 /* FileExplorerRowView.swift in Sources */, + FE8011050000000000000002 /* FileExplorerSearchBarView.swift in Sources */, FE001103 /* FileExplorerSearchController.swift in Sources */, FE5996050000000000000002 /* FileExplorerSearchField.swift in Sources */, + FE8011080000000000000002 /* FileExplorerSearchMenuSelection.swift in Sources */, + FE8011040000000000000002 /* FileExplorerSearchQueryState.swift in Sources */, FE5996060000000000000002 /* FileExplorerSearchResultCellView.swift in Sources */, FE5996070000000000000002 /* FileExplorerSearchResultsTableView.swift in Sources */, + FE8011030000000000000002 /* FileExplorerSearchScope.swift in Sources */, B37A00000000000000000009 /* FileExplorerState.swift in Sources */, FE001101 /* FileExplorerStore.swift in Sources */, FE001104 /* FileExplorerTerminalPathInsertion.swift in Sources */, + FE8011010000000000000002 /* FileExplorerTreeFilter.swift in Sources */, + A8011F020000000000000002 /* FileExplorerTreeFilterResult.swift in Sources */, + A8011F030000000000000002 /* FileExplorerTreeFilterSnapshot.swift in Sources */, + A8011F040000000000000002 /* FileExplorerTreeFilterSnapshotBuilder.swift in Sources */, FE001102 /* FileExplorerView.swift in Sources */, A5001441A5001441A5001441 /* FileOpenSocketSupport.swift in Sources */, A5001432A5001432A5001432 /* FilePreviewFocusCoordinator.swift in Sources */, @@ -6331,6 +6391,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D0B10016A1B2C3D4E5F60001 /* FilePreviewTextEditor.swift in Sources */, F11E52270000000000005227 /* FilePreviewWordWrapSettings.swift in Sources */, A5001445A5001445A5001445 /* FilePreviewWorkspaceOpenSupport.swift in Sources */, + FE8011060000000000000002 /* FileSearchRequest.swift in Sources */, + 8011D0028011D0028011D002 /* FileSearchSnapshot+Grouping.swift in Sources */, F1A0C0DE0000000000000002 /* FindTextFieldSupport.swift in Sources */, F0C05170000000000000002 /* FocusHistory.swift in Sources */, C4160A020000000000000001 /* FocusHistoryMenuInvalidator.swift in Sources */, @@ -7613,6 +7675,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = F50030040000000000000001 /* TitlebarInteractiveControlTests.swift in Sources */, E30760060000000000000002 /* TmuxWorkspacePaneOverlayModelTests.swift in Sources */, D3284001A1B2C3D4E5F60718 /* TraditionalChineseIMENumpadRegressionTests.swift in Sources */, + FE8011070000000000000002 /* UnifiedFileExplorerSearchScopeTests.swift in Sources */, + 8011C0028011C0028011C002 /* UnifiedFileExplorerTests.swift in Sources */, F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */, A3340002A3340002A3340002 /* ViewerNavigationTests.swift in Sources */, C37800000000000000000003 /* VMDefaultCloudCommandTests.swift in Sources */, diff --git a/cmuxTests/RightSidebarRemoteCommandTests.swift b/cmuxTests/RightSidebarRemoteCommandTests.swift index 0d320bf5ebb4..c400242ea339 100644 --- a/cmuxTests/RightSidebarRemoteCommandTests.swift +++ b/cmuxTests/RightSidebarRemoteCommandTests.swift @@ -37,6 +37,14 @@ extension TerminalControllerSocketSecurityTests { #expect(fileExplorerState.mode == .find) #expect(fileExplorerState.isVisible) + let findModeResponse = TerminalController.shared.handleSocketLine("right_sidebar mode") + let findModeData = try #require(findModeResponse.data(using: .utf8)) + let findModePayload = try #require(JSONSerialization.jsonObject(with: findModeData) as? [String: Any]) + #expect(findModePayload["mode"] as? String == "find") + + #expect(TerminalController.shared.handleSocketLine("right_sidebar focus") == "OK") + #expect(fileExplorerState.mode == .find) + #expect(TerminalController.shared.handleSocketLine("right_sidebar set vault --no-focus") == "OK") #expect(fileExplorerState.mode == .sessions) diff --git a/cmuxTests/UnifiedFileExplorerSearchScopeTests.swift b/cmuxTests/UnifiedFileExplorerSearchScopeTests.swift new file mode 100644 index 000000000000..39bcf6b5356a --- /dev/null +++ b/cmuxTests/UnifiedFileExplorerSearchScopeTests.swift @@ -0,0 +1,496 @@ +import AppKit +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite("Unified file explorer search scopes", .serialized) +struct UnifiedFileExplorerSearchScopeTests { + @Test("Names and Contents retain independent queries") + func independentQueryState() { + var state = FileExplorerSearchQueryState() + + state.setQuery("Package", for: .names) + state.setQuery("TODO", for: .contents) + #expect(state.query(for: .names) == "Package") + #expect(state.query(for: .contents) == "TODO") + } + + @Test("Name filtering reads only already-loaded nodes") + func nameFilterPreservesLazyNodes() throws { + let root = FileExplorerNode(name: "Sources", path: "/repo/Sources", isDirectory: true) + let unloadedDirectory = FileExplorerNode( + name: "Unloaded", + path: "/repo/Sources/Unloaded", + isDirectory: true + ) + let matchingFile = FileExplorerNode( + name: "NeedleView.swift", + path: "/repo/Sources/NeedleView.swift", + isDirectory: false + ) + root.children = [unloadedDirectory, matchingFile] + var filter = FileExplorerTreeFilter() + + let builder = FileExplorerTreeFilterSnapshotBuilder(nodes: [root]) + let captured = try #require(builder.buildSynchronously(upTo: .max)) + filter.replaceIndex(snapshot: captured.snapshot, nodesByPath: captured.nodesByPath) + let activatedFilter = filter.setQuery("needle") + let unchangedFilter = filter.setQuery(" needle ") + _ = filter.apply(filter.snapshot.filterSynchronously(query: filter.query)) + #expect(activatedFilter) + #expect(!unchangedFilter) + + #expect(filter.visibleRootNodes(in: [root]).map(\.path) == [root.path]) + #expect(filter.visibleChildren(of: root).map(\.path) == [matchingFile.path]) + #expect(unloadedDirectory.children == nil) + } + + @Test("Full-text search retains the original fixed-string behavior") + func fullTextSearchArguments() { + let request = FileSearchRequest( + query: " TODO ", + rootPath: "/repo", + isLocal: true, + contentRevision: 4 + ) + + #expect(request.query == "TODO") + #expect(request.ripgrepArguments.contains("--smart-case")) + #expect(request.ripgrepArguments.contains("--fixed-strings")) + #expect(Self.globValues(in: request.ripgrepArguments).contains("!**/.git/**")) + #expect(Self.globValues(in: request.ripgrepArguments).contains("!**/node_modules/**")) + #expect(Array(request.ripgrepArguments.suffix(3)) == ["--", "TODO", "/repo"]) + } + + @Test("Switching scopes preserves the filtered tree and full-text results") + func scopeSwitchPreservesBothProjections() throws { + let state = FileExplorerState.unifiedTestState(mode: .files) + let store = FileExplorerStore() + store.rootPath = "/repo" + let sources = FileExplorerNode(name: "Sources", path: "/repo/Sources", isDirectory: true) + let matchingFile = FileExplorerNode( + name: "NeedleView.swift", + path: "/repo/Sources/NeedleView.swift", + isDirectory: false + ) + let otherFile = FileExplorerNode( + name: "Other.swift", + path: "/repo/Sources/Other.swift", + isDirectory: false + ) + sources.children = [matchingFile, otherFile] + let readme = FileExplorerNode(name: "README.md", path: "/repo/README.md", isDirectory: false) + store.rootNodes = [sources, readme] + store.expand(node: sources) + let controller = UnifiedSearchControllerSpy() + let coordinator = FileExplorerPanelView.Coordinator( + store: store, + state: state, + onOpenFilePreview: { _ in } + ) + let container = FileExplorerContainerView( + coordinator: coordinator, + presentation: .unified, + searchController: controller + ) + container.frame = NSRect(x: 0, y: 0, width: 320, height: 520) + container.updateHeader(store: store) + container.updateVisibility(hasContent: true, isLoading: false, statusMessage: nil) + coordinator.reloadIfNeeded() + + let window = NSWindow( + contentRect: container.frame, + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.contentView = container + window.contentView?.layoutSubtreeIfNeeded() + defer { + _ = window.makeFirstResponder(nil) + window.contentView = nil + window.orderOut(nil) + } + + let field = try #require(Self.searchField(in: container)) + let outline = try #require(Self.outlineView(in: container)) + #expect(window.makeFirstResponder(outline)) + outline.keyDown(with: try Self.keyEvent(characters: "/", keyCode: 44)) + outline.keyDown(with: try Self.keyEvent(characters: "needle", keyCode: 0)) + container.applyPendingFileFilter() + #expect(state.mode == .files) + #expect(container.displayedSearchScope == .names) + #expect(outline.numberOfRows == 2) + #expect(store.expandedPaths == [sources.path]) + + #expect(container.focusSearchField()) + #expect(field.stringValue.isEmpty) + field.stringValue = "TODO" + container.controlTextDidChange( + Notification(name: NSControl.textDidChangeNotification, object: field) + ) + let result = FileSearchResult( + path: matchingFile.path, + relativePath: "Sources/NeedleView.swift", + lineNumber: 12, + columnNumber: 4, + preview: "// TODO: finish" + ) + controller.publish( + FileSearchSnapshot(query: "TODO", results: [result], status: .matches, isSearching: false) + ) + + #expect(container.focusOutline()) + #expect(field.stringValue == "TODO") + #expect(container.searchQuery(for: .names) == "needle") + #expect(outline.numberOfRows == 2) + #expect(container.searchSnapshot.results == [result]) + + outline.keyDown(with: try Self.keyEvent(characters: "\u{1b}", keyCode: 53)) + #expect(outline.numberOfRows == 4) + #expect(store.expandedPaths == [sources.path]) + + #expect(container.focusSearchField()) + #expect(field.stringValue == "TODO") + #expect(container.searchSnapshot.results == [result]) + } + + @Test("Clearing a name filter restores nested expansion") + func clearingNameFilterRestoresNestedExpansion() throws { + let state = FileExplorerState.unifiedTestState(mode: .files) + let store = FileExplorerStore() + store.rootPath = "/repo" + let root = FileExplorerNode(name: "Sources", path: "/repo/Sources", isDirectory: true) + let nested = FileExplorerNode(name: "Nested", path: "/repo/Sources/Nested", isDirectory: true) + let match = FileExplorerNode( + name: "Needle.swift", + path: "/repo/Sources/Nested/Needle.swift", + isDirectory: false + ) + nested.children = [match] + root.children = [nested] + store.rootNodes = [root] + store.expand(node: root) + store.expand(node: nested) + let coordinator = FileExplorerPanelView.Coordinator( + store: store, + state: state, + onOpenFilePreview: { _ in } + ) + let container = FileExplorerContainerView( + coordinator: coordinator, + presentation: .unified, + searchController: UnifiedSearchControllerSpy() + ) + container.frame = NSRect(x: 0, y: 0, width: 320, height: 480) + container.updateHeader(store: store) + container.updateVisibility(hasContent: true, isLoading: false, statusMessage: nil) + coordinator.reloadIfNeeded() + let outline = try #require(Self.outlineView(in: container)) + outline.expandItem(root) + outline.expandItem(nested) + + outline.keyDown(with: try Self.keyEvent(characters: "/", keyCode: 44)) + outline.keyDown(with: try Self.keyEvent(characters: "needle", keyCode: 0)) + container.applyPendingFileFilter() + outline.keyDown(with: try Self.keyEvent(characters: "\u{1b}", keyCode: 53)) + + #expect(outline.isItemExpanded(root)) + #expect(outline.isItemExpanded(nested)) + } + + @Test("Empty Escape in the dedicated Find presentation preserves content search") + func emptyEscapePreservesDedicatedFindPresentation() throws { + let state = FileExplorerState.unifiedTestState(mode: .find) + let store = FileExplorerStore() + store.rootPath = "/repo" + store.rootNodes = [ + FileExplorerNode(name: "README.md", path: "/repo/README.md", isDirectory: false) + ] + let coordinator = FileExplorerPanelView.Coordinator( + store: store, + state: state, + onOpenFilePreview: { _ in } + ) + let container = FileExplorerContainerView( + coordinator: coordinator, + presentation: .find, + searchController: UnifiedSearchControllerSpy() + ) + container.frame = NSRect(x: 0, y: 0, width: 320, height: 480) + container.updateHeader(store: store) + container.updateVisibility(hasContent: true, isLoading: false, statusMessage: nil) + coordinator.reloadIfNeeded() + + let window = NSWindow( + contentRect: container.frame, + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.contentView = container + window.contentView?.layoutSubtreeIfNeeded() + defer { + _ = window.makeFirstResponder(nil) + window.contentView = nil + window.orderOut(nil) + } + + let outline = try #require(Self.outlineView(in: container)) + #expect(container.focusSearchField()) + #expect(container.displayedSearchScope == .contents) + + let handled = container.handleSearchFieldCommand( + #selector(NSResponder.cancelOperation(_:)), + textView: NSTextView() + ) + + #expect(handled) + #expect(container.displayedSearchScope == .contents) + #expect(window.firstResponder !== outline) + } + + @Test("Filtered disclosure moves into an auto-expanded directory") + func filteredDisclosureMovesToFirstVisibleChild() throws { + let state = FileExplorerState.unifiedTestState(mode: .files) + let store = FileExplorerStore() + store.rootPath = "/repo" + let root = FileExplorerNode(name: "Sources", path: "/repo/Sources", isDirectory: true) + let match = FileExplorerNode( + name: "Needle.swift", + path: "/repo/Sources/Needle.swift", + isDirectory: false + ) + root.children = [match] + store.rootNodes = [root] + let coordinator = FileExplorerPanelView.Coordinator( + store: store, + state: state, + onOpenFilePreview: { _ in } + ) + let container = FileExplorerContainerView( + coordinator: coordinator, + presentation: .unified, + searchController: UnifiedSearchControllerSpy() + ) + container.frame = NSRect(x: 0, y: 0, width: 320, height: 480) + container.updateHeader(store: store) + container.updateVisibility(hasContent: true, isLoading: false, statusMessage: nil) + coordinator.reloadIfNeeded() + + let outline = try #require(Self.outlineView(in: container)) + store.select(node: root) + coordinator.ensureSelection(in: outline, fallbackToFirstVisible: true, scroll: false) + coordinator.setFileFilterQuery("needle", in: outline) + + #expect(outline.isItemExpanded(root)) + #expect(!store.isExpanded(root)) + #expect(outline.selectedRow == 0) + + NotificationCenter.default.post(name: .fileExplorerStyleDidChange, object: nil) + #expect(outline.isItemExpanded(root)) + outline.keyDown(with: try Self.keyEvent(characters: "\u{f703}", keyCode: 124)) + + #expect(outline.selectedRow == 1) + #expect(store.selectedPath == match.path) + } + + @Test("Hidden name filtering defers store refreshes until Files returns") + func hiddenNameFilterDefersStoreRefresh() throws { + let state = FileExplorerState.unifiedTestState(mode: .files) + let store = FileExplorerStore() + store.rootPath = "/repo" + let root = FileExplorerNode(name: "Sources", path: "/repo/Sources", isDirectory: true) + let firstMatch = FileExplorerNode( + name: "FirstNeedle.swift", + path: "/repo/Sources/FirstNeedle.swift", + isDirectory: false + ) + root.children = [firstMatch] + store.rootNodes = [root] + let coordinator = FileExplorerPanelView.Coordinator( + store: store, + state: state, + onOpenFilePreview: { _ in } + ) + let container = FileExplorerContainerView( + coordinator: coordinator, + presentation: .unified, + searchController: UnifiedSearchControllerSpy() + ) + container.frame = NSRect(x: 0, y: 0, width: 320, height: 480) + container.updateHeader(store: store) + container.updateVisibility(hasContent: true, isLoading: false, statusMessage: nil) + coordinator.reloadIfNeeded() + + let window = NSWindow( + contentRect: container.frame, + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.contentView = container + window.contentView?.layoutSubtreeIfNeeded() + defer { + _ = window.makeFirstResponder(nil) + window.contentView = nil + window.orderOut(nil) + } + + let outline = try #require(Self.outlineView(in: container)) + coordinator.setFileFilterQuery("needle", in: outline) + #expect(outline.numberOfRows == 2) + #expect(container.focusSearchField()) + + let secondMatch = FileExplorerNode( + name: "SecondNeedle.swift", + path: "/repo/Sources/SecondNeedle.swift", + isDirectory: false + ) + root.children = [firstMatch, secondMatch] + store.rootNodes = [root] + coordinator.reloadIfNeeded() + + #expect(outline.numberOfRows == 2) + #expect(container.focusOutline()) + #expect(outline.numberOfRows == 3) + + outline.collapseItem(root) + #expect(!outline.isItemExpanded(root)) + coordinator.reloadIfNeeded() + #expect(!outline.isItemExpanded(root)) + } + + @Test("Return flushes a pending name filter before opening") + func returnFlushesPendingNameFilter() throws { + let state = FileExplorerState.unifiedTestState(mode: .files) + let store = FileExplorerStore() + store.rootPath = "/repo" + let directory = FileExplorerNode(name: "Sources", path: "/repo/Sources", isDirectory: true) + let match = FileExplorerNode(name: "Needle.swift", path: "/repo/Sources/Needle.swift", isDirectory: false) + let staleSelection = FileExplorerNode(name: "Other.swift", path: "/repo/Sources/Other.swift", isDirectory: false) + directory.children = [match, staleSelection] + store.rootNodes = [directory] + store.select(node: staleSelection) + var openedPath: String? + let coordinator = FileExplorerPanelView.Coordinator( + store: store, + state: state, + onOpenFilePreview: { openedPath = $0 } + ) + let container = FileExplorerContainerView( + coordinator: coordinator, + presentation: .unified, + searchController: UnifiedSearchControllerSpy() + ) + container.frame = NSRect(x: 0, y: 0, width: 320, height: 480) + container.updateHeader(store: store) + container.updateVisibility(hasContent: true, isLoading: false, statusMessage: nil) + coordinator.reloadIfNeeded() + let outline = try #require(Self.outlineView(in: container)) + + outline.keyDown(with: try Self.keyEvent(characters: "/", keyCode: 44)) + outline.keyDown(with: try Self.keyEvent(characters: "needle", keyCode: 0)) + outline.keyDown(with: try Self.keyEvent(characters: "\r", keyCode: 36)) + + #expect(outline.numberOfRows == 1) + #expect(openedPath == match.path) + } + + @Test("Double-click flushes a pending name filter before resolving its row") + func doubleClickFlushesPendingNameFilter() throws { + let state = FileExplorerState.unifiedTestState(mode: .files) + let store = FileExplorerStore() + store.rootPath = "/repo" + let match = FileExplorerNode(name: "Needle.swift", path: "/repo/Needle.swift", isDirectory: false) + let hidden = FileExplorerNode(name: "Other.swift", path: "/repo/Other.swift", isDirectory: false) + store.rootNodes = [match, hidden] + store.select(node: hidden) + var openedPath: String? + let coordinator = FileExplorerPanelView.Coordinator( + store: store, + state: state, + onOpenFilePreview: { openedPath = $0 } + ) + let container = FileExplorerContainerView( + coordinator: coordinator, + presentation: .unified, + searchController: UnifiedSearchControllerSpy() + ) + container.updateHeader(store: store) + container.updateVisibility(hasContent: true, isLoading: false, statusMessage: nil) + coordinator.reloadIfNeeded() + let outline = try #require(Self.outlineView(in: container)) + + outline.keyDown(with: try Self.keyEvent(characters: "/", keyCode: 44)) + outline.keyDown(with: try Self.keyEvent(characters: "needle", keyCode: 0)) + coordinator.handleDoubleClick(outline) + + #expect(outline.numberOfRows == 1) + #expect(openedPath == nil) + } + + private static func globValues(in arguments: [String]) -> [String] { + arguments.indices.compactMap { index in + guard arguments[index] == "--glob", arguments.indices.contains(index + 1) else { return nil } + return arguments[index + 1] + } + } + + private static func searchField(in root: NSView) -> FileExplorerSearchField? { + if let field = root as? FileExplorerSearchField { return field } + for subview in root.subviews { + if let field = searchField(in: subview) { return field } + } + return nil + } + + private static func outlineView(in root: NSView) -> FileExplorerNSOutlineView? { + if let outline = root as? FileExplorerNSOutlineView { return outline } + for subview in root.subviews { + if let outline = outlineView(in: subview) { return outline } + } + return nil + } + + private static func keyEvent(characters: String, keyCode: UInt16) throws -> NSEvent { + try #require(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: characters, + charactersIgnoringModifiers: characters, + isARepeat: false, + keyCode: keyCode + )) + } + +} + +@MainActor +private final class UnifiedSearchControllerSpy: FileSearchControlling { + var onSnapshotChanged: ((FileSearchSnapshot) -> Void)? + + func search(query rawQuery: String, rootPath: String, isLocal: Bool, contentRevision: Int) {} + func cancel(clear: Bool) {} + func publish(_ snapshot: FileSearchSnapshot) { onSnapshotChanged?(snapshot) } +} + +extension FileExplorerState { + static func unifiedTestState(mode: RightSidebarMode) -> FileExplorerState { + let suiteName = "UnifiedFileExplorerTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + let state = FileExplorerState(defaults: defaults) + state.mode = mode + return state + } +} diff --git a/cmuxTests/UnifiedFileExplorerTests.swift b/cmuxTests/UnifiedFileExplorerTests.swift new file mode 100644 index 000000000000..13fb82c5adc8 --- /dev/null +++ b/cmuxTests/UnifiedFileExplorerTests.swift @@ -0,0 +1,499 @@ +import AppKit +import CmuxFoundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite("Unified Files and Find sidebar", .serialized) +struct UnifiedFileExplorerTests { + @Test("Find remains an activation alias instead of a registered sidebar tool") + func findIsAnActivationAlias() { + let registeredModes = RightSidebarMode.availableModes(feedEnabled: true, dockEnabled: true) + let activationModes = RightSidebarMode.availableActivationModes(feedEnabled: true, dockEnabled: true) + + #expect(registeredModes == [.files, .sessions, .feed, .dock]) + #expect(activationModes.contains(.find)) + #expect(RightSidebarMode.find.registeredToolMode == .files) + } + + @Test("Grouped search rows retain accessible file and line identity") + func groupedSearchRowsRetainAccessibleIdentity() { + let result = FileSearchResult( + path: "/tmp/unified-file-explorer-tests/Sources/file.swift", + relativePath: "Sources/file.swift", + lineNumber: 7, + columnNumber: 3, + preview: "let needle = true" + ) + let cell = FileExplorerSearchResultCellView( + identifier: NSUserInterfaceItemIdentifier("AccessibleSearchResult") + ) + + cell.configure(with: result, startsFileGroup: false) + + let format = String( + localized: "fileExplorer.search.result.accessibilityLabel", + defaultValue: "%@: line %lld" + ) + #expect( + cell.accessibilityLabel() == String.localizedStringWithFormat( + format, + result.relativePath, + Int64(result.lineNumber) + ) + ) + #expect(cell.accessibilityValue() as? String == result.preview) + } + + @Test("Interleaved matches are grouped by file") + func interleavedMatchesAreGroupedByFile() { + let firstMatch = FileSearchResult( + path: "/tmp/a.swift", + relativePath: "a.swift", + lineNumber: 1, + columnNumber: 1, + preview: "first" + ) + let otherFileMatch = FileSearchResult( + path: "/tmp/b.swift", + relativePath: "b.swift", + lineNumber: 2, + columnNumber: 1, + preview: "other" + ) + let secondMatch = FileSearchResult( + path: "/tmp/a.swift", + relativePath: "a.swift", + lineNumber: 3, + columnNumber: 1, + preview: "second" + ) + let snapshot = FileSearchSnapshot( + query: "match", + results: [firstMatch, otherFileMatch, secondMatch], + status: .matches, + isSearching: false + ) + + #expect(snapshot.groupingMatchesByFile().results == [firstMatch, secondMatch, otherFileMatch]) + } + + @Test("Streaming regrouping preserves the selected match") + func streamingRegroupingPreservesSelectedMatch() throws { + let searchController = SearchControllerSpy() + let coordinator = FileExplorerPanelView.Coordinator( + store: FileExplorerStore(), + state: FileExplorerState(), + onOpenFilePreview: { _ in } + ) + let container = FileExplorerContainerView( + coordinator: coordinator, + presentation: .unified, + searchController: searchController + ) + let firstMatch = FileSearchResult( + path: "/tmp/a.swift", relativePath: "a.swift", lineNumber: 1, columnNumber: 1, preview: "first" + ) + let selectedMatch = FileSearchResult( + path: "/tmp/b.swift", relativePath: "b.swift", lineNumber: 2, columnNumber: 1, preview: "selected" + ) + let insertedMatch = FileSearchResult( + path: "/tmp/a.swift", relativePath: "a.swift", lineNumber: 3, columnNumber: 1, preview: "inserted" + ) + searchController.publish( + FileSearchSnapshot(query: "match", results: [firstMatch, selectedMatch], status: .searching, isSearching: true) + ) + let selectedRow = try #require(container.searchSnapshot.results.firstIndex(of: selectedMatch)) + container.searchResultsView.selectRowIndexes(IndexSet(integer: selectedRow), byExtendingSelection: false) + let menu = try #require(container.searchResultsView.menu) + container.menuNeedsUpdate(menu) + let menuSelection = try #require(menu.items.first?.representedObject as? FileExplorerSearchMenuSelection) + + searchController.publish( + FileSearchSnapshot( + query: "match", + results: [firstMatch, selectedMatch, insertedMatch], + status: .searching, + isSearching: true + ) + ) + + let regroupedSelection = try #require(container.searchResultsView.selectedRowIndexes.first) + #expect(container.searchSnapshot.results[regroupedSelection] == selectedMatch) + #expect(menuSelection.clickedResult == selectedMatch) + #expect(menuSelection.selectedResults == [selectedMatch]) + } + + @Test("Hidden unified search chrome stays collapsed after font changes") + func hiddenUnifiedSearchChromeStaysCollapsedAfterFontChanges() throws { + let coordinator = FileExplorerPanelView.Coordinator( + store: FileExplorerStore(), + state: FileExplorerState(), + onOpenFilePreview: { _ in } + ) + let container = FileExplorerContainerView( + coordinator: coordinator, + presentation: .unified, + searchController: SearchControllerSpy() + ) + container.frame = NSRect(x: 0, y: 0, width: 320, height: 480) + container.updateVisibility(hasContent: false, isLoading: false, statusMessage: nil) + container.layoutSubtreeIfNeeded() + let searchField = try #require(Self.searchField(in: container)) + let searchBar = try #require(searchField.superview) + #expect(searchBar.isHidden) + #expect(searchBar.frame.height == 0) + + NotificationCenter.default.post(name: GlobalFontMagnification.didChangeNotification, object: nil) + container.layoutSubtreeIfNeeded() + + #expect(searchBar.frame.height == 0) + } + + @Test("Unified pane preserves the original Files and Finder chrome") + func unifiedPanePreservesOriginalChrome() throws { + let state = FileExplorerState.unifiedTestState(mode: .files) + let store = FileExplorerStore() + store.rootPath = "/tmp/unified-file-explorer-tests" + let coordinator = FileExplorerPanelView.Coordinator( + store: store, + state: state, + onOpenFilePreview: { _ in } + ) + let container = FileExplorerContainerView( + coordinator: coordinator, + presentation: .unified, + searchController: SearchControllerSpy() + ) + container.frame = NSRect(x: 0, y: 0, width: 320, height: 480) + container.updateHeader(store: store) + container.updateVisibility(hasContent: true, isLoading: false, statusMessage: nil) + container.layoutSubtreeIfNeeded() + + let searchField = try #require(Self.searchField(in: container)) + let searchBar = try #require(searchField.superview) + #expect(searchBar.isHidden) + #expect(searchBar.frame.height == 0) + + state.mode = .find + container.updatePresentation(.unified) + container.updateVisibility(hasContent: true, isLoading: false, statusMessage: nil) + container.layoutSubtreeIfNeeded() + + #expect(!searchBar.isHidden) + #expect(searchBar.frame.height == max(48, GlobalFontMagnification.scaled(48))) + #expect( + searchField.placeholderString == + String(localized: "fileExplorer.search.placeholder", defaultValue: "Search files") + ) + #expect((searchField.cell as? NSSearchFieldCell)?.searchMenuTemplate == nil) + #expect( + !container.subviews + .compactMap { $0 as? NSTextField } + .contains { !$0.isHidden && $0.stringValue == "Type to search" } + ) + } + + @Test("Typing preserves the selected Files or Contents projection") + func typingPreservesSelectedSearchScope() throws { + let state = FileExplorerState.unifiedTestState(mode: .files) + let coordinator = FileExplorerPanelView.Coordinator( + store: FileExplorerStore(), + state: state, + onOpenFilePreview: { _ in } + ) + let container = FileExplorerContainerView( + coordinator: coordinator, + presentation: .unified, + searchController: SearchControllerSpy() + ) + let searchField = try #require(Self.searchField(in: container)) + let outline = try #require(Self.outlineView(in: container)) + + outline.keyDown(with: try Self.keyEvent(characters: "/", keyCode: 44)) + outline.keyDown(with: try Self.keyEvent(characters: "needle", keyCode: 0)) + + #expect(state.mode == .files) + #expect(container.displayedSearchScope == .names) + #expect(container.searchQuery(for: .names) == "needle") + + state.mode = .find + container.updatePresentation(.unified) + searchField.stringValue = "content needle" + container.controlTextDidChange( + Notification(name: NSControl.textDidChangeNotification, object: searchField) + ) + + #expect(state.mode == .find) + #expect(container.displayedSearchScope == .contents) + #expect(container.searchQuery(for: .contents) == "content needle") + #expect(container.searchQuery(for: .names) == "needle") + } + + @Test("Files and Find focus one host without discarding either view's state") + func focusAliasesPreserveTreeAndSearchState() throws { + let state = FileExplorerState.unifiedTestState(mode: .files) + let store = FileExplorerStore() + store.rootPath = "/tmp/unified-file-explorer-tests" + let directory = FileExplorerNode( + name: "Sources", + path: "/tmp/unified-file-explorer-tests/Sources", + isDirectory: true + ) + directory.children = [] + store.rootNodes = [directory] + store.expand(node: directory) + store.select(node: directory) + let searchController = SearchControllerSpy() + let coordinator = FileExplorerPanelView.Coordinator( + store: store, + state: state, + onOpenFilePreview: { _ in } + ) + let container = FileExplorerContainerView( + coordinator: coordinator, + presentation: .unified, + searchController: searchController + ) + container.frame = NSRect(x: 0, y: 0, width: 320, height: 480) + container.updateHeader(store: store) + container.updateVisibility(hasContent: true, isLoading: false, statusMessage: nil) + coordinator.reloadIfNeeded() + container.needsLayout = false + container.updatePresentation(.unified) + #expect(!container.needsLayout) + + let window = NSWindow( + contentRect: container.frame, + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.contentView = container + window.contentView?.layoutSubtreeIfNeeded() + defer { + _ = window.makeFirstResponder(nil) + window.contentView = nil + window.orderOut(nil) + } + + let focusController = MainWindowFocusController( + windowId: UUID(), + window: window, + tabManager: TabManager(), + fileExplorerState: state + ) + focusController.registerFileExplorerHost(container) + + #expect(focusController.focusRightSidebar(mode: .find, focusFirstItem: true)) + let searchField = try #require(Self.searchField(in: container)) + let searchResponder = try #require(window.firstResponder) + #expect(state.mode == .find) + #expect(focusController.activeRightSidebarMode == .find) + #expect(container.ownsKeyboardFocus(searchResponder)) + + #expect(focusController.toggleRightSidebarOrTerminalFocus(mode: .files)) + #expect(window.firstResponder is NSOutlineView) + #expect(state.mode == .files) + #expect(container.displayedSearchScope == .names) + #expect(focusController.focusRightSidebar(mode: .find, focusFirstItem: true)) + + searchField.stringValue = "needle" + #expect(container.focusSearchField()) + let result = FileSearchResult( + path: "/tmp/unified-file-explorer-tests/file.swift", + relativePath: "file.swift", + lineNumber: 7, + columnNumber: 3, + preview: "let needle = true" + ) + let snapshot = FileSearchSnapshot( + query: "needle", + results: [result], + status: .matches, + isSearching: false + ) + searchController.publish(snapshot) + + #expect(focusController.focusRightSidebar(mode: .files, focusFirstItem: true)) + #expect(window.firstResponder is NSOutlineView) + #expect(state.mode == .files) + #expect(focusController.activeRightSidebarMode == .files) + #expect(searchField.stringValue == "needle") + #expect(container.searchQuery(for: .contents) == "needle") + #expect(container.searchSnapshot == snapshot) + #expect(store.expandedPaths == [directory.path]) + #expect(store.selectedPath == directory.path) + + searchController.emitsEmptySnapshotOnSearch = true + let searchCountBeforeFindActivation = searchController.searchRequests.count + let outlineResponder = window.firstResponder + state.mode = .find + focusController.rememberRightSidebarMode(.find) + container.updatePresentation(.unified) + #expect(container.searchResultsView.isHiddenOrHasHiddenAncestor) + #expect(searchController.searchRequests.count == searchCountBeforeFindActivation) + #expect(container.searchSnapshot == snapshot) + #expect(window.firstResponder === outlineResponder) + try #require(outlineResponder as? FileExplorerNSOutlineView).keyDown(with: try Self.keyEvent(characters: "/", keyCode: 44)) + #expect(state.mode == .files) + state.mode = .find + searchController.emitsEmptySnapshotOnSearch = false + + _ = window.makeFirstResponder(nil) + container.updatePresentation(.unified) + #expect(!container.searchResultsView.isHiddenOrHasHiddenAncestor) + #expect(window.makeFirstResponder(container.searchResultsView)) + let searchResultsResponder = window.firstResponder + let cancelCountBeforeFilesActivation = searchController.cancelRequests.count + state.mode = .files + focusController.rememberRightSidebarMode(.files) + container.updatePresentation(.unified) + #expect(!container.searchResultsView.isHiddenOrHasHiddenAncestor) + #expect(window.firstResponder === searchResultsResponder) + #expect(searchController.cancelRequests.count == cancelCountBeforeFilesActivation) + + _ = window.makeFirstResponder(nil) + container.updatePresentation(.unified) + #expect(container.searchResultsView.isHiddenOrHasHiddenAncestor) + #expect(searchController.cancelRequests.count == cancelCountBeforeFilesActivation + 1) + #expect(searchController.cancelRequests.last == false) + #expect(focusController.focusRightSidebar(mode: nil, focusFirstItem: true)) + #expect(window.firstResponder is NSOutlineView) + #expect(state.mode == .files) + + let searchCountBeforeHiddenRevision = searchController.searchRequests.count + store.reload() + container.updateHeader(store: store) + #expect(searchController.searchRequests.count == searchCountBeforeHiddenRevision) + #expect(focusController.focusRightSidebar(mode: .find, focusFirstItem: true)) + #expect(searchController.searchRequests.count == searchCountBeforeHiddenRevision + 1) + #expect(searchController.searchRequests.last?.contentRevision == store.contentRevision) + + #expect(focusController.focusRightSidebar(mode: .find, focusFirstItem: true)) + #expect(state.mode == .find) + #expect(focusController.activeRightSidebarMode == .find) + #expect(searchField.stringValue == "needle") + #expect(container.searchQuery(for: .names).isEmpty) + #expect(container.searchSnapshot == snapshot) + #expect(store.expandedPaths == [directory.path]) + #expect(store.selectedPath == directory.path) + let restoredSearchResponder = try #require(window.firstResponder) + #expect(container.ownsKeyboardFocus(restoredSearchResponder)) + + state.mode = .files + focusController.noteRightSidebarInteraction(mode: .find) + #expect(state.mode == .find) + +#if DEBUG + let outlineView = try #require(Self.outlineView(in: container)) + focusController.debugSyncAfterResponderChange(responder: outlineView) + #expect(state.mode == .files) +#endif + } + + @Test("Switching to Find cancels hidden filename filter work") + func switchingToFindCancelsFilenameFilter() throws { + let state = FileExplorerState.unifiedTestState(mode: .files) + let store = FileExplorerStore() + store.rootPath = "/repo" + store.rootNodes = (0...2_048).map { index in + FileExplorerNode( + name: index == 0 ? "Needle.swift" : "File\(index).swift", + path: "/repo/File\(index).swift", + isDirectory: false + ) + } + let coordinator = FileExplorerPanelView.Coordinator( + store: store, + state: state, + onOpenFilePreview: { _ in } + ) + let container = FileExplorerContainerView( + coordinator: coordinator, + presentation: .unified, + searchController: SearchControllerSpy() + ) + container.frame = NSRect(x: 0, y: 0, width: 320, height: 480) + container.updateHeader(store: store) + container.updateVisibility(hasContent: true, isLoading: false, statusMessage: nil) + coordinator.reloadIfNeeded() + let window = NSWindow(contentRect: container.frame, styleMask: [.titled], backing: .buffered, defer: false) + window.contentView = container + defer { window.contentView = nil } + let outline = try #require(Self.outlineView(in: container)) + var actionRan = false + + coordinator.setFileFilterQuery("needle", in: outline, afterApplying: { actionRan = true }) + #expect(coordinator.fileFilterTask != nil) + #expect(container.focusSearchField()) + + #expect(coordinator.fileFilterTask == nil) + #expect(!actionRan) + } + + private static func searchField(in root: NSView) -> NSSearchField? { + if let field = root as? NSSearchField, + field.accessibilityIdentifier() == "FileExplorerSearchField" { + return field + } + for subview in root.subviews { + if let field = searchField(in: subview) { return field } + } + return nil + } + + private static func outlineView(in root: NSView) -> FileExplorerNSOutlineView? { + if let outlineView = root as? FileExplorerNSOutlineView { return outlineView } + for subview in root.subviews { + if let outlineView = outlineView(in: subview) { return outlineView } + } + return nil + } + + private static func keyEvent(characters: String, keyCode: UInt16) throws -> NSEvent { + try #require(NSEvent.keyEvent( + with: .keyDown, location: .zero, modifierFlags: [], timestamp: 0, + windowNumber: 0, context: nil, characters: characters, + charactersIgnoringModifiers: characters, isARepeat: false, keyCode: keyCode + )) + } + +} + +@MainActor +private final class SearchControllerSpy: FileSearchControlling { + struct SearchRequest: Equatable { + let query: String + let rootPath: String + let contentRevision: Int + } + + var onSnapshotChanged: ((FileSearchSnapshot) -> Void)? + private(set) var searchRequests: [SearchRequest] = [] + private(set) var cancelRequests: [Bool] = [] + var emitsEmptySnapshotOnSearch = false + + func search(query rawQuery: String, rootPath: String, isLocal: Bool, contentRevision: Int) { + searchRequests.append( + SearchRequest(query: rawQuery, rootPath: rootPath, contentRevision: contentRevision) + ) + if emitsEmptySnapshotOnSearch { + publish( + FileSearchSnapshot( + query: rawQuery, + results: [], + status: .searching, + isSearching: true + ) + ) + } + } + func cancel(clear: Bool) { cancelRequests.append(clear) } + func publish(_ snapshot: FileSearchSnapshot) { onSnapshotChanged?(snapshot) } +}