diff --git a/Sources/FileExplorerSearchController.swift b/Sources/FileExplorerSearchController.swift index 169a6434451e..cab581c7b5c3 100644 --- a/Sources/FileExplorerSearchController.swift +++ b/Sources/FileExplorerSearchController.swift @@ -1,6 +1,7 @@ +import Darwin import Foundation -struct FileSearchResult: Equatable { +struct FileSearchResult: Equatable, Sendable { let path: String let relativePath: String let lineNumber: Int @@ -46,8 +47,8 @@ enum FileSearchRipgrepParser { } } -struct FileSearchSnapshot: Equatable { - enum Status: Equatable { +struct FileSearchSnapshot: Equatable, Sendable { + enum Status: Equatable, Sendable { case idle case unsupported case searching @@ -66,7 +67,153 @@ struct FileSearchSnapshot: Equatable { } @MainActor -final class FileSearchController { +protocol FileSearchControlling: AnyObject { + var onSnapshotChanged: ((FileSearchSnapshot) -> Void)? { get set } + + func search(query rawQuery: String, rootPath: String, isLocal: Bool, contentRevision: Int) + func cancel(clear: Bool) +} + +private struct FileSearchPipelineUpdate: Sendable { + let results: [FileSearchResult] + let status: FileSearchSnapshot.Status + let isSearching: Bool + let shouldStopProcess: Bool +} + +private actor FileSearchTerminationSignal { + private var status: Int32? + private var continuations: [UUID: CheckedContinuation] = [:] + private var cancelledWaits = Set() + + func complete(status: Int32) { + guard self.status == nil else { return } + self.status = status + let pendingContinuations = Array(continuations.values) + continuations.removeAll() + cancelledWaits.removeAll() + for continuation in pendingContinuations { + continuation.resume(returning: status) + } + } + + func wait() async -> Int32? { + if let status { + return status + } + let waitID = UUID() + return await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + if let status { + continuation.resume(returning: status) + } else if cancelledWaits.remove(waitID) != nil { + continuation.resume(returning: nil) + } else { + continuations[waitID] = continuation + } + } + } onCancel: { + Task { + await self.cancelWait(id: waitID) + } + } + } + + private func cancelWait(id: UUID) { + guard status == nil else { return } + if let continuation = continuations.removeValue(forKey: id) { + continuation.resume(returning: nil) + } else { + cancelledWaits.insert(id) + } + } +} + +private actor FileSearchOutputPipeline { + private let rootPath: String + private let maxResults: Int + private let snapshotInterval: TimeInterval + private var stderrBuffer = Data() + private var results: [FileSearchResult] = [] + private var lastSnapshotEmissionDate = Date.distantPast + private var isFinished = false + + init(rootPath: String, maxResults: Int, snapshotInterval: TimeInterval) { + self.rootPath = rootPath + self.maxResults = maxResults + self.snapshotInterval = snapshotInterval + } + + func consumeStdoutLine(_ line: String) -> FileSearchPipelineUpdate? { + guard !isFinished, + let result = FileSearchRipgrepParser.parseMatchLine(line, rootPath: rootPath) else { + return nil + } + results.append(result) + if results.count >= maxResults { + return FileSearchPipelineUpdate( + results: results, + status: .limited(maxResults), + isSearching: false, + shouldStopProcess: true + ) + } + + let now = Date() + guard now.timeIntervalSince(lastSnapshotEmissionDate) >= snapshotInterval else { + return nil + } + lastSnapshotEmissionDate = now + return FileSearchPipelineUpdate( + results: results, + status: .searching, + isSearching: true, + shouldStopProcess: false + ) + } + + func consumeStderr(_ data: Data) { + guard !isFinished else { return } + stderrBuffer.append(data) + if stderrBuffer.count > 8_192 { + stderrBuffer.removeSubrange(0..<(stderrBuffer.count - 8_192)) + } + } + + func consumeStderrLine(_ line: String) { + guard !isFinished else { return } + let lineData = Data((line + "\n").utf8) + consumeStderr(lineData) + } + + func finish(status: Int32) -> FileSearchPipelineUpdate { + isFinished = true + if status == 0 || status == 1 { + return FileSearchPipelineUpdate( + results: results, + status: results.isEmpty ? .noMatches : .matches, + isSearching: false, + shouldStopProcess: false + ) + } + + let errorText = String(data: stderrBuffer, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let fallback = String( + format: String(localized: "fileExplorer.search.rgExited", defaultValue: "rg exited with status %d"), + Int(status) + ) + return FileSearchPipelineUpdate( + results: results, + status: .failed(errorText?.isEmpty == false ? errorText! : fallback), + isSearching: false, + shouldStopProcess: false + ) + } +} + +@MainActor +final class FileSearchController: FileSearchControlling { private struct Request: Equatable { let query: String let rootPath: String @@ -82,6 +229,7 @@ final class FileSearchController { var onSnapshotChanged: ((FileSearchSnapshot) -> Void)? private let maxResults = 500 + private let snapshotInterval: TimeInterval = 0.05 private let excludedSearchGlobs = [ "!.git/**", "!**/.git/**", @@ -95,11 +243,11 @@ final class FileSearchController { "!**/DerivedData/**", ] private var process: Process? - private var stdoutBuffer = Data() - private var stderrBuffer = Data() private var generation = 0 private var request: Request? 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) @@ -116,8 +264,6 @@ final class FileSearchController { stopAndAdvanceGeneration() results.removeAll() - stdoutBuffer.removeAll(keepingCapacity: true) - stderrBuffer.removeAll(keepingCapacity: true) guard !query.isEmpty else { emit(status: .idle, isSearching: false) @@ -165,34 +311,47 @@ final class FileSearchController { let stderr = Pipe() process.standardOutput = stdout process.standardError = stderr + let pipeline = FileSearchOutputPipeline( + rootPath: rootPath, + maxResults: maxResults, + snapshotInterval: snapshotInterval + ) + self.pipeline = pipeline + let terminationSignal = FileSearchTerminationSignal() - stdout.fileHandleForReading.readabilityHandler = { [weak self] handle in - let data = handle.availableData - guard !data.isEmpty else { return } - Task { @MainActor [weak self] in - self?.consumeStdout(data, generation: searchGeneration, rootPath: rootPath) - } - } - stderr.fileHandleForReading.readabilityHandler = { [weak self] handle in - let data = handle.availableData - guard !data.isEmpty else { return } - Task { @MainActor [weak self] in - self?.consumeStderr(data, generation: searchGeneration) - } - } - - process.terminationHandler = { [weak self] process in - Task { @MainActor [weak self] in - self?.finish(generation: searchGeneration, status: process.terminationStatus) + process.terminationHandler = { process in + let status = process.terminationStatus + Task { + await terminationSignal.complete(status: status) } } do { try process.run() self.process = process + let stdoutHandle = stdout.fileHandleForReading + let stderrHandle = stderr.fileHandleForReading + searchTask = Task { [weak self, pipeline, terminationSignal] in + let applyUpdate: @Sendable (FileSearchPipelineUpdate, Int) async -> Void = { [weak self] update, generation in + await self?.applyPipelineUpdate(update, generation: generation) + } + async let stdoutDone: Void = Self.streamStdout( + from: stdoutHandle, + pipeline: pipeline, + generation: searchGeneration, + applyUpdate: applyUpdate + ) + async let stderrDone: Void = Self.streamStderr(from: stderrHandle, pipeline: pipeline) + guard let status = await terminationSignal.wait() else { return } + _ = await (stdoutDone, stderrDone) + guard !Task.isCancelled else { return } + let update = await pipeline.finish(status: status) + self?.finish(generation: searchGeneration, update: update) + } } catch { process.standardOutput = nil process.standardError = nil + self.pipeline = nil emit(status: .failed(error.localizedDescription), isSearching: false) } } @@ -200,64 +359,28 @@ final class FileSearchController { func cancel(clear: Bool) { request = nil stopAndAdvanceGeneration() - stdoutBuffer.removeAll(keepingCapacity: true) - stderrBuffer.removeAll(keepingCapacity: true) if clear { results.removeAll() emit(status: .idle, isSearching: false) } } - private func consumeStdout(_ data: Data, generation searchGeneration: Int, rootPath: String) { + private func applyPipelineUpdate(_ update: FileSearchPipelineUpdate, generation searchGeneration: Int) { guard searchGeneration == generation else { return } - stdoutBuffer.append(data) - var didAppendResult = false - - while let newlineIndex = stdoutBuffer.firstIndex(of: 10) { - let lineData = stdoutBuffer[..= maxResults { - stopAndAdvanceGeneration() - emit(status: .limited(maxResults), isSearching: false) - return - } - } - if didAppendResult { - emit(status: .searching, isSearching: true) - } - } - - private func consumeStderr(_ data: Data, generation searchGeneration: Int) { - guard searchGeneration == generation else { return } - stderrBuffer.append(data) - if stderrBuffer.count > 8_192 { - stderrBuffer.removeSubrange(0..<(stderrBuffer.count - 8_192)) + results = update.results + if update.shouldStopProcess { + stopAndAdvanceGeneration() } + emit(status: update.status, isSearching: update.isSearching) } - private func finish(generation searchGeneration: Int, status: Int32) { + private func finish(generation searchGeneration: Int, update: FileSearchPipelineUpdate) { guard searchGeneration == generation else { return } - stopCurrentProcess() - - if status == 0 || status == 1 { - let finalStatus: FileSearchSnapshot.Status = results.isEmpty ? .noMatches : .matches - emit(status: finalStatus, isSearching: false) - return - } - - let errorText = String(data: stderrBuffer, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines) - let fallback = String( - format: String(localized: "fileExplorer.search.rgExited", defaultValue: "rg exited with status %d"), - Int(status) - ) - emit(status: .failed(errorText?.isEmpty == false ? errorText! : fallback), isSearching: false) + process = nil + pipeline = nil + searchTask = nil + results = update.results + emit(status: update.status, isSearching: update.isSearching) } private func emit(status: FileSearchSnapshot.Status, isSearching: Bool) { @@ -277,11 +400,55 @@ final class FileSearchController { private func stopCurrentProcess() { guard let process else { return } self.process = nil - (process.standardOutput as? Pipe)?.fileHandleForReading.readabilityHandler = nil - (process.standardError as? Pipe)?.fileHandleForReading.readabilityHandler = nil - process.terminationHandler = nil + searchTask?.cancel() + searchTask = nil + pipeline = nil if process.isRunning { - process.terminate() + _ = Darwin.kill(process.processIdentifier, SIGTERM) + } + } + +#if compiler(>=6.2) + @concurrent +#endif + private nonisolated static func streamStdout( + from handle: FileHandle, + pipeline: FileSearchOutputPipeline, + generation: Int, + applyUpdate: @Sendable (FileSearchPipelineUpdate, Int) async -> Void + ) async { + do { + for try await line in handle.bytes.lines { + try Task.checkCancellation() + guard let update = await pipeline.consumeStdoutLine(line) else { continue } + await applyUpdate(update, generation) + if update.shouldStopProcess { + return + } + } + } catch is CancellationError { + return + } catch { + await pipeline.consumeStderrLine(error.localizedDescription) + } + } + +#if compiler(>=6.2) + @concurrent +#endif + private nonisolated static func streamStderr( + from handle: FileHandle, + pipeline: FileSearchOutputPipeline + ) async { + do { + for try await line in handle.bytes.lines { + try Task.checkCancellation() + await pipeline.consumeStderrLine(line) + } + } catch is CancellationError { + return + } catch { + await pipeline.consumeStderrLine(error.localizedDescription) } } diff --git a/Sources/FileExplorerView.swift b/Sources/FileExplorerView.swift index 9483b2c43ea9..7884cb893f5c 100644 --- a/Sources/FileExplorerView.swift +++ b/Sources/FileExplorerView.swift @@ -100,14 +100,16 @@ struct FileExplorerPanelView: NSViewRepresentable { observationCancellable = store.objectWillChange .debounce(for: .milliseconds(50), scheduler: RunLoop.main) .sink { [weak self] _ in - self?.reloadIfNeeded() + Task { @MainActor [weak self] in + self?.reloadIfNeeded() + } } } + @MainActor func reloadIfNeeded() { guard let outlineView else { return } - // Update empty state vs tree visibility containerView?.updateVisibility(hasContent: !store.rootPath.isEmpty, isLoading: store.isRootLoading) let newCount = store.rootNodes.count @@ -575,6 +577,7 @@ struct FileExplorerPanelView: NSViewRepresentable { // MARK: - Container View (all-AppKit) /// Pure AppKit container holding the header bar and outline view. +@MainActor final class FileExplorerContainerView: NSView { private let headerView: FileExplorerHeaderView private let searchBarView: NSView @@ -586,17 +589,40 @@ final class FileExplorerContainerView: NSView { let searchResultsView: FileExplorerSearchResultsTableView private let emptyLabel: NSTextField private let loadingIndicator: NSProgressIndicator - private let searchController: FileSearchController + private let searchController: any FileSearchControlling private var searchBarHeightConstraint: NSLayoutConstraint! private(set) var searchSnapshot = FileSearchSnapshot.empty private var currentRootPath = "" private var currentProviderIsLocal = false private var currentContentRevision = 0 - private var isSearchVisible = false + private let searchDebounceSubject = PassthroughSubject() + private var searchDebounceCancellable: AnyCancellable? + private var searchDebounceGeneration = 0 + private var isSearchVisible = false { + didSet { + if !isSearchVisible { + cancelPendingSearchRefresh() + } + } + } private var presentation: FileExplorerPanelPresentation private let coordinator: FileExplorerPanelView.Coordinator + private let searchDebounceDelayMilliseconds = 200 + private let searchBarVisibleHeight: CGFloat = 48 - init(coordinator: FileExplorerPanelView.Coordinator, presentation: FileExplorerPanelPresentation) { +#if DEBUG + private var debugLastSearchTextChangeUptime: TimeInterval = 0 + private var debugLastSearchLayoutFieldWidth: CGFloat = -1 + private var debugLastSearchLayoutStatusWidth: CGFloat = -1 + private var debugLastLoggedSearchResultCount = -1 + private var debugLastLoggedSearchStatus = "" +#endif + + init( + coordinator: FileExplorerPanelView.Coordinator, + presentation: FileExplorerPanelPresentation, + searchController: (any FileSearchControlling)? = nil + ) { headerView = FileExplorerHeaderView() searchBarView = NSView() searchField = FileExplorerSearchField() @@ -607,11 +633,12 @@ final class FileExplorerContainerView: NSView { searchResultsView = FileExplorerSearchResultsTableView() emptyLabel = NSTextField(labelWithString: String(localized: "fileExplorer.empty", defaultValue: "No folder open")) loadingIndicator = NSProgressIndicator() - searchController = FileSearchController() + self.searchController = searchController ?? FileSearchController() self.presentation = presentation self.coordinator = coordinator super.init(frame: .zero) + configureSearchDebounce() // Header headerView.translatesAutoresizingMaskIntoConstraints = false @@ -627,6 +654,11 @@ final class FileExplorerContainerView: NSView { searchField.placeholderString = String(localized: "fileExplorer.search.placeholder", defaultValue: "Search files") searchField.font = .systemFont(ofSize: 12, weight: .regular) 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() @@ -652,6 +684,8 @@ final class FileExplorerContainerView: NSView { 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) @@ -760,7 +794,7 @@ final class FileExplorerContainerView: NSView { searchScrollView.isHidden = true addSubview(searchScrollView) - searchController.onSnapshotChanged = { [weak self] snapshot in + self.searchController.onSnapshotChanged = { [weak self] snapshot in self?.applySearchSnapshot(snapshot) } @@ -776,13 +810,14 @@ final class FileExplorerContainerView: NSView { searchBarHeightConstraint, searchField.leadingAnchor.constraint(equalTo: searchBarView.leadingAnchor, constant: 8), - searchField.centerYAnchor.constraint(equalTo: searchBarView.centerYAnchor), + searchField.trailingAnchor.constraint(equalTo: searchBarView.trailingAnchor, constant: -8), + searchField.topAnchor.constraint(equalTo: searchBarView.topAnchor, constant: 4), searchField.heightAnchor.constraint(equalToConstant: 24), + searchField.widthAnchor.constraint(greaterThanOrEqualToConstant: 120), - searchStatusLabel.leadingAnchor.constraint(equalTo: searchField.trailingAnchor, constant: 8), - searchStatusLabel.trailingAnchor.constraint(equalTo: searchBarView.trailingAnchor, constant: -8), - searchStatusLabel.centerYAnchor.constraint(equalTo: searchField.centerYAnchor), - searchStatusLabel.widthAnchor.constraint(lessThanOrEqualToConstant: 140), + 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), @@ -808,6 +843,7 @@ final class FileExplorerContainerView: NSView { override func viewWillMove(toWindow newWindow: NSWindow?) { if newWindow == nil { + cancelPendingSearchRefresh() searchController.cancel(clear: false) } super.viewWillMove(toWindow: newWindow) @@ -832,8 +868,14 @@ final class FileExplorerContainerView: NSView { } override func layout() { +#if DEBUG + let debugLayoutStart = ProcessInfo.processInfo.systemUptime +#endif super.layout() registerWithKeyboardFocusCoordinatorIfNeeded() +#if DEBUG + logSearchLayoutIfNeeded(startedAt: debugLayoutStart, reason: "layout") +#endif } func updateHeader(store: FileExplorerStore) { @@ -979,6 +1021,15 @@ final class FileExplorerContainerView: NSView { private func refreshSearchIfNeeded() { guard isSearchVisible else { return } + cancelPendingSearchRefresh() +#if DEBUG + dlog( + "file.search.request queryLen=\(searchField.stringValue.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, rootPath: currentRootPath, @@ -987,22 +1038,72 @@ final class FileExplorerContainerView: NSView { ) } + private func configureSearchDebounce() { + searchDebounceCancellable = searchDebounceSubject + .debounce(for: .milliseconds(searchDebounceDelayMilliseconds), scheduler: RunLoop.main) + .sink { [weak self] debounceGeneration in + Task { @MainActor [weak self] in + guard let self, + self.isSearchVisible, + self.searchDebounceGeneration == debounceGeneration else { return } +#if DEBUG + dlog( + "file.search.debounce.fire queryLen=\(self.searchField.stringValue.count) " + + "delayMs=\(self.searchDebounceDelayMilliseconds)" + ) +#endif + self.refreshSearchIfNeeded() + } + } + } + + private func scheduleSearchRefresh() { + guard isSearchVisible else { return } + searchDebounceGeneration += 1 + let debounceGeneration = searchDebounceGeneration +#if DEBUG + dlog( + "file.search.debounce.schedule queryLen=\(searchField.stringValue.count) " + + "delayMs=\(searchDebounceDelayMilliseconds)" + ) +#endif + searchDebounceSubject.send(debounceGeneration) + } + + private func cancelPendingSearchRefresh() { + searchDebounceGeneration += 1 + } + private func updateSearchLayout(hasContent: Bool? = nil, isLoading: Bool? = nil) { let effectiveHasContent = hasContent ?? !currentRootPath.isEmpty let effectiveIsLoading = isLoading ?? false let showSearch = isSearchVisible && effectiveHasContent && !effectiveIsLoading searchBarView.isHidden = !showSearch - searchBarHeightConstraint.constant = showSearch ? RightSidebarChromeMetrics.secondaryBarHeight : 0 + searchBarHeightConstraint.constant = showSearch ? searchBarVisibleHeight : 0 searchScrollView.isHidden = !showSearch scrollView.isHidden = showSearch || !effectiveHasContent || effectiveIsLoading needsLayout = true } private func applySearchSnapshot(_ snapshot: FileSearchSnapshot) { +#if DEBUG + let debugApplyStart = ProcessInfo.processInfo.systemUptime + let previousStatusName = debugSearchStatusName(searchSnapshot.status) + let previousStatusTextLength = searchStatusLabel.stringValue.count +#endif let previousSelectedRow = searchResultsView.selectedRow + let previousResults = searchSnapshot.results searchSnapshot = snapshot searchStatusLabel.stringValue = statusText(for: snapshot) - searchResultsView.reloadData() + applySearchResultsUpdate(previousResults: previousResults, nextResults: snapshot.results) +#if DEBUG + logSearchSnapshot( + snapshot, + startedAt: debugApplyStart, + previousStatusName: previousStatusName, + previousStatusTextLength: previousStatusTextLength + ) +#endif guard !snapshot.results.isEmpty else { return } let selectedRow = previousSelectedRow >= 0 @@ -1011,6 +1112,31 @@ final class FileExplorerContainerView: NSView { searchResultsView.selectRowIndexes(IndexSet(integer: selectedRow), byExtendingSelection: false) } + private func applySearchResultsUpdate(previousResults: [FileSearchResult], nextResults: [FileSearchResult]) { + if previousResults == nextResults { + return + } + + if nextResults.count > previousResults.count && + nextResults.starts(with: previousResults) { + let insertedRange = previousResults.count.. String { switch snapshot.status { case .idle: @@ -1042,9 +1168,102 @@ final class FileExplorerContainerView: NSView { } } +#if DEBUG + private func debugSearchNumber(_ value: CGFloat) -> String { + String(format: "%.1f", Double(value)) + } + + private func debugSearchNumber(_ value: Double) -> String { + String(format: "%.1f", value) + } + + private func debugSearchStatusName(_ status: FileSearchSnapshot.Status) -> String { + switch status { + case .idle: + return "idle" + case .unsupported: + return "unsupported" + case .searching: + return "searching" + case .noMatches: + return "noMatches" + case .matches: + return "matches" + case .limited(let limit): + return "limited(\(limit))" + case .failed: + return "failed" + } + } + + private func logSearchLayoutIfNeeded(startedAt: TimeInterval, reason: String) { + guard isSearchVisible else { return } + let now = ProcessInfo.processInfo.systemUptime + let fieldWidth = searchField.frame.width + let statusWidth = searchStatusLabel.frame.width + let firstLayout = debugLastSearchLayoutFieldWidth < 0 || debugLastSearchLayoutStatusWidth < 0 + let fieldDelta = debugLastSearchLayoutFieldWidth >= 0 + ? abs(fieldWidth - debugLastSearchLayoutFieldWidth) + : 0 + let statusDelta = debugLastSearchLayoutStatusWidth >= 0 + ? abs(statusWidth - debugLastSearchLayoutStatusWidth) + : 0 + let layoutMs = max(0, (now - startedAt) * 1000) + let widthChanged = fieldDelta > 0.5 || statusDelta > 0.5 + let slowLayout = layoutMs >= 8 + guard firstLayout || widthChanged || slowLayout else { return } + + debugLastSearchLayoutFieldWidth = fieldWidth + debugLastSearchLayoutStatusWidth = statusWidth + let sinceKeyMs = debugLastSearchTextChangeUptime > 0 + ? debugSearchNumber((now - debugLastSearchTextChangeUptime) * 1000) + : "n/a" + dlog( + "file.search.layout reason=\(reason) fieldW=\(debugSearchNumber(fieldWidth)) " + + "statusW=\(debugSearchNumber(statusWidth)) fieldDelta=\(debugSearchNumber(fieldDelta)) " + + "statusDelta=\(debugSearchNumber(statusDelta)) layoutMs=\(debugSearchNumber(layoutMs)) " + + "sinceKeyMs=\(sinceKeyMs) queryLen=\(searchField.stringValue.count) " + + "results=\(searchSnapshot.results.count) status=\(debugSearchStatusName(searchSnapshot.status))" + ) + } + + private func logSearchSnapshot( + _ snapshot: FileSearchSnapshot, + startedAt: TimeInterval, + previousStatusName: String, + previousStatusTextLength: Int + ) { + let now = ProcessInfo.processInfo.systemUptime + let applyMs = max(0, (now - startedAt) * 1000) + let statusName = debugSearchStatusName(snapshot.status) + let resultDelta = debugLastLoggedSearchResultCount >= 0 + ? abs(snapshot.results.count - debugLastLoggedSearchResultCount) + : snapshot.results.count + let shouldLog = statusName != debugLastLoggedSearchStatus || + resultDelta >= 50 || + applyMs >= 4 + guard shouldLog else { return } + + debugLastLoggedSearchStatus = statusName + debugLastLoggedSearchResultCount = snapshot.results.count + let sinceKeyMs = debugLastSearchTextChangeUptime > 0 + ? debugSearchNumber((now - debugLastSearchTextChangeUptime) * 1000) + : "n/a" + dlog( + "file.search.snapshot status=\(statusName) previousStatus=\(previousStatusName) " + + "results=\(snapshot.results.count) isSearching=\(snapshot.isSearching ? 1 : 0) " + + "applyMs=\(debugSearchNumber(applyMs)) sinceKeyMs=\(sinceKeyMs) " + + "fieldW=\(debugSearchNumber(searchField.frame.width)) statusW=\(debugSearchNumber(searchStatusLabel.frame.width)) " + + "statusIntrinsicW=\(debugSearchNumber(searchStatusLabel.intrinsicContentSize.width)) " + + "statusTextLen=\(searchStatusLabel.stringValue.count) previousStatusTextLen=\(previousStatusTextLength)" + ) + } +#endif + private func closeSearchAndFocusOutline() { if presentation == .find { let hadQuery = !searchField.stringValue.isEmpty + cancelPendingSearchRefresh() searchController.cancel(clear: true) searchField.stringValue = "" applySearchSnapshot(.empty) @@ -1132,7 +1351,33 @@ final class FileExplorerContainerView: NSView { extension FileExplorerContainerView: NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate, NSMenuDelegate { func controlTextDidChange(_ notification: Notification) { guard notification.object as? NSTextField === searchField else { return } - refreshSearchIfNeeded() + 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 { diff --git a/cmuxTests/FileExplorerStoreTests.swift b/cmuxTests/FileExplorerStoreTests.swift index f282b7ccaca6..0636b478c6ba 100644 --- a/cmuxTests/FileExplorerStoreTests.swift +++ b/cmuxTests/FileExplorerStoreTests.swift @@ -449,6 +449,43 @@ final class FileSearchControllerTests: XCTestCase { XCTAssertEqual(refreshedSnapshot.results.map(\.relativePath), ["editable.txt"]) } + func testTypingBurstDebouncesFindSearches() async throws { + let store = FileExplorerStore() + let state = FileExplorerState() + let searchController = SpyFileSearchController() + let coordinator = FileExplorerPanelView.Coordinator( + store: store, + state: state, + onOpenFilePreview: { _ in } + ) + let container = FileExplorerContainerView( + coordinator: coordinator, + presentation: .find, + searchController: searchController + ) + store.provider = MockFileExplorerProvider(homePath: "/tmp") + store.setRootPath("/tmp/cmux-find-debounce-test") + container.updateHeader(store: store) + container.updatePresentation(.find) + + let searchField = try XCTUnwrap(Self.findSearchField(in: container)) + searchController.searchRequests.removeAll() + + for query in ["p", "pr", "pri", "priv", "priva", "privat", "private"] { + searchField.stringValue = query + container.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: searchField)) + } + + try await Task.sleep(nanoseconds: 300_000_000) + + XCTAssertLessThanOrEqual( + searchController.searchRequests.count, + 1, + "A burst of typing should coalesce into one ripgrep search per debounce window." + ) + XCTAssertEqual(searchController.searchRequests.last?.query, "private") + } + private func waitForSettledSearchSnapshot( timeout: TimeInterval = 5, _ snapshot: @MainActor @escaping () -> FileSearchSnapshot? @@ -478,4 +515,43 @@ final class FileSearchControllerTests: XCTestCase { } return false } + + private static func findSearchField(in root: NSView) -> NSSearchField? { + if let field = root as? NSSearchField, + field.accessibilityIdentifier() == "FileExplorerSearchField" { + return field + } + for subview in root.subviews { + if let field = findSearchField(in: subview) { + return field + } + } + return nil + } + + private final class SpyFileSearchController: FileSearchControlling { + struct SearchRequest: Equatable { + let query: String + let rootPath: String + let isLocal: Bool + let contentRevision: Int + } + + var onSnapshotChanged: ((FileSearchSnapshot) -> Void)? + var searchRequests: [SearchRequest] = [] + var cancelCount = 0 + + func search(query rawQuery: String, rootPath: String, isLocal: Bool, contentRevision: Int) { + searchRequests.append(SearchRequest( + query: rawQuery, + rootPath: rootPath, + isLocal: isLocal, + contentRevision: contentRevision + )) + } + + func cancel(clear: Bool) { + cancelCount += 1 + } + } }