From 731bb83641115bdff319ae974b1c84a0ec2bbe47 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 8 May 2026 12:46:32 -0700 Subject: [PATCH 01/13] Expose debounce regression for sidebar find The Find search field had no injectable boundary, so the regression test could not count backend searches without launching ripgrep. This adds the smallest controller seam and a test that types a burst through controlTextDidChange and expects one backend search after the debounce window. Constraint: Regression test must exercise observable input behavior instead of grepping source text Confidence: high Scope-risk: narrow Directive: Keep the spy-backed input test on the AppKit delegate path so future changes preserve typing debounce semantics Tested: Baseline UI repro and sample captured at /tmp/cmux-3738-baseline-sample.txt Not-tested: Local XCTest run intentionally skipped per instruction --- Sources/FileExplorerSearchController.swift | 10 ++- Sources/FileExplorerView.swift | 10 ++- cmuxTests/FileExplorerStoreTests.swift | 76 ++++++++++++++++++++++ 3 files changed, 92 insertions(+), 4 deletions(-) diff --git a/Sources/FileExplorerSearchController.swift b/Sources/FileExplorerSearchController.swift index 169a6434451e..3db106555eaf 100644 --- a/Sources/FileExplorerSearchController.swift +++ b/Sources/FileExplorerSearchController.swift @@ -66,7 +66,15 @@ 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) +} + +@MainActor +final class FileSearchController: FileSearchControlling { private struct Request: Equatable { let query: String let rootPath: String diff --git a/Sources/FileExplorerView.swift b/Sources/FileExplorerView.swift index 9483b2c43ea9..556c7e2ea30e 100644 --- a/Sources/FileExplorerView.swift +++ b/Sources/FileExplorerView.swift @@ -586,7 +586,7 @@ 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 = "" @@ -596,7 +596,11 @@ final class FileExplorerContainerView: NSView { private var presentation: FileExplorerPanelPresentation private let coordinator: FileExplorerPanelView.Coordinator - init(coordinator: FileExplorerPanelView.Coordinator, presentation: FileExplorerPanelPresentation) { + init( + coordinator: FileExplorerPanelView.Coordinator, + presentation: FileExplorerPanelPresentation, + searchController: any FileSearchControlling = FileSearchController() + ) { headerView = FileExplorerHeaderView() searchBarView = NSView() searchField = FileExplorerSearchField() @@ -607,7 +611,7 @@ final class FileExplorerContainerView: NSView { searchResultsView = FileExplorerSearchResultsTableView() emptyLabel = NSTextField(labelWithString: String(localized: "fileExplorer.empty", defaultValue: "No folder open")) loadingIndicator = NSProgressIndicator() - searchController = FileSearchController() + self.searchController = searchController self.presentation = presentation self.coordinator = coordinator 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 + } + } } From e43f84fd5f92f5d682931b9ed5e8b62fb17a954e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 8 May 2026 12:54:41 -0700 Subject: [PATCH 02/13] Keep sidebar find typing off the hot path Typing in the right-sidebar Find field used to synchronously restart rg and process every JSON result through the main actor. The fix gives the text field a single debounced search trigger, moves rg output parsing and path normalization behind a background actor, batches result snapshots, and only applies targeted table updates for append/same-size changes. Constraint: Local test runs are disabled for this repo; verification used the required tagged reload build and manual reproduction sampling. Rejected: Only adding debounce | leaves JSON parsing and path normalization able to starve the main actor once rg starts streaming matches Rejected: Sprinkling detached work at each parse site | keeps lifecycle ownership fragmented instead of one output pipeline Confidence: high Scope-risk: moderate Directive: Keep Find search input, rg lifecycle, output parsing, and table updates on one cancellable pipeline; do not reintroduce per-keystroke synchronous search starts. Tested: ./scripts/reload.sh --tag issue-3738-find-sidebar-typing-lag --launch; manual fast typing of private in /Users/austinwang/manaflow/term/cmux1; post-fix sample at /tmp/cmux-3738-fixed-sample.txt Not-tested: Local XCTest/XCUITest suites per repository policy --- Sources/FileExplorerSearchController.swift | 189 ++++++++++++++------- Sources/FileExplorerView.swift | 63 ++++++- 2 files changed, 184 insertions(+), 68 deletions(-) diff --git a/Sources/FileExplorerSearchController.swift b/Sources/FileExplorerSearchController.swift index 3db106555eaf..6c1786553764 100644 --- a/Sources/FileExplorerSearchController.swift +++ b/Sources/FileExplorerSearchController.swift @@ -1,6 +1,6 @@ import Foundation -struct FileSearchResult: Equatable { +struct FileSearchResult: Equatable, Sendable { let path: String let relativePath: String let lineNumber: Int @@ -46,8 +46,8 @@ enum FileSearchRipgrepParser { } } -struct FileSearchSnapshot: Equatable { - enum Status: Equatable { +struct FileSearchSnapshot: Equatable, Sendable { + enum Status: Equatable, Sendable { case idle case unsupported case searching @@ -73,6 +73,97 @@ protocol FileSearchControlling: AnyObject { func cancel(clear: Bool) } +private struct FileSearchPipelineUpdate: Sendable { + let results: [FileSearchResult] + let status: FileSearchSnapshot.Status + let isSearching: Bool + let shouldStopProcess: Bool +} + +private actor FileSearchOutputPipeline { + private let rootPath: String + private let maxResults: Int + private let snapshotInterval: TimeInterval + private var stdoutBuffer = Data() + private var stderrBuffer = Data() + private var results: [FileSearchResult] = [] + private var lastSnapshotEmissionDate = Date.distantPast + + init(rootPath: String, maxResults: Int, snapshotInterval: TimeInterval) { + self.rootPath = rootPath + self.maxResults = maxResults + self.snapshotInterval = snapshotInterval + } + + func consumeStdout(_ data: Data) -> FileSearchPipelineUpdate? { + stdoutBuffer.append(data) + var didAppendResult = false + + while let newlineIndex = stdoutBuffer.firstIndex(of: 10) { + let lineData = stdoutBuffer[..= maxResults { + return FileSearchPipelineUpdate( + results: results, + status: .limited(maxResults), + isSearching: false, + shouldStopProcess: true + ) + } + } + + guard didAppendResult else { return nil } + 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) { + stderrBuffer.append(data) + if stderrBuffer.count > 8_192 { + stderrBuffer.removeSubrange(0..<(stderrBuffer.count - 8_192)) + } + } + + func finish(status: Int32) -> FileSearchPipelineUpdate { + 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 { @@ -90,6 +181,7 @@ final class FileSearchController: FileSearchControlling { var onSnapshotChanged: ((FileSearchSnapshot) -> Void)? private let maxResults = 500 + private let snapshotInterval: TimeInterval = 0.05 private let excludedSearchGlobs = [ "!.git/**", "!**/.git/**", @@ -103,11 +195,10 @@ final class FileSearchController: FileSearchControlling { "!**/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? func search(query rawQuery: String, rootPath: String, isLocal: Bool, contentRevision: Int = 0) { let query = rawQuery.trimmingCharacters(in: .whitespacesAndNewlines) @@ -124,8 +215,6 @@ final class FileSearchController: FileSearchControlling { stopAndAdvanceGeneration() results.removeAll() - stdoutBuffer.removeAll(keepingCapacity: true) - stderrBuffer.removeAll(keepingCapacity: true) guard !query.isEmpty else { emit(status: .idle, isSearching: false) @@ -173,25 +262,33 @@ final class FileSearchController: FileSearchControlling { let stderr = Pipe() process.standardOutput = stdout process.standardError = stderr + let pipeline = FileSearchOutputPipeline( + rootPath: rootPath, + maxResults: maxResults, + snapshotInterval: snapshotInterval + ) + self.pipeline = pipeline - stdout.fileHandleForReading.readabilityHandler = { [weak self] handle in + stdout.fileHandleForReading.readabilityHandler = { [weak self, pipeline] handle in let data = handle.availableData guard !data.isEmpty else { return } - Task { @MainActor [weak self] in - self?.consumeStdout(data, generation: searchGeneration, rootPath: rootPath) + Task { [weak self, pipeline] in + guard let update = await pipeline.consumeStdout(data) else { return } + await self?.applyPipelineUpdate(update, generation: searchGeneration) } } - stderr.fileHandleForReading.readabilityHandler = { [weak self] handle in + stderr.fileHandleForReading.readabilityHandler = { [pipeline] handle in let data = handle.availableData guard !data.isEmpty else { return } - Task { @MainActor [weak self] in - self?.consumeStderr(data, generation: searchGeneration) + Task { [pipeline] in + await pipeline.consumeStderr(data) } } - process.terminationHandler = { [weak self] process in - Task { @MainActor [weak self] in - self?.finish(generation: searchGeneration, status: process.terminationStatus) + process.terminationHandler = { [weak self, pipeline] process in + Task { [weak self, pipeline] in + let update = await pipeline.finish(status: process.terminationStatus) + await self?.finish(generation: searchGeneration, update: update) } } @@ -201,6 +298,7 @@ final class FileSearchController: FileSearchControlling { } catch { process.standardOutput = nil process.standardError = nil + self.pipeline = nil emit(status: .failed(error.localizedDescription), isSearching: false) } } @@ -208,64 +306,26 @@ final class FileSearchController: FileSearchControlling { 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) { - 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) { + private func applyPipelineUpdate(_ update: FileSearchPipelineUpdate, 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) + results = update.results + emit(status: update.status, isSearching: update.isSearching) } private func emit(status: FileSearchSnapshot.Status, isSearching: Bool) { @@ -285,11 +345,14 @@ final class FileSearchController: FileSearchControlling { private func stopCurrentProcess() { guard let process else { return } self.process = nil + pipeline = nil (process.standardOutput as? Pipe)?.fileHandleForReading.readabilityHandler = nil (process.standardError as? Pipe)?.fileHandleForReading.readabilityHandler = nil process.terminationHandler = nil if process.isRunning { - process.terminate() + Task.detached(priority: .utility) { + process.terminate() + } } } diff --git a/Sources/FileExplorerView.swift b/Sources/FileExplorerView.swift index 556c7e2ea30e..cbeb36e23b69 100644 --- a/Sources/FileExplorerView.swift +++ b/Sources/FileExplorerView.swift @@ -54,6 +54,7 @@ struct FileExplorerPanelView: NSViewRepresentable { // MARK: - Coordinator + @MainActor final class Coordinator: NSObject, NSOutlineViewDataSource, NSOutlineViewDelegate, NSMenuDelegate { var store: FileExplorerStore var state: FileExplorerState @@ -575,6 +576,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 @@ -595,11 +597,13 @@ final class FileExplorerContainerView: NSView { private var isSearchVisible = false private var presentation: FileExplorerPanelPresentation private let coordinator: FileExplorerPanelView.Coordinator + private var searchDebounceTask: Task? + private let searchDebounceDelay: UInt64 = 200_000_000 init( coordinator: FileExplorerPanelView.Coordinator, presentation: FileExplorerPanelPresentation, - searchController: any FileSearchControlling = FileSearchController() + searchController: (any FileSearchControlling)? = nil ) { headerView = FileExplorerHeaderView() searchBarView = NSView() @@ -611,7 +615,7 @@ final class FileExplorerContainerView: NSView { searchResultsView = FileExplorerSearchResultsTableView() emptyLabel = NSTextField(labelWithString: String(localized: "fileExplorer.empty", defaultValue: "No folder open")) loadingIndicator = NSProgressIndicator() - self.searchController = searchController + self.searchController = searchController ?? FileSearchController() self.presentation = presentation self.coordinator = coordinator @@ -764,7 +768,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) } @@ -812,6 +816,7 @@ final class FileExplorerContainerView: NSView { override func viewWillMove(toWindow newWindow: NSWindow?) { if newWindow == nil { + cancelPendingSearchRefresh() searchController.cancel(clear: false) } super.viewWillMove(toWindow: newWindow) @@ -983,6 +988,7 @@ final class FileExplorerContainerView: NSView { private func refreshSearchIfNeeded() { guard isSearchVisible else { return } + cancelPendingSearchRefresh() searchController.search( query: searchField.stringValue, rootPath: currentRootPath, @@ -991,6 +997,25 @@ final class FileExplorerContainerView: NSView { ) } + private func scheduleSearchRefresh() { + guard isSearchVisible else { return } + searchDebounceTask?.cancel() + searchDebounceTask = Task { @MainActor [weak self] in + do { + try await Task.sleep(nanoseconds: self?.searchDebounceDelay ?? 200_000_000) + } catch { + return + } + guard !Task.isCancelled else { return } + self?.refreshSearchIfNeeded() + } + } + + private func cancelPendingSearchRefresh() { + searchDebounceTask?.cancel() + searchDebounceTask = nil + } + private func updateSearchLayout(hasContent: Bool? = nil, isLoading: Bool? = nil) { let effectiveHasContent = hasContent ?? !currentRootPath.isEmpty let effectiveIsLoading = isLoading ?? false @@ -1004,9 +1029,10 @@ final class FileExplorerContainerView: NSView { private func applySearchSnapshot(_ snapshot: FileSearchSnapshot) { let previousSelectedRow = searchResultsView.selectedRow + let previousResults = searchSnapshot.results searchSnapshot = snapshot searchStatusLabel.stringValue = statusText(for: snapshot) - searchResultsView.reloadData() + applySearchResultsUpdate(previousResults: previousResults, nextResults: snapshot.results) guard !snapshot.results.isEmpty else { return } let selectedRow = previousSelectedRow >= 0 @@ -1015,6 +1041,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 && + Array(nextResults.prefix(previousResults.count)) == previousResults { + let insertedRange = previousResults.count.. String { switch snapshot.status { case .idle: @@ -1049,6 +1100,7 @@ final class FileExplorerContainerView: NSView { private func closeSearchAndFocusOutline() { if presentation == .find { let hadQuery = !searchField.stringValue.isEmpty + cancelPendingSearchRefresh() searchController.cancel(clear: true) searchField.stringValue = "" applySearchSnapshot(.empty) @@ -1065,6 +1117,7 @@ final class FileExplorerContainerView: NSView { } isSearchVisible = false + cancelPendingSearchRefresh() searchController.cancel(clear: true) searchField.stringValue = "" searchSnapshot = .empty @@ -1136,7 +1189,7 @@ final class FileExplorerContainerView: NSView { extension FileExplorerContainerView: NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate, NSMenuDelegate { func controlTextDidChange(_ notification: Notification) { guard notification.object as? NSTextField === searchField else { return } - refreshSearchIfNeeded() + scheduleSearchRefresh() } func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { From a8859463d2ab6de69bc0fb7324f03aa9f88403d2 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 8 May 2026 13:04:14 -0700 Subject: [PATCH 03/13] Keep coordinator changes within warning budget The initial main-actor isolation fix made the whole file explorer coordinator actor-isolated, which compiled locally but added new Swift isolation warnings that the macOS debug-build warning budget rejects. Scope the actor hop to the container visibility update instead, preserving synchronous outline refresh behavior without expanding coordinator isolation. Constraint: CI enforces scripts/swift_warning_budget.py on debug build output. Rejected: Refresh the warning budget | this was avoidable new debt from the PR, not accepted project debt Confidence: high Scope-risk: narrow Tested: ./scripts/reload.sh --tag issue-3738-find-sidebar-typing-lag --launch; python3 scripts/swift_warning_budget.py --log /tmp/cmux-reload-issue-3738-find-sidebar-typing-lag.log Not-tested: Local XCTest/XCUITest suites per repository policy --- Sources/FileExplorerView.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Sources/FileExplorerView.swift b/Sources/FileExplorerView.swift index cbeb36e23b69..fcdccbde1556 100644 --- a/Sources/FileExplorerView.swift +++ b/Sources/FileExplorerView.swift @@ -54,7 +54,6 @@ struct FileExplorerPanelView: NSViewRepresentable { // MARK: - Coordinator - @MainActor final class Coordinator: NSObject, NSOutlineViewDataSource, NSOutlineViewDelegate, NSMenuDelegate { var store: FileExplorerStore var state: FileExplorerState @@ -108,8 +107,11 @@ struct FileExplorerPanelView: NSViewRepresentable { func reloadIfNeeded() { guard let outlineView else { return } - // Update empty state vs tree visibility - containerView?.updateVisibility(hasContent: !store.rootPath.isEmpty, isLoading: store.isRootLoading) + // Update empty state vs tree visibility. Coordinator callbacks are delivered on the main + // run loop; keep this synchronous so outline and container visibility stay in the same pass. + MainActor.assumeIsolated { + containerView?.updateVisibility(hasContent: !store.rootPath.isEmpty, isLoading: store.isRootLoading) + } let newCount = store.rootNodes.count withProgrammaticOutlineUpdate { From c5037a33ae4d41159e8bb196621d7aa4ac4d0932 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 8 May 2026 13:17:58 -0700 Subject: [PATCH 04/13] Keep sidebar find geometry stable during streaming results The right-sidebar Find status label changes text as ripgrep streams matches. Its intrinsic width was still participating in the search-bar layout, so status updates could move the search field while the user was typing. Give the status area a stable width and add DEBUG-only evidence logs for input cadence, debounce firing, snapshot apply cost, and search-bar width changes. Constraint: Use the project reload script only; never invoke xcodebuild directly for this branch. Constraint: User needs debug-log evidence for a reproducible non-crash latency issue. Rejected: Hide or remove the status label | loses progress feedback and does not prove whether status text was driving field geometry churn. Confidence: medium Scope-risk: narrow Directive: Revisit the DEBUG file.search.* probes after the active repro is resolved; keep only logs that remain useful for future typing-lag diagnostics. Tested: ./scripts/reload.sh --tag issue-3738-find-sidebar-typing-lag --launch Tested: Typed private into the right-sidebar Find input on /Users/austinwang/manaflow/term/cmux1; file.search.* logs showed one debounced search and stable field/status widths while status intrinsic width changed. Tested: python3 scripts/swift_warning_budget.py --log /tmp/cmux-reload-issue-3738-find-sidebar-typing-lag.log Not-tested: XCTest and XCUITest, per local testing policy. --- Sources/FileExplorerView.swift | 158 ++++++++++++++++++++++++++++++++- 1 file changed, 157 insertions(+), 1 deletion(-) diff --git a/Sources/FileExplorerView.swift b/Sources/FileExplorerView.swift index fcdccbde1556..4b4f6feccfd2 100644 --- a/Sources/FileExplorerView.swift +++ b/Sources/FileExplorerView.swift @@ -601,6 +601,15 @@ final class FileExplorerContainerView: NSView { private let coordinator: FileExplorerPanelView.Coordinator private var searchDebounceTask: Task? private let searchDebounceDelay: UInt64 = 200_000_000 + private let searchStatusWidth: CGFloat = 128 + +#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, @@ -637,6 +646,8 @@ 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.setContentHuggingPriority(.defaultLow, for: .horizontal) + searchField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) searchField.delegate = self searchField.onCancel = { [weak self] in self?.closeSearchAndFocusOutline() @@ -662,6 +673,7 @@ final class FileExplorerContainerView: NSView { searchStatusLabel.textColor = .secondaryLabelColor searchStatusLabel.lineBreakMode = .byTruncatingTail searchStatusLabel.maximumNumberOfLines = 1 + searchStatusLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) searchStatusLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) searchBarView.addSubview(searchStatusLabel) @@ -775,6 +787,8 @@ final class FileExplorerContainerView: NSView { } searchBarHeightConstraint = searchBarView.heightAnchor.constraint(equalToConstant: 0) + let searchStatusWidthConstraint = searchStatusLabel.widthAnchor.constraint(equalToConstant: searchStatusWidth) + searchStatusWidthConstraint.priority = .defaultHigh NSLayoutConstraint.activate([ headerView.topAnchor.constraint(equalTo: topAnchor), headerView.leadingAnchor.constraint(equalTo: leadingAnchor), @@ -788,11 +802,12 @@ final class FileExplorerContainerView: NSView { searchField.leadingAnchor.constraint(equalTo: searchBarView.leadingAnchor, constant: 8), searchField.centerYAnchor.constraint(equalTo: searchBarView.centerYAnchor), searchField.heightAnchor.constraint(equalToConstant: 24), + searchField.widthAnchor.constraint(greaterThanOrEqualToConstant: 80), 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), + searchStatusWidthConstraint, scrollView.topAnchor.constraint(equalTo: searchBarView.bottomAnchor), scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), @@ -843,8 +858,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) { @@ -991,6 +1012,14 @@ 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, @@ -1002,6 +1031,9 @@ final class FileExplorerContainerView: NSView { private func scheduleSearchRefresh() { guard isSearchVisible else { return } searchDebounceTask?.cancel() +#if DEBUG + dlog("file.search.debounce.schedule queryLen=\(searchField.stringValue.count) delayMs=200") +#endif searchDebounceTask = Task { @MainActor [weak self] in do { try await Task.sleep(nanoseconds: self?.searchDebounceDelay ?? 200_000_000) @@ -1009,6 +1041,11 @@ final class FileExplorerContainerView: NSView { return } guard !Task.isCancelled else { return } +#if DEBUG + if let self { + dlog("file.search.debounce.fire queryLen=\(self.searchField.stringValue.count) delayMs=200") + } +#endif self?.refreshSearchIfNeeded() } } @@ -1030,11 +1067,24 @@ final class FileExplorerContainerView: NSView { } 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) 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 @@ -1099,6 +1149,98 @@ 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 @@ -1191,6 +1333,20 @@ final class FileExplorerContainerView: NSView { extension FileExplorerContainerView: NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate, NSMenuDelegate { func controlTextDidChange(_ notification: Notification) { guard notification.object as? NSTextField === searchField else { return } +#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() } From 8f9a886092f393ee5187a7a2b70cdff734dd6fe9 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 8 May 2026 13:38:47 -0700 Subject: [PATCH 05/13] Let sidebar find input use the full bar width The fixed inline status slot stopped width churn but took too much space from the search field, especially for long queries. Move status text below the full-width search field and make the field cell scrollable so typing follows the insertion point. Constraint: Preserve the no-resize property for streaming status updates without sacrificing input visibility. Rejected: Keep shrinking the inline status slot | still trades query visibility against result status and repeats the same layout coupling. Confidence: high Scope-risk: narrow Tested: ./scripts/reload.sh --tag issue-3738-find-sidebar-typing-lag --launch Tested: Typed abcdefghijklmnopqrstuvwxyz-private-function-search-long-query into the launched Find field; visible text followed the suffix instead of remaining stuck at the prefix. Tested: python3 scripts/swift_warning_budget.py --log /tmp/cmux-reload-issue-3738-find-sidebar-typing-lag.log Not-tested: XCTest/XCUITest locally, per repository policy. --- Sources/FileExplorerView.swift | 34 ++++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/Sources/FileExplorerView.swift b/Sources/FileExplorerView.swift index 4b4f6feccfd2..3e0341f9914a 100644 --- a/Sources/FileExplorerView.swift +++ b/Sources/FileExplorerView.swift @@ -601,7 +601,7 @@ final class FileExplorerContainerView: NSView { private let coordinator: FileExplorerPanelView.Coordinator private var searchDebounceTask: Task? private let searchDebounceDelay: UInt64 = 200_000_000 - private let searchStatusWidth: CGFloat = 128 + private let searchBarVisibleHeight: CGFloat = 48 #if DEBUG private var debugLastSearchTextChangeUptime: TimeInterval = 0 @@ -646,6 +646,9 @@ 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 @@ -673,6 +676,7 @@ 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) @@ -787,8 +791,6 @@ final class FileExplorerContainerView: NSView { } searchBarHeightConstraint = searchBarView.heightAnchor.constraint(equalToConstant: 0) - let searchStatusWidthConstraint = searchStatusLabel.widthAnchor.constraint(equalToConstant: searchStatusWidth) - searchStatusWidthConstraint.priority = .defaultHigh NSLayoutConstraint.activate([ headerView.topAnchor.constraint(equalTo: topAnchor), headerView.leadingAnchor.constraint(equalTo: leadingAnchor), @@ -800,14 +802,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: 80), + 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), - searchStatusWidthConstraint, + 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), @@ -1060,7 +1062,7 @@ final class FileExplorerContainerView: NSView { 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 @@ -1333,6 +1335,10 @@ final class FileExplorerContainerView: NSView { 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 @@ -1350,6 +1356,14 @@ extension FileExplorerContainerView: NSSearchFieldDelegate, NSTableViewDataSourc 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 else { return false } switch commandSelector { From 74dd0d1dcfffc82c991029d90bdeb849d990731a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 8 May 2026 14:14:05 -0700 Subject: [PATCH 06/13] Make Find search completion deterministic under review Review feedback exposed two remaining latency-path risks: the debounce still used a Swift sleep timer, and process finalization could race pipe consumption. This changes the Find debounce to a cancellable main-queue work item, cancels pending work when Find is hidden, avoids prefix array allocation during incremental table updates, and makes the search controller finalize only after process termination plus stdout/stderr stream completion. Constraint: Build must use scripts/reload.sh only; no direct xcodebuild and no local tests. Rejected: Keep readabilityHandler callbacks and guard late results individually | still allows final status to race tail output. Confidence: high Scope-risk: moderate Directive: Keep search parsing/finalization ordered off the text input path; do not reintroduce per-keystroke sleeps or main-thread pipe parsing. Tested: ./scripts/reload.sh --tag issue-3738-find-sidebar-typing-lag --launch Tested: python3 scripts/swift_warning_budget.py --log /tmp/cmux-reload-issue-3738-find-sidebar-typing-lag.log Not-tested: Local XCTest per repository policy. --- Sources/FileExplorerSearchController.swift | 152 +++++++++++++++++---- Sources/FileExplorerView.swift | 46 ++++--- 2 files changed, 155 insertions(+), 43 deletions(-) diff --git a/Sources/FileExplorerSearchController.swift b/Sources/FileExplorerSearchController.swift index 6c1786553764..00a67695d888 100644 --- a/Sources/FileExplorerSearchController.swift +++ b/Sources/FileExplorerSearchController.swift @@ -80,6 +80,30 @@ private struct FileSearchPipelineUpdate: Sendable { let shouldStopProcess: Bool } +private actor FileSearchTerminationSignal { + private var status: Int32? + private var continuations: [CheckedContinuation] = [] + + func complete(status: Int32) { + guard self.status == nil else { return } + self.status = status + let pendingContinuations = continuations + continuations.removeAll() + for continuation in pendingContinuations { + continuation.resume(returning: status) + } + } + + func wait() async -> Int32 { + if let status { + return status + } + return await withCheckedContinuation { continuation in + continuations.append(continuation) + } + } +} + private actor FileSearchOutputPipeline { private let rootPath: String private let maxResults: Int @@ -88,6 +112,7 @@ private actor FileSearchOutputPipeline { 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 @@ -96,6 +121,7 @@ private actor FileSearchOutputPipeline { } func consumeStdout(_ data: Data) -> FileSearchPipelineUpdate? { + guard !isFinished else { return nil } stdoutBuffer.append(data) var didAppendResult = false @@ -132,14 +158,50 @@ private actor FileSearchOutputPipeline { ) } + 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, @@ -199,6 +261,7 @@ final class FileSearchController: FileSearchControlling { 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) @@ -268,33 +331,35 @@ final class FileSearchController: FileSearchControlling { snapshotInterval: snapshotInterval ) self.pipeline = pipeline + let terminationSignal = FileSearchTerminationSignal() - stdout.fileHandleForReading.readabilityHandler = { [weak self, pipeline] handle in - let data = handle.availableData - guard !data.isEmpty else { return } - Task { [weak self, pipeline] in - guard let update = await pipeline.consumeStdout(data) else { return } - await self?.applyPipelineUpdate(update, generation: searchGeneration) - } - } - stderr.fileHandleForReading.readabilityHandler = { [pipeline] handle in - let data = handle.availableData - guard !data.isEmpty else { return } - Task { [pipeline] in - await pipeline.consumeStderr(data) - } - } - - process.terminationHandler = { [weak self, pipeline] process in - Task { [weak self, pipeline] in - let update = await pipeline.finish(status: process.terminationStatus) - await self?.finish(generation: searchGeneration, update: update) + 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 controller = self + async let stdoutDone: Void = Self.streamStdout( + from: stdoutHandle, + pipeline: pipeline, + generation: searchGeneration, + controller: controller + ) + async let stderrDone: Void = Self.streamStderr(from: stderrHandle, pipeline: pipeline) + let status = await terminationSignal.wait() + _ = await (stdoutDone, stderrDone) + guard !Task.isCancelled else { return } + let update = await pipeline.finish(status: status) + controller?.finish(generation: searchGeneration, update: update) + } } catch { process.standardOutput = nil process.standardError = nil @@ -323,7 +388,9 @@ final class FileSearchController: FileSearchControlling { private func finish(generation searchGeneration: Int, update: FileSearchPipelineUpdate) { guard searchGeneration == generation else { return } - stopCurrentProcess() + process = nil + pipeline = nil + searchTask = nil results = update.results emit(status: update.status, isSearching: update.isSearching) } @@ -345,10 +412,9 @@ final class FileSearchController: FileSearchControlling { private func stopCurrentProcess() { guard let process else { return } self.process = nil + searchTask?.cancel() + searchTask = nil pipeline = nil - (process.standardOutput as? Pipe)?.fileHandleForReading.readabilityHandler = nil - (process.standardError as? Pipe)?.fileHandleForReading.readabilityHandler = nil - process.terminationHandler = nil if process.isRunning { Task.detached(priority: .utility) { process.terminate() @@ -356,6 +422,44 @@ final class FileSearchController: FileSearchControlling { } } + private nonisolated static func streamStdout( + from handle: FileHandle, + pipeline: FileSearchOutputPipeline, + generation: Int, + controller: FileSearchController? + ) async { + do { + for try await line in handle.bytes.lines { + try Task.checkCancellation() + guard let update = await pipeline.consumeStdoutLine(line) else { continue } + await controller?.applyPipelineUpdate(update, generation: generation) + if update.shouldStopProcess { + return + } + } + } catch is CancellationError { + return + } catch { + await pipeline.consumeStderrLine(error.localizedDescription) + } + } + + 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) + } + } + private static func ripgrepExecutable() -> RipgrepExecutable? { let fileManager = FileManager.default for path in ["/opt/homebrew/bin/rg", "/usr/local/bin/rg", "/usr/bin/rg"] where fileManager.isExecutableFile(atPath: path) { diff --git a/Sources/FileExplorerView.swift b/Sources/FileExplorerView.swift index 3e0341f9914a..5124618358a9 100644 --- a/Sources/FileExplorerView.swift +++ b/Sources/FileExplorerView.swift @@ -596,11 +596,17 @@ final class FileExplorerContainerView: NSView { private var currentRootPath = "" private var currentProviderIsLocal = false private var currentContentRevision = 0 - private var isSearchVisible = false + private var isSearchVisible = false { + didSet { + if !isSearchVisible { + cancelPendingSearchRefresh() + } + } + } private var presentation: FileExplorerPanelPresentation private let coordinator: FileExplorerPanelView.Coordinator - private var searchDebounceTask: Task? - private let searchDebounceDelay: UInt64 = 200_000_000 + private var searchDebounceWorkItem: DispatchWorkItem? + private let searchDebounceDelaySeconds: TimeInterval = 0.2 private let searchBarVisibleHeight: CGFloat = 48 #if DEBUG @@ -1032,29 +1038,29 @@ final class FileExplorerContainerView: NSView { private func scheduleSearchRefresh() { guard isSearchVisible else { return } - searchDebounceTask?.cancel() + searchDebounceWorkItem?.cancel() #if DEBUG dlog("file.search.debounce.schedule queryLen=\(searchField.stringValue.count) delayMs=200") #endif - searchDebounceTask = Task { @MainActor [weak self] in - do { - try await Task.sleep(nanoseconds: self?.searchDebounceDelay ?? 200_000_000) - } catch { - return - } - guard !Task.isCancelled else { return } + var workItem: DispatchWorkItem! + workItem = DispatchWorkItem { [weak self] in + guard !workItem.isCancelled else { return } + MainActor.assumeIsolated { + guard let self, self.searchDebounceWorkItem === workItem else { return } + self.searchDebounceWorkItem = nil #if DEBUG - if let self { dlog("file.search.debounce.fire queryLen=\(self.searchField.stringValue.count) delayMs=200") - } #endif - self?.refreshSearchIfNeeded() + self.refreshSearchIfNeeded() + } } + searchDebounceWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + searchDebounceDelaySeconds, execute: workItem) } private func cancelPendingSearchRefresh() { - searchDebounceTask?.cancel() - searchDebounceTask = nil + searchDebounceWorkItem?.cancel() + searchDebounceWorkItem = nil } private func updateSearchLayout(hasContent: Bool? = nil, isLoading: Bool? = nil) { @@ -1101,7 +1107,7 @@ final class FileExplorerContainerView: NSView { } if nextResults.count > previousResults.count && - Array(nextResults.prefix(previousResults.count)) == previousResults { + nextResults.starts(with: previousResults) { let insertedRange = previousResults.count.. Date: Fri, 8 May 2026 14:18:47 -0700 Subject: [PATCH 07/13] Make file explorer reload isolation explicit Greptile's summary correctly pointed out that Coordinator.reloadIfNeeded relied on MainActor.assumeIsolated. The method is UI work, so it now carries @MainActor directly and the Combine store observer performs an explicit main-actor hop before reloading. Constraint: Build must use scripts/reload.sh only; no direct xcodebuild and no local tests. Rejected: Leave assumeIsolated because the sink uses RunLoop.main | keeps a runtime trap where compile-time isolation is available. Confidence: high Scope-risk: narrow Directive: Prefer @MainActor annotations for FileExplorer UI reload paths when the caller can hop explicitly. Tested: ./scripts/reload.sh --tag issue-3738-find-sidebar-typing-lag --launch Tested: python3 scripts/swift_warning_budget.py --log /tmp/cmux-reload-issue-3738-find-sidebar-typing-lag.log Not-tested: Local XCTest per repository policy. --- Sources/FileExplorerView.swift | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/Sources/FileExplorerView.swift b/Sources/FileExplorerView.swift index 5124618358a9..4fac6e926a34 100644 --- a/Sources/FileExplorerView.swift +++ b/Sources/FileExplorerView.swift @@ -100,18 +100,17 @@ 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. Coordinator callbacks are delivered on the main - // run loop; keep this synchronous so outline and container visibility stay in the same pass. - MainActor.assumeIsolated { - containerView?.updateVisibility(hasContent: !store.rootPath.isEmpty, isLoading: store.isRootLoading) - } + containerView?.updateVisibility(hasContent: !store.rootPath.isEmpty, isLoading: store.isRootLoading) let newCount = store.rootNodes.count withProgrammaticOutlineUpdate { From d6e53ef9b4ca016dec62ff73efb426031c4ea006 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 8 May 2026 14:33:54 -0700 Subject: [PATCH 08/13] Remove obsolete search stdout buffer path The structured async stream path consumes ripgrep output line-by-line through consumeStdoutLine. Keeping the old Data-buffered consumeStdout method and stdoutBuffer field created dead code after the review-response refactor, so this removes the unused path and leaves one output ingestion model. Constraint: Build must use scripts/reload.sh only; no direct xcodebuild and no local tests. Rejected: Keep both buffered and line-based ingestion paths | invites future edits to the wrong path and weakens the pipeline ownership model. Confidence: high Scope-risk: narrow Directive: FileSearchOutputPipeline stdout ingestion is line-based through AsyncBytes.lines; add buffering only if a future reader no longer provides complete lines. Tested: ./scripts/reload.sh --tag issue-3738-find-sidebar-typing-lag --launch Tested: python3 scripts/swift_warning_budget.py --log /tmp/cmux-reload-issue-3738-find-sidebar-typing-lag.log Not-tested: Local XCTest per repository policy. --- Sources/FileExplorerSearchController.swift | 39 ---------------------- 1 file changed, 39 deletions(-) diff --git a/Sources/FileExplorerSearchController.swift b/Sources/FileExplorerSearchController.swift index 00a67695d888..0dcc873b8118 100644 --- a/Sources/FileExplorerSearchController.swift +++ b/Sources/FileExplorerSearchController.swift @@ -108,7 +108,6 @@ private actor FileSearchOutputPipeline { private let rootPath: String private let maxResults: Int private let snapshotInterval: TimeInterval - private var stdoutBuffer = Data() private var stderrBuffer = Data() private var results: [FileSearchResult] = [] private var lastSnapshotEmissionDate = Date.distantPast @@ -120,44 +119,6 @@ private actor FileSearchOutputPipeline { self.snapshotInterval = snapshotInterval } - func consumeStdout(_ data: Data) -> FileSearchPipelineUpdate? { - guard !isFinished else { return nil } - stdoutBuffer.append(data) - var didAppendResult = false - - while let newlineIndex = stdoutBuffer.firstIndex(of: 10) { - let lineData = stdoutBuffer[..= maxResults { - return FileSearchPipelineUpdate( - results: results, - status: .limited(maxResults), - isSearching: false, - shouldStopProcess: true - ) - } - } - - guard didAppendResult else { return nil } - let now = Date() - guard now.timeIntervalSince(lastSnapshotEmissionDate) >= snapshotInterval else { - return nil - } - lastSnapshotEmissionDate = now - return FileSearchPipelineUpdate( - results: results, - status: .searching, - isSearching: true, - shouldStopProcess: false - ) - } - func consumeStdoutLine(_ line: String) -> FileSearchPipelineUpdate? { guard !isFinished, let result = FileSearchRipgrepParser.parseMatchLine(line, rootPath: rootPath) else { From 88ddeaca672b277772bf8eb54b877bcf6f3798ff Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 8 May 2026 14:51:25 -0700 Subject: [PATCH 09/13] Break the Find debounce work-item retain cycle The debounce path now uses a generation token to invalidate stale callbacks instead of letting a DispatchWorkItem closure retain the work item it is assigned to. The deferred field-editor scroll also uses an explicit MainActor task so the handoff is visible to Swift concurrency checking. Constraint: Build must use ./scripts/reload.sh only; no direct xcodebuild invocation Rejected: Capture DispatchWorkItem inside its own closure | canceled items can retain themselves Confidence: high Scope-risk: narrow Tested: ./scripts/reload.sh --tag issue-3738-find-sidebar-typing-lag --launch Tested: python3 scripts/swift_warning_budget.py --log /tmp/cmux-reload-issue-3738-find-sidebar-typing-lag.log Not-tested: Local XCTest per repo policy --- Sources/FileExplorerView.swift | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/Sources/FileExplorerView.swift b/Sources/FileExplorerView.swift index 4fac6e926a34..e6132f0154f3 100644 --- a/Sources/FileExplorerView.swift +++ b/Sources/FileExplorerView.swift @@ -595,6 +595,7 @@ final class FileExplorerContainerView: NSView { private var currentRootPath = "" private var currentProviderIsLocal = false private var currentContentRevision = 0 + private var searchDebounceGeneration = 0 private var isSearchVisible = false { didSet { if !isSearchVisible { @@ -1038,14 +1039,14 @@ final class FileExplorerContainerView: NSView { private func scheduleSearchRefresh() { guard isSearchVisible else { return } searchDebounceWorkItem?.cancel() + searchDebounceGeneration += 1 + let debounceGeneration = searchDebounceGeneration #if DEBUG dlog("file.search.debounce.schedule queryLen=\(searchField.stringValue.count) delayMs=200") #endif - var workItem: DispatchWorkItem! - workItem = DispatchWorkItem { [weak self] in - guard !workItem.isCancelled else { return } - MainActor.assumeIsolated { - guard let self, self.searchDebounceWorkItem === workItem else { return } + let workItem = DispatchWorkItem { [weak self] in + Task { @MainActor [weak self] in + guard let self, self.searchDebounceGeneration == debounceGeneration else { return } self.searchDebounceWorkItem = nil #if DEBUG dlog("file.search.debounce.fire queryLen=\(self.searchField.stringValue.count) delayMs=200") @@ -1058,6 +1059,7 @@ final class FileExplorerContainerView: NSView { } private func cancelPendingSearchRefresh() { + searchDebounceGeneration += 1 searchDebounceWorkItem?.cancel() searchDebounceWorkItem = nil } @@ -1268,7 +1270,6 @@ final class FileExplorerContainerView: NSView { } isSearchVisible = false - cancelPendingSearchRefresh() searchController.cancel(clear: true) searchField.stringValue = "" searchSnapshot = .empty @@ -1341,10 +1342,8 @@ extension FileExplorerContainerView: NSSearchFieldDelegate, NSTableViewDataSourc func controlTextDidChange(_ notification: Notification) { guard notification.object as? NSTextField === searchField else { return } scrollSearchFieldEditorToInsertionPoint() - DispatchQueue.main.async { [weak self] in - MainActor.assumeIsolated { - self?.scrollSearchFieldEditorToInsertionPoint() - } + Task { @MainActor [weak self] in + self?.scrollSearchFieldEditorToInsertionPoint() } #if DEBUG let now = ProcessInfo.processInfo.systemUptime From 33d8dd29c31858ce09c9513a0797815b92791bc2 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 8 May 2026 15:07:50 -0700 Subject: [PATCH 10/13] Keep Find debounce inside one observable pipeline The Find field debounce now uses a Combine subject and generation guard instead of queueing Dispatch work items. Search process cancellation sends SIGTERM directly, and the stdout/stderr readers are marked @concurrent so their I/O and parsing path cannot inherit main-actor execution. Constraint: Build must use ./scripts/reload.sh only; no direct xcodebuild invocation Rejected: DispatchQueue.asyncAfter debounce | reviewer rule treats queue timers in typing paths as blocking-risk code Rejected: Task.detached process termination | detached tasks obscure lifecycle and were flagged by review Confidence: high Scope-risk: moderate Tested: ./scripts/reload.sh --tag issue-3738-find-sidebar-typing-lag --launch Tested: python3 scripts/swift_warning_budget.py --log /tmp/cmux-reload-issue-3738-find-sidebar-typing-lag.log Not-tested: Local XCTest per repo policy --- Sources/FileExplorerSearchController.swift | 7 ++-- Sources/FileExplorerView.swift | 46 +++++++++++++--------- 2 files changed, 32 insertions(+), 21 deletions(-) diff --git a/Sources/FileExplorerSearchController.swift b/Sources/FileExplorerSearchController.swift index 0dcc873b8118..1d3f6c8a8f63 100644 --- a/Sources/FileExplorerSearchController.swift +++ b/Sources/FileExplorerSearchController.swift @@ -1,3 +1,4 @@ +import Darwin import Foundation struct FileSearchResult: Equatable, Sendable { @@ -377,12 +378,11 @@ final class FileSearchController: FileSearchControlling { searchTask = nil pipeline = nil if process.isRunning { - Task.detached(priority: .utility) { - process.terminate() - } + _ = Darwin.kill(process.processIdentifier, SIGTERM) } } + @concurrent private nonisolated static func streamStdout( from handle: FileHandle, pipeline: FileSearchOutputPipeline, @@ -405,6 +405,7 @@ final class FileSearchController: FileSearchControlling { } } + @concurrent private nonisolated static func streamStderr( from handle: FileHandle, pipeline: FileSearchOutputPipeline diff --git a/Sources/FileExplorerView.swift b/Sources/FileExplorerView.swift index e6132f0154f3..7884cb893f5c 100644 --- a/Sources/FileExplorerView.swift +++ b/Sources/FileExplorerView.swift @@ -595,6 +595,8 @@ final class FileExplorerContainerView: NSView { private var currentRootPath = "" private var currentProviderIsLocal = false private var currentContentRevision = 0 + private let searchDebounceSubject = PassthroughSubject() + private var searchDebounceCancellable: AnyCancellable? private var searchDebounceGeneration = 0 private var isSearchVisible = false { didSet { @@ -605,8 +607,7 @@ final class FileExplorerContainerView: NSView { } private var presentation: FileExplorerPanelPresentation private let coordinator: FileExplorerPanelView.Coordinator - private var searchDebounceWorkItem: DispatchWorkItem? - private let searchDebounceDelaySeconds: TimeInterval = 0.2 + private let searchDebounceDelayMilliseconds = 200 private let searchBarVisibleHeight: CGFloat = 48 #if DEBUG @@ -637,6 +638,7 @@ final class FileExplorerContainerView: NSView { self.coordinator = coordinator super.init(frame: .zero) + configureSearchDebounce() // Header headerView.translatesAutoresizingMaskIntoConstraints = false @@ -1036,32 +1038,40 @@ 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 } - searchDebounceWorkItem?.cancel() searchDebounceGeneration += 1 let debounceGeneration = searchDebounceGeneration #if DEBUG - dlog("file.search.debounce.schedule queryLen=\(searchField.stringValue.count) delayMs=200") -#endif - let workItem = DispatchWorkItem { [weak self] in - Task { @MainActor [weak self] in - guard let self, self.searchDebounceGeneration == debounceGeneration else { return } - self.searchDebounceWorkItem = nil -#if DEBUG - dlog("file.search.debounce.fire queryLen=\(self.searchField.stringValue.count) delayMs=200") + dlog( + "file.search.debounce.schedule queryLen=\(searchField.stringValue.count) " + + "delayMs=\(searchDebounceDelayMilliseconds)" + ) #endif - self.refreshSearchIfNeeded() - } - } - searchDebounceWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + searchDebounceDelaySeconds, execute: workItem) + searchDebounceSubject.send(debounceGeneration) } private func cancelPendingSearchRefresh() { searchDebounceGeneration += 1 - searchDebounceWorkItem?.cancel() - searchDebounceWorkItem = nil } private func updateSearchLayout(hasContent: Bool? = nil, isLoading: Bool? = nil) { From fd5d72fa6a1ca1083a59da2bebc959d919511893 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 8 May 2026 15:13:16 -0700 Subject: [PATCH 11/13] Gate concurrent search readers for CI toolchains CircleCI still runs Xcode 16.4, so the search stream readers keep the @concurrent annotation only for Swift 6.2+ compilers. Newer local toolchains still force the I/O-heavy readers off the caller actor, while older CI compilers can build the same source. Constraint: CircleCI macOS executor uses Xcode 16.4 Constraint: Build must use ./scripts/reload.sh only; no direct xcodebuild invocation Rejected: Ungated @concurrent | Swift 6.1 CI toolchains cannot parse the attribute Confidence: high Scope-risk: narrow Tested: ./scripts/reload.sh --tag issue-3738-find-sidebar-typing-lag --launch Tested: python3 scripts/swift_warning_budget.py --log /tmp/cmux-reload-issue-3738-find-sidebar-typing-lag.log Not-tested: Local XCTest per repo policy --- Sources/FileExplorerSearchController.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/FileExplorerSearchController.swift b/Sources/FileExplorerSearchController.swift index 1d3f6c8a8f63..a9fd07a4d24d 100644 --- a/Sources/FileExplorerSearchController.swift +++ b/Sources/FileExplorerSearchController.swift @@ -382,7 +382,9 @@ final class FileSearchController: FileSearchControlling { } } +#if compiler(>=6.2) @concurrent +#endif private nonisolated static func streamStdout( from handle: FileHandle, pipeline: FileSearchOutputPipeline, @@ -405,7 +407,9 @@ final class FileSearchController: FileSearchControlling { } } +#if compiler(>=6.2) @concurrent +#endif private nonisolated static func streamStderr( from handle: FileHandle, pipeline: FileSearchOutputPipeline From e9fbd9e187ff8968cabde996dbe9cb01e61af77e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 8 May 2026 15:29:56 -0700 Subject: [PATCH 12/13] Let canceled search tasks leave termination wait FileSearchTerminationSignal now tracks cancellable wait continuations by id, so canceling a search task resumes the waiter with nil instead of holding the task until the process termination handler runs. Normal process completion still resumes every pending waiter with the exit status. Constraint: Build must use ./scripts/reload.sh only; no direct xcodebuild invocation Rejected: Plain CheckedContinuation wait | cancellation cannot release the search task promptly Confidence: high Scope-risk: narrow Tested: ./scripts/reload.sh --tag issue-3738-find-sidebar-typing-lag --launch Tested: python3 scripts/swift_warning_budget.py --log /tmp/cmux-reload-issue-3738-find-sidebar-typing-lag.log Not-tested: Local XCTest per repo policy --- Sources/FileExplorerSearchController.swift | 36 ++++++++++++++++++---- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/Sources/FileExplorerSearchController.swift b/Sources/FileExplorerSearchController.swift index a9fd07a4d24d..90079700356a 100644 --- a/Sources/FileExplorerSearchController.swift +++ b/Sources/FileExplorerSearchController.swift @@ -83,24 +83,48 @@ private struct FileSearchPipelineUpdate: Sendable { private actor FileSearchTerminationSignal { private var status: Int32? - private var continuations: [CheckedContinuation] = [] + private var continuations: [UUID: CheckedContinuation] = [:] + private var cancelledWaits = Set() func complete(status: Int32) { guard self.status == nil else { return } self.status = status - let pendingContinuations = continuations + let pendingContinuations = Array(continuations.values) continuations.removeAll() + cancelledWaits.removeAll() for continuation in pendingContinuations { continuation.resume(returning: status) } } - func wait() async -> Int32 { + func wait() async -> Int32? { if let status { return status } - return await withCheckedContinuation { continuation in - continuations.append(continuation) + 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) } } } @@ -316,7 +340,7 @@ final class FileSearchController: FileSearchControlling { controller: controller ) async let stderrDone: Void = Self.streamStderr(from: stderrHandle, pipeline: pipeline) - let status = await terminationSignal.wait() + guard let status = await terminationSignal.wait() else { return } _ = await (stdoutDone, stderrDone) guard !Task.isCancelled else { return } let update = await pipeline.finish(status: status) From 899d0e7e25f8e616ecd18403cc8a264b04875b00 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 8 May 2026 16:04:00 -0700 Subject: [PATCH 13/13] Clarify Find search task ownership The search stream task used a weak capture but immediately promoted it to a task-long strong controller reference. Keep the task weak with an async update handler so streaming updates and final completion only retain the controller at the call site. Constraint: Cursor flagged the misleading weak-to-strong promotion after CI completed. Rejected: Capture self strongly for the whole task | preserves the temporary retain cycle the review identified. Confidence: high Scope-risk: narrow Tested: ./scripts/reload.sh --tag issue-3738-find-sidebar-typing-lag --launch Tested: python3 scripts/swift_warning_budget.py --log /tmp/cmux-reload-issue-3738-find-sidebar-typing-lag.log Not-tested: Local tests per repository policy; CI will run after push. --- Sources/FileExplorerSearchController.swift | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Sources/FileExplorerSearchController.swift b/Sources/FileExplorerSearchController.swift index 90079700356a..cab581c7b5c3 100644 --- a/Sources/FileExplorerSearchController.swift +++ b/Sources/FileExplorerSearchController.swift @@ -332,19 +332,21 @@ final class FileSearchController: FileSearchControlling { let stdoutHandle = stdout.fileHandleForReading let stderrHandle = stderr.fileHandleForReading searchTask = Task { [weak self, pipeline, terminationSignal] in - let controller = self + 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, - controller: controller + 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) - controller?.finish(generation: searchGeneration, update: update) + self?.finish(generation: searchGeneration, update: update) } } catch { process.standardOutput = nil @@ -413,13 +415,13 @@ final class FileSearchController: FileSearchControlling { from handle: FileHandle, pipeline: FileSearchOutputPipeline, generation: Int, - controller: FileSearchController? + 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 controller?.applyPipelineUpdate(update, generation: generation) + await applyUpdate(update, generation) if update.shouldStopProcess { return }