diff --git a/.agents/notes/implemented/architecture/2026-09-13-resizable-ui-performance-boundaries.md b/.agents/notes/implemented/architecture/2026-09-13-resizable-ui-performance-boundaries.md index 0418f0aaa1..28f5f72cba 100644 --- a/.agents/notes/implemented/architecture/2026-09-13-resizable-ui-performance-boundaries.md +++ b/.agents/notes/implemented/architecture/2026-09-13-resizable-ui-performance-boundaries.md @@ -48,6 +48,14 @@ macOS 和 Windows 可以用不同的 UI 技术实现,但用户看到的交互 反复询问子面板的最小、理想和最大尺寸。仅容器尚无确定宽高时读取内容 的理想尺寸。这个边界对应 IDEA `ThreeComponentsSplitter` 对现有组件 设置 bounds 的做法;达到尺寸限制后的相同结果不再写入拖动状态。 +- 每个面板先由共享分隔容器包装成单个纵向布局容器,再交给三块矩形布局。 + SwiftUI 的 `Group`、条件分支和 `ForEach` 会展开为零到多个直接子视图, + `.frame` 不会把这些子视图合成一个面板。#1564 原先达到 200 条时的“更多提示”暴露了该问题; + 现已移除提示并自动分页,“文件列表 + 失败重试”仍须只占一个上方面板,下方预览另占一个面板。 + 不要让业务调用方逐处补包装,也不要遇到子视图数不是三个就跳过布局后 + 任由内容重叠。内部纵向容器只处理该面板的内容,外层仍直接分配确定边界; + 空的加载状态同样保留面板边界。回归覆盖空、单项和多项面板,以及真实搜索 + 弹窗在 199/200 条结果时的列表、预览、鼠标选择和键盘滚动。 - 尺寸读取器的闭包仅处理可见宽高。全文测量、Diff 折叠与双栏规划、Git 文件树构建等数据计算放在闭包外,数据变化时更新;长 Diff 的代码文本使用固定宽度的原生文本容器,行号与连接带按 可见区域绘制;折叠和导航定位仍用 `LazyVStack`,不能用普通 `VStack` 布置整份文件。否则保持父视图不重建 diff --git a/.agents/notes/implemented/architecture/2026-10-24-remote-workspace-routing.md b/.agents/notes/implemented/architecture/2026-10-24-remote-workspace-routing.md index 5929a429a7..748d3ef77a 100644 --- a/.agents/notes/implemented/architecture/2026-10-24-remote-workspace-routing.md +++ b/.agents/notes/implemented/architecture/2026-10-24-remote-workspace-routing.md @@ -32,6 +32,7 @@ - **上传不互相拖住**:上传表只登记和移除身份,不把文件打开、写入、同步、发布或删除放在全局锁里。每个文件独立同步,同一上传的并发操作返回 `BUSY`;断线、取消和到期先标记取消,由仍在执行的工作线程保管并最终删除暂存文件。一个慢文件不能阻止另一个文件完成,释放动作也不能等慢文件的锁。 - **stdio 与启动**:语言、构建和 VCS 提供者复用 Core 的协议引擎,远程通道保留有界输入输出与原连接。异步部署、工具探测和启动在工作线程进行,启动期间保留有界文档快照与文件变更,停止后到达的会话先销毁再删除包。仅映射协议定义的 URI 和 VCS 路径字段,参数、文本与插件自定义值保持原样。依赖本机原生集成的 Java 仍明确拒绝,不能伪装成通用 stdio 已支持。 - **启动中取消不能依赖空资源表**:服务端的连接读取线程先登记进程、终端和监听的启动身份,再交给工作线程。后续关闭先记录在这个身份上,由创建者在发布资源后执行清理;即使关闭请求因工作线程已满返回 `BUSY`、客户端停止等待或断线,清理责任也不消失。关闭等待最多 60 秒,但到期不会撤销已经记录的清理;不同资源仍可并行,注册表锁不覆盖磁盘、启动或结束进程树。无效参数不能触发取消,后到的普通结束请求不能降低已请求的强制结束。这样修复的是所有通用后端的生命周期,不需要引入 Java/JDT 等语言专属语义。 +- **常驻服务启动只允许一个代理创建进程**:daemon 的存活锁只能防止同时服务,不能阻止竞争失败的启动进程在旧会话关闭后接管锁。代理在启动前先取得同一身份的 `.start` 文件锁,再检查 socket,已有服务就复用;锁覆盖创建进程至 socket 可连接,等待使用原有启动期限。`.start` 位于远端用户私有状态目录的 `run/`,进程关闭文件即释放锁,文件本身保留,避免删除后不同代理锁住不同 inode;它属于远端会话协调状态,不可跨工作树复用,不写安装包,不影响代码签名或 Sparkle delta。验证启动锁互斥、期限、释放及并发连接收到关闭确认后服务确实退出,不用延长测试超时掩盖残留进程。 - **断线与会话结束不同**:常驻会话仅断线时保留终端和进程供重连;非驻留连接断开、显式关闭会话或空闲到期时,迟到发布的资源必须由创建者清理。结束标记在清理现有资源表之前设置,否则慢启动可能在清理后才出现,尤其是常驻会话不能再把它当成“等待重连”的合法资源。 ### 正确做法 diff --git a/.agents/notes/implemented/simplification/2026-09-30-macos-shared-dropdown-style.md b/.agents/notes/implemented/simplification/2026-09-30-macos-shared-dropdown-style.md index 641bb19524..9858036fe9 100644 --- a/.agents/notes/implemented/simplification/2026-09-30-macos-shared-dropdown-style.md +++ b/.agents/notes/implemented/simplification/2026-09-30-macos-shared-dropdown-style.md @@ -55,6 +55,36 @@ Commit/Shelf 页签的留白以同一 Community revision 的 `IslandsUICustomiza ### Search Everywhere +#1564 的增量搜索复用 Core 已有请求事件和取消机制:先扫描、发布文件名,再构建正文/符号索引。 +首次候选立即发送,后续名称/类/符号候选按约 100ms 合并,并在来源结束时刷新;每批是替换快照, +先按原有匹配度排序再限制每来源与总条数,较晚的优质候选不会被早期截断丢弃。 +正文结果和 Git 忽略状态仍随最终响应到达;中间缺失的忽略状态保持未知。 +macOS 使用只保留最新快照的 AsyncStream,逐批核对查询代次、工作区/来源有效性和任务取消, +旧任务结束不能清掉新任务的 loading;重置/关闭也撤销 Core 操作。 +复用 RustCoreCancellation 的 requestStarted 握手处理注册前取消,阻塞的 C 调用放到 GCD worker。 +列表及用户主动选择按结果 ID 识别,追加更高排名结果不会把预览切到同序号的另一文件。 +未进行鼠标或键盘导航时,`nil` 表示默认跟随当前首项;不能把临时首项自动写成主动选择, +否则本地 Actions 先到或较差候选先到时,等待完成后 Enter 仍会激活旧项。主动选择失效才清回 +默认状态。原生回归分别控制 Actions/文件与较差/更优候选的到达顺序,核对预览和 Enter。 +保留 180ms 输入防抖和索引更新/失效屏障,冷名称索引仍须先完成轻量枚举;不宣称固定首屏延迟。 +不引入新的搜索线程池、缓存或任意等待关键来源的排序延迟。参考 +[ThrottlingListenerWrapper.java](https://github.com/JetBrains/intellij-community/blob/master/platform/lang-impl/src/com/intellij/ide/actions/searcheverywhere/ThrottlingListenerWrapper.java) +的默认 100ms 合并窗口,但不复制 IDEA 不同贡献者的等待策略。 +Rust 模拟时间验证 99/100ms 边界、最终 flush 与较晚优质候选;实际桥接验证名称先于符号和取消。 +模块回归覆盖流式期间 loading、替换查询、reset、取消与突发快照的最终结果;原生深浅主题 +40/199/200 条结果同时核对分栏、首批可交互及追加结果后的选择/预览/滚动。 +继续修复移除“更多”提示和每来源 200 条总上限:macOS 每来源每页 50 条,复用 +`LitheScrollLoadTrigger` 与共享懒列表,靠近底部自动续查,无需点击加载。Core 使用绑定工作区、 +查询、选项、来源及排除规则的游标;名称/符号仅保留最多 51 个候选,正文按路径/行推进并读取 +一个额外命中判断耗尽,不预先搜全量再由 UI 切片,也不另建常驻搜索会话缓存。 +首屏仍流式替换,续页完成后按 ID 去重追加;按页保持来源排名,动作位于首批名称之后, +防止后续类/符号或动作移动已显示行。重复游标、无新增项、耗尽停止;失败保留行与游标, +暂停自动加载,只提供已有 i18n Retry 操作。新查询/来源取消旧分页,防抖期间保留当前列表。 +取舍:名称/符号每页仍扫描已有索引,50 条减少候选缓存/排序与首屏渲染,不承诺索引扫描变快; +游标读取实时索引,游标之前新插入的匹配需重新查询。原生深浅主题自动滚动加载 257 文件, +核对去重、非零像素偏移与预览不跳;Core 覆盖四来源超过 200、恰好整页、空页与跨查询游标拒绝。 +Windows 仍消费旧有最终响应,未实现或验收其流式展示和自动分页。 + 双 Shift 搜索窗口沿用 Community `c7f91397daa3a961b4e78bc634fe467a0a7d9ade` 的 `SearchEverywhereUI.java`、`SENewUIHeaderView.kt`、`BigPopupUI.java`、 `IslandsUICustomization.kt`、`IslandsTabPainter.kt` 与 `ManyIslandsDark/Light.theme.json`。 @@ -147,8 +177,8 @@ Search Everywhere 可显式包含生成目录的**文件名**,但不把 `.buil 中的几十 GB 内容和二进制加入正文/符号索引。额外索引仅在内存中保存路径,沿用 Core 索引失效与文件增量事件;未被 watcher 监视的生成目录新增条目需刷新索引。 Git 元数据、嵌套工作树、自定义隐藏目录及路径 glob 仍被排除;该开关覆盖内置生成 -目录名称排除,不改变项目树、正文搜索、替换和 watcher 的范围。每来源 200 项、 -四类总计 800 项是有界候选,不等于 IDEA 的无限分页或完整项目模型。 +目录名称排除,不改变项目树、正文搜索、替换和 watcher 的范围。Search Everywhere 按每来源 +50 项分页;旧同步调用保留自身上限。自动分页不等于 IDEA 的完整项目模型。 漏斗按 `SearchEverywhereFiltersAction` 控制当前已实现的 Classes/Files/Symbols/Actions, 底部支持 All/None/Invert,复用 `litheDropdown` 的原生子面板。All 页包含选中来源的 diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index 40e464ee49..07bc292959 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -167,6 +167,7 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { struct SearchResponsePayload: Decodable, Sendable { let matches: [SearchMatchPayload] + var nextCursor: String? = nil func makeResults(at rootURL: URL) -> [FileSearchResult] { matches.map { match in @@ -189,7 +190,8 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { fileMatches: results.filter { $0.kind == .file }, classMatches: results.filter { $0.kind == .type }, symbolMatches: results.filter { $0.kind == .symbol }, - contentMatches: results.filter { $0.kind == .content } + contentMatches: results.filter { $0.kind == .content }, + nextCursor: nextCursor ) } } @@ -1560,6 +1562,9 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { let hiddenDirectoryNames: [String] let hiddenFilePatterns: [String] var includeGeneratedFileNames = false + var pageSize: Int? = nil + var cursor: String? = nil + var resultKinds: [String]? = nil } private struct ReplacementPreviewRequest: Encodable { @@ -2558,9 +2563,11 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { fileMask: String = "", hiddenDirectoryNames: [String] = [], hiddenFilePatterns: [String] = [], - includeGeneratedFileNames: Bool = false + includeGeneratedFileNames: Bool = false, + operationID: String? = nil, + onEvent: (@Sendable (String) -> Void)? = nil ) -> SearchResponsePayload? { - execute( + let result: Result = executeResult( command: "workspace.searchEverywhere", payload: SearchRequest( root: rootURL.standardizedFileURL.path, @@ -2576,8 +2583,28 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { hiddenDirectoryNames: hiddenDirectoryNames, hiddenFilePatterns: hiddenFilePatterns, includeGeneratedFileNames: includeGeneratedFileNames - ) + ), + operationID: operationID, onEvent: onEvent ) + return try? result.get() + } + + func searchEverywherePage( + at rootURL: URL, query: String, options: ProjectSearchOptions, + hiddenDirectoryNames: [String], hiddenFilePatterns: [String], includeGeneratedFileNames: Bool, + kinds: Set, cursor: String?, operationID: String, + onEvent: @escaping @Sendable (String) -> Void + ) -> Result { + let size = SearchEverywhereResults.pageSize + return executeResult(command: "workspace.searchEverywhere", payload: SearchRequest( + root: rootURL.standardizedFileURL.path, query: query, caseSensitive: options.caseSensitive, + wholeWords: options.wholeWords, regularExpression: options.regularExpression, + maxResults: size * 4, maxFileResults: kinds.contains(.file) ? size : 0, + maxContentResults: kinds.contains(.content) ? size : 0, + maxSymbolResults: !kinds.isDisjoint(with: [.type, .symbol]) ? size : 0, + fileMask: options.fileMask, hiddenDirectoryNames: hiddenDirectoryNames, + hiddenFilePatterns: hiddenFilePatterns, includeGeneratedFileNames: includeGeneratedFileNames, + pageSize: size, cursor: cursor, resultKinds: kinds.map(\.rawValue).sorted()), operationID: operationID, onEvent: onEvent) } func previewReplacement( diff --git a/macos/Sources/Lithe/Core/Rust/RustWorkspaceOperations.swift b/macos/Sources/Lithe/Core/Rust/RustWorkspaceOperations.swift index 403955930c..311dacdffa 100644 --- a/macos/Sources/Lithe/Core/Rust/RustWorkspaceOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustWorkspaceOperations.swift @@ -106,7 +106,9 @@ struct RustWorkspaceOperations: WorkspaceOperations, Sendable { options: ProjectSearchOptions, visibilityRules: FileVisibilityRules, includeGeneratedFileNames: Bool = false, - kinds: Set = [.file, .type, .symbol, .content] + kinds: Set = [.file, .type, .symbol, .content], + operationID: String? = nil, + onEvent: (@Sendable (String) -> Void)? = nil ) -> SearchEverywhereResults? { core.searchEverywhere( at: rootURL, @@ -121,7 +123,8 @@ struct RustWorkspaceOperations: WorkspaceOperations, Sendable { fileMask: options.fileMask, hiddenDirectoryNames: visibilityRules.hiddenDirectoryNames, hiddenFilePatterns: visibilityRules.hiddenFilePatterns, - includeGeneratedFileNames: includeGeneratedFileNames + includeGeneratedFileNames: includeGeneratedFileNames, + operationID: operationID, onEvent: onEvent )?.makeEverywhereResults(at: rootURL) } @@ -229,6 +232,51 @@ extension RustWorkspaceOperations: SearchOperations { searchEverywhere(at: rootURL, query: query, options: options, visibilityRules: FileVisibilityRules(searchRules: visibilityRules), includeGeneratedFileNames: includeGeneratedFileNames, kinds: kinds) } + func searchEverywhere( + at rootURL: URL, query: String, options: ProjectSearchOptions, + visibilityRules: SearchVisibilityRules, includeGeneratedFileNames: Bool, + kinds: Set, + onResults: @escaping @Sendable (SearchEverywhereResults) -> Void + ) async -> SearchEverywhereResults? { + try? await searchEverywherePage(at: rootURL, query: query, options: options, visibilityRules: visibilityRules, + includeGeneratedFileNames: includeGeneratedFileNames, kinds: kinds, cursor: nil, onResults: onResults) + } + + func searchEverywherePage( + at rootURL: URL, query: String, options: ProjectSearchOptions, + visibilityRules: SearchVisibilityRules, includeGeneratedFileNames: Bool, + kinds: Set, cursor: String?, + onResults: @escaping @Sendable (SearchEverywhereResults) -> Void + ) async throws -> SearchEverywhereResults { + struct Batch: Decodable { + let type: String + let results: RustCoreBridge.SearchResponsePayload? + } + let operationID = UUID().uuidString + let cancellation = RustCoreCancellation(core: core, operationID: operationID) + return try await withTaskCancellationHandler { + try Task.checkCancellation() + return try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + guard !cancellation.isCancelled else { continuation.resume(throwing: CancellationError()); return } + let results = core.searchEverywherePage( + at: rootURL, query: query, options: options, + hiddenDirectoryNames: visibilityRules.hiddenDirectoryNames, hiddenFilePatterns: visibilityRules.hiddenFilePatterns, + includeGeneratedFileNames: includeGeneratedFileNames, kinds: kinds, + cursor: cursor, operationID: operationID, onEvent: { raw in + cancellation.receive(raw) + guard !cancellation.isCancelled, + let batch = try? JSONDecoder().decode(Batch.self, from: Data(raw.utf8)), + batch.type == "searchResults", let results = batch.results else { return } + onResults(results.makeEverywhereResults(at: rootURL)) + }) + if cancellation.isCancelled { continuation.resume(throwing: CancellationError()) } + else { continuation.resume(with: results.map { $0.makeEverywhereResults(at: rootURL) }) } + } + } + } onCancel: { cancellation.cancel() } + } + func previewReplacement( at rootURL: URL, query: String, diff --git a/macos/Sources/Lithe/Views/Components/LitheSplitPaneView.swift b/macos/Sources/Lithe/Views/Components/LitheSplitPaneView.swift index 4fa392e619..de52e84c80 100644 --- a/macos/Sources/Lithe/Views/Components/LitheSplitPaneView.swift +++ b/macos/Sources/Lithe/Views/Components/LitheSplitPaneView.swift @@ -137,7 +137,8 @@ struct LitheSplitPaneView: View { @ViewBuilder private var sizedPane: some View { - let pane = sized.frame(maxWidth: .infinity, maxHeight: .infinity, + // A builder can emit zero or multiple views; the split still needs one child per pane. + let pane = VStack(spacing: 0) { sized }.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: axis == .horizontal ? (placement == .leading ? .leading : .trailing) : (placement == .leading ? .top : .bottom)) @@ -150,10 +151,11 @@ struct LitheSplitPaneView: View { @ViewBuilder private var flexiblePane: some View { + let pane = VStack(spacing: 0) { flexible } if axis == .horizontal { - flexible.frame(minWidth: flexibleMinimum, maxWidth: .infinity) + pane.frame(minWidth: flexibleMinimum, maxWidth: .infinity) } else { - flexible.frame(minHeight: flexibleMinimum, maxHeight: .infinity) + pane.frame(minHeight: flexibleMinimum, maxHeight: .infinity) } } diff --git a/macos/Sources/Lithe/Views/Search/SearchEverywhereView.swift b/macos/Sources/Lithe/Views/Search/SearchEverywhereView.swift index b482021fa5..26eaf13505 100644 --- a/macos/Sources/Lithe/Views/Search/SearchEverywhereView.swift +++ b/macos/Sources/Lithe/Views/Search/SearchEverywhereView.swift @@ -147,7 +147,16 @@ private struct SearchEverywhereContent: View { get { draftQuery ?? session.everywhereQuery } nonmutating set { draftQuery = newValue; session.everywhereQuery = newValue } } - @State private var selectedIndex = 0 + @State private var pageRequest = 0 + @State private var retryPage = false + // nil follows the current first result until the user explicitly selects a row. + @State private var selectedItemID: String? + private var selectedIndex: Int { + get { visibleItems.firstIndex { $0.id == selectedItemID } ?? 0 } + nonmutating set { + selectedItemID = visibleItems.indices.contains(newValue) ? visibleItems[newValue].id : nil + } + } // New searches and keyboard navigation reveal rows; mouse selection preserves the viewport. @State private var selectionRevealRevision = 0 private var scope: SearchEverywhereScope { @@ -167,9 +176,16 @@ private struct SearchEverywhereContent: View { } @State private var keyMonitor: Any? - private enum SearchItem { + private enum SearchItem: Identifiable { case result(FileSearchResult) case action(LitheAction) + + var id: String { + switch self { + case .result(let result): "result:\(result.id)" + case .action(let action): "action:\(action.id)" + } + } } private var visibleItems: [SearchItem] { @@ -179,8 +195,10 @@ private struct SearchEverywhereContent: View { let nameMatches = (enabledProviders.contains(.files) ? values.fileMatches : []) + (enabledProviders.contains(.classes) ? values.classMatches : []) + (enabledProviders.contains(.symbols) ? values.symbolMatches : []) - return rankedResults(nameMatches) + let names = rankedResults(nameMatches) + return names.filter { pageNumber($0) == 0 } + (enabledProviders.contains(.actions) ? actionMatches(query).map(SearchItem.action) : []) + + names.filter { pageNumber($0) > 0 } case .classes: return results(in: feature.searchEverywhereResults.classMatches) case .files: @@ -218,11 +236,23 @@ private struct SearchEverywhereContent: View { ) } ranked.sort { left, right in - left.score == right.score ? left.index < right.index : left.score > right.score + let leftPage = feature.searchEverywherePageByID[left.value.id] ?? 0 + let rightPage = feature.searchEverywherePageByID[right.value.id] ?? 0 + if leftPage != rightPage { return leftPage < rightPage } + return left.score == right.score ? left.index < right.index : left.score > right.score } return ranked.map { SearchItem.result($0.value) } } + private func pageNumber(_ item: SearchItem) -> Int { + guard case .result(let result) = item else { return 0 } + return feature.searchEverywherePageByID[result.id] ?? 0 + } + + private var searchTaskIdentity: String { + "\(queryMode.workspaceSearchTaskID)|\(scope.rawValue)|\(session.everywhereResultKinds.map(\.rawValue).sorted())|\(searchOptions.cacheKey)|\(settings.searchExcludedPathPatterns)|\(settings.searchExclusionsEnabled)" + } + private var hasQuery: Bool { !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } @@ -249,23 +279,34 @@ private struct SearchEverywhereContent: View { SearchEverywhereExclusionsEditor(settings: settings, dismiss: { editingExclusions = false }) }.frame(width: 0, height: 0).allowsHitTesting(false)) .onAppear { - selectedIndex = 0 + selectedItemID = nil installKeyMonitor() } .onDisappear { removeKeyMonitor() } .onChange(of: query) { _ in - selectedIndex = 0 + selectedItemID = nil selectionRevealRevision &+= 1 } .onChange(of: scope) { _ in - selectedIndex = 0 + selectedItemID = nil selectionRevealRevision &+= 1 } .onChange(of: enabledProviders) { _ in - selectedIndex = 0 + selectedItemID = nil selectionRevealRevision &+= 1 } - .task(id: "\(queryMode.workspaceSearchTaskID)|\(scope.rawValue)|\(session.everywhereResultKinds.map(\.rawValue).sorted())|\(searchOptions.cacheKey)|\(settings.searchExcludedPathPatterns)|\(settings.searchExclusionsEnabled)") { + .onChange(of: visibleItems.map(\.id)) { ids in + if let selectedItemID, !ids.contains(selectedItemID) { self.selectedItemID = nil } + } + .onChange(of: searchTaskIdentity) { _ in + feature.cancelSearchEverywhere() + pageRequest = 0 + } + .task(id: pageRequest) { + guard pageRequest > 0 else { return } + await feature.loadNextSearchPage(retry: retryPage) + } + .task(id: searchTaskIdentity) { if queryMode.commandQuery != nil || session.everywhereResultKinds.isEmpty { feature.clearSearchEverywhere() return @@ -402,58 +443,48 @@ private struct SearchEverywhereContent: View { @ViewBuilder private var resultsList: some View { - if visibleItems.isEmpty { + let items = visibleItems + let selectedID = items.first(where: { $0.id == selectedItemID })?.id ?? items.first?.id + if items.isEmpty { if !feature.isSearchingEverywhere { placeholder(resultSource.emptyResultsMessage) } } else { LitheTreeList( - Array(visibleItems.enumerated()), id: \.offset, - selection: Binding( - get: { selectedIndex }, set: { if let index = $0 { selectedIndex = index } }), + Array(items.enumerated()), id: \.element.id, + selection: Binding(get: { selectedID }, set: { selectedItemID = $0 }), retainsSearchFocus: true ) { index, item in - itemRow(item, index: index) + itemRow(item, index: index, selected: item.id == selectedID) + .background { + // A lazy list may not instantiate its last row until it is visible. + if index == max(0, items.count - Int(LitheScrollLoadTrigger.prefetchDistance / LitheTheme.Tree.rowHeight)) { + LitheScrollLoadTrigger(continuationID: feature.searchEverywhereResults.nextCursor, + isEnabled: !feature.isSearchingEverywhere && !feature.searchEverywhereFailed) { + retryPage = false + pageRequest += 1 + } + } + } } - if isTruncated { moreRow } } - } - - /// 后端按 matchLimit 截断,命中数刚好顶到上限时提示还有更多。 - private var isTruncated: Bool { - guard queryMode.commandQuery == nil else { return false } - let values = feature.searchEverywhereResults - let buckets: [[FileSearchResult]] - switch resultSource { - case .combinedWorkspaceNames: - buckets = (enabledProviders.contains(.files) ? [values.fileMatches] : []) - + (enabledProviders.contains(.classes) ? [values.classMatches] : []) - + (enabledProviders.contains(.symbols) ? [values.symbolMatches] : []) - case .files: buckets = [values.fileMatches] - case .classes: buckets = [values.classMatches] - case .symbols: buckets = [values.symbolMatches] - case .text: buckets = [values.contentMatches] - case .actions, .commands: buckets = [] - } - return buckets.contains { $0.count >= SearchEverywhereResults.providerLimit } - } - - private var moreRow: some View { - Text("… more") - .font(LitheTheme.uiFont(size: 11)) - .foregroundStyle(LitheTheme.tertiaryText) - .padding(.horizontal, 12) - .frame(maxWidth: .infinity, alignment: .leading) - .frame(height: 22) + if feature.searchEverywhereFailed { + Button("Retry") { + retryPage = true + pageRequest += 1 + } + .buttonStyle(.plain) + .padding(8) + } } @ViewBuilder - private func itemRow(_ item: SearchItem, index: Int) -> some View { + private func itemRow(_ item: SearchItem, index: Int, selected: Bool) -> some View { switch item { case .result(let result): - resultRow(result, index: index, showsLine: result.kind != .file) + resultRow(result, index: index, selected: selected, showsLine: result.kind != .file) case .action(let action): - actionRow(action, index: index) + actionRow(action, selected: selected) } } @@ -466,13 +497,13 @@ private struct SearchEverywhereContent: View { .frame(height: 34) } - private func resultRow(_ result: FileSearchResult, index: Int, showsLine: Bool) -> some View { + private func resultRow(_ result: FileSearchResult, index: Int, selected: Bool, showsLine: Bool) -> some View { // 文件夹按钮与整行点击是两个独立目标,所以并排放而不是嵌套, // 否则嵌套的 Button 收不到点击。 HStack(spacing: 8) { Button { let event = NSApp.currentEvent - if showsPreview && event?.type != .keyDown && event?.clickCount != 2 { selectedIndex = index } + if showsPreview && event?.type != .keyDown && event?.clickCount != 2 { selectedItemID = SearchItem.result(result).id } else { openResult(result) } } label: { HStack(spacing: 8) { @@ -480,7 +511,7 @@ private struct SearchEverywhereContent: View { .frame(width: 16) // 对齐 IDEA:名字和路径左侧连排,而不是把路径推到右端。 - Text(SearchEverywhereNameText.highlighted(result, selected: index == selectedIndex, + Text(SearchEverywhereNameText.highlighted(result, selected: selected, foreground: session.everywhereGitStatus.change(relativePath: result.url.standardizedFileURL.path)?.fileNameColor ?? LitheTheme.primaryText)) .font(LitheTheme.uiFont(size: 13)) @@ -563,13 +594,13 @@ private struct SearchEverywhereContent: View { moduleLabel(url) } - private func actionRow(_ action: LitheAction, index: Int) -> some View { + private func actionRow(_ action: LitheAction, selected: Bool) -> some View { Button { performAction(action) } label: { HStack(spacing: 8) { if let icon = actionIcon(action) { - icon.foregroundStyle(index == selectedIndex ? LitheTheme.toolWindowSelectedText : LitheTheme.secondaryText) + icon.foregroundStyle(selected ? LitheTheme.toolWindowSelectedText : LitheTheme.secondaryText) } else { Color.clear.frame(width: WorkbenchContributionIcon.Role.action.size) } diff --git a/macos/Sources/LitheSearchModule/Application/SearchFeatureModel.swift b/macos/Sources/LitheSearchModule/Application/SearchFeatureModel.swift index 2a6796c4ab..8ef08436a6 100644 --- a/macos/Sources/LitheSearchModule/Application/SearchFeatureModel.swift +++ b/macos/Sources/LitheSearchModule/Application/SearchFeatureModel.swift @@ -17,6 +17,10 @@ public final class SearchFeatureModel: ObservableObject { contentMatches: [] ) @Published public private(set) var isSearchingEverywhere = false + @Published public private(set) var searchEverywhereFailed = false + @Published public private(set) var searchEverywherePageByID: [String: Int] = [:] + private var nextSearchPage: (@MainActor () async -> Void)? + private var searchPageNumber = 0 @Published public private(set) var projectReplacementFiles: [ProjectReplacementFile] = [] @Published public private(set) var isLoadingProjectReplacement = false @@ -24,6 +28,7 @@ public final class SearchFeatureModel: ObservableObject { private var indexTask: Task? private var indexTaskGeneration = 0 private var everywhereSearchGeneration = 0 + private var everywhereSearchTask: Task? private var replacementPreviewGeneration = 0 public var hasActiveModuleWork: Bool { @@ -40,8 +45,7 @@ public final class SearchFeatureModel: ObservableObject { indexTask = nil searchResults = [] isSearching = false - searchEverywhereResults = SearchEverywhereResults(fileMatches: [], contentMatches: []) - isSearchingEverywhere = false + clearSearchEverywhere() projectReplacementFiles = [] isLoadingProjectReplacement = false } @@ -143,10 +147,20 @@ public final class SearchFeatureModel: ObservableObject { isSearching = false } + public func cancelSearchEverywhere() { + everywhereSearchGeneration &+= 1 + everywhereSearchTask?.cancel() + everywhereSearchTask = nil + nextSearchPage = nil + searchEverywhereFailed = false + isSearchingEverywhere = false + } + public func clearSearchEverywhere() { - everywhereSearchGeneration += 1 + cancelSearchEverywhere() + searchPageNumber = 0 + searchEverywherePageByID = [:] searchEverywhereResults = SearchEverywhereResults(fileMatches: [], contentMatches: []) - isSearchingEverywhere = false } public func searchEverywhere( @@ -157,38 +171,91 @@ public final class SearchFeatureModel: ObservableObject { kinds: Set = [.file, .type, .symbol, .content], isCurrent: @escaping @MainActor () -> Bool ) async { - everywhereSearchGeneration += 1 + clearSearchEverywhere() + guard !kinds.isEmpty, !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } let generation = everywhereSearchGeneration - guard !kinds.isEmpty, !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - clearSearchEverywhere() - return + nextSearchPage = { [weak self] in + guard let self else { return } + await self.loadSearchPage(at: workspaceURL, query: query, options: options, + visibilityRules: visibilityRules, kinds: kinds, generation: generation, isCurrent: isCurrent) } + await nextSearchPage?() + } + public func loadNextSearchPage(retry: Bool = false) async { + guard !isSearchingEverywhere, retry || !searchEverywhereFailed, + searchEverywhereResults.nextCursor != nil || (retry && searchEverywhereFailed) else { return } + await nextSearchPage?() + } + + private func loadSearchPage(at workspaceURL: URL, query: String, options: ProjectSearchOptions, + visibilityRules: SearchVisibilityRules, kinds: Set, generation: Int, + isCurrent: @escaping @MainActor () -> Bool + ) async { + guard generation == everywhereSearchGeneration, !Task.isCancelled, isCurrent() else { return } isSearchingEverywhere = true + searchEverywhereFailed = false + defer { + if generation == everywhereSearchGeneration { + everywhereSearchTask = nil + isSearchingEverywhere = false + } + } await indexTask?.value + guard generation == everywhereSearchGeneration, !Task.isCancelled, isCurrent() else { return } let operations = self.operations - let indexedResults = await Task.detached(priority: .userInitiated) { - operations.searchEverywhere( - at: workspaceURL, - query: query, - options: options, - visibilityRules: visibilityRules, - includeGeneratedFileNames: true, - kinds: kinds - ) ?? SearchEverywhereResults() - }.value - - guard generation == everywhereSearchGeneration, isCurrent() else { - isSearchingEverywhere = false - return + let cursor = searchEverywhereResults.nextCursor + let previous = cursor == nil ? SearchEverywhereResults() : searchEverywhereResults + let pageNumber = searchPageNumber + // Only the first page streams replacement snapshots. Continuations are + // appended atomically so later ranking cannot move the visible viewport. + let (updates, continuation) = AsyncStream.makeStream( + of: Result<(SearchEverywhereResults, Bool), Error>.self, bufferingPolicy: .bufferingNewest(1)) + let worker = Task { + defer { continuation.finish() } + do { + let results = try await operations.searchEverywherePage( + at: workspaceURL, query: query, options: options, visibilityRules: visibilityRules, + includeGeneratedFileNames: true, kinds: kinds, cursor: cursor, + onResults: { if cursor == nil { continuation.yield(.success(($0, false))) } }) + continuation.yield(.success((results, true))) + } catch { continuation.yield(.failure(error)) } + } + everywhereSearchTask = worker + await withTaskCancellationHandler { + for await update in updates { + guard generation == everywhereSearchGeneration, !Task.isCancelled, isCurrent() else { + worker.cancel() + break + } + switch update { + case .failure: + searchEverywhereFailed = true + case .success(let (results, finished)): + func append(_ old: [FileSearchResult], _ incoming: [FileSearchResult]) -> [FileSearchResult] { + var ids = Set(old.map(\.id)) + return old + incoming.filter { ids.insert($0.id).inserted } + } + let files = append(previous.fileMatches, results.fileMatches) + let types = append(previous.classMatches, results.classMatches) + let symbols = append(previous.symbolMatches, results.symbolMatches) + let contents = append(previous.contentMatches, results.contentMatches) + let count = files.count + types.count + symbols.count + contents.count + let progressed = count > previous.totalCount && results.nextCursor != cursor + let nextCursor = finished ? (progressed ? results.nextCursor : nil) : cursor + searchEverywhereResults = .init(fileMatches: files, classMatches: types, + symbolMatches: symbols, contentMatches: contents, nextCursor: nextCursor) + var pageByID = cursor == nil ? [:] : searchEverywherePageByID + for item in results.allMatches where pageByID[item.id] == nil { pageByID[item.id] = pageNumber } + searchEverywherePageByID = pageByID + if finished { searchPageNumber += 1 } + } + } + await worker.value + } onCancel: { + worker.cancel() + continuation.finish() } - searchEverywhereResults = SearchEverywhereResults( - fileMatches: indexedResults.fileMatches, - classMatches: indexedResults.classMatches, - symbolMatches: indexedResults.symbolMatches, - contentMatches: indexedResults.contentMatches - ) - isSearchingEverywhere = false } /// File-name matches for a completion list. Unlike Search Everywhere this diff --git a/macos/Sources/LitheSearchModule/Models/SearchResults.swift b/macos/Sources/LitheSearchModule/Models/SearchResults.swift index db0c2e7577..56ff291597 100644 --- a/macos/Sources/LitheSearchModule/Models/SearchResults.swift +++ b/macos/Sources/LitheSearchModule/Models/SearchResults.swift @@ -76,23 +76,28 @@ public struct SearchSymbol: Codable, Hashable, Sendable { } public struct SearchEverywhereResults: Sendable { + public static let pageSize = 50 + // Legacy synchronous consumers (including Agent completion) keep bounded results. public static let providerLimit = 200 public static let matchLimit = providerLimit * 4 public let fileMatches: [FileSearchResult] public let classMatches: [FileSearchResult] public let symbolMatches: [FileSearchResult] public let contentMatches: [FileSearchResult] + public let nextCursor: String? public init( fileMatches: [FileSearchResult] = [], classMatches: [FileSearchResult] = [], symbolMatches: [FileSearchResult] = [], - contentMatches: [FileSearchResult] = [] + contentMatches: [FileSearchResult] = [], + nextCursor: String? = nil ) { self.fileMatches = fileMatches self.classMatches = classMatches self.symbolMatches = symbolMatches self.contentMatches = contentMatches + self.nextCursor = nextCursor } public var allMatches: [FileSearchResult] { diff --git a/macos/Sources/LitheSearchModule/Ports/SearchOperations.swift b/macos/Sources/LitheSearchModule/Ports/SearchOperations.swift index a0418e7554..a588a7c14f 100644 --- a/macos/Sources/LitheSearchModule/Ports/SearchOperations.swift +++ b/macos/Sources/LitheSearchModule/Ports/SearchOperations.swift @@ -30,6 +30,28 @@ public protocol SearchOperations: Sendable { kinds: Set ) -> SearchEverywhereResults? + /// Receives bounded replacement snapshots while the request is running. + func searchEverywhere( + at rootURL: URL, + query: String, + options: ProjectSearchOptions, + visibilityRules: SearchVisibilityRules, + includeGeneratedFileNames: Bool, + kinds: Set, + onResults: @escaping @Sendable (SearchEverywhereResults) -> Void + ) async -> SearchEverywhereResults? + + func searchEverywherePage( + at rootURL: URL, + query: String, + options: ProjectSearchOptions, + visibilityRules: SearchVisibilityRules, + includeGeneratedFileNames: Bool, + kinds: Set, + cursor: String?, + onResults: @escaping @Sendable (SearchEverywhereResults) -> Void + ) async throws -> SearchEverywhereResults + func previewReplacement( at rootURL: URL, query: String, @@ -45,6 +67,40 @@ public protocol SearchOperations: Sendable { } public extension SearchOperations { + func searchEverywhere( + at rootURL: URL, + query: String, + options: ProjectSearchOptions, + visibilityRules: SearchVisibilityRules, + includeGeneratedFileNames: Bool, + kinds: Set, + onResults: @escaping @Sendable (SearchEverywhereResults) -> Void + ) async -> SearchEverywhereResults? { + guard !Task.isCancelled else { return nil } + return await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + continuation.resume(returning: searchEverywhere( + at: rootURL, query: query, options: options, visibilityRules: visibilityRules, + includeGeneratedFileNames: includeGeneratedFileNames, kinds: kinds)) + } + } + } + + func searchEverywherePage( + at rootURL: URL, + query: String, + options: ProjectSearchOptions, + visibilityRules: SearchVisibilityRules, + includeGeneratedFileNames: Bool, + kinds: Set, + cursor: String?, + onResults: @escaping @Sendable (SearchEverywhereResults) -> Void + ) async throws -> SearchEverywhereResults { + guard cursor == nil else { return .init() } + return await searchEverywhere(at: rootURL, query: query, options: options, visibilityRules: visibilityRules, + includeGeneratedFileNames: includeGeneratedFileNames, kinds: kinds, onResults: onResults) ?? SearchEverywhereResults() + } + func warmSearchIndex(at rootURL: URL, visibilityRules: SearchVisibilityRules) {} func updateSearchIndex(at rootURL: URL, changedPaths: [String], visibilityRules: SearchVisibilityRules) {} func invalidateSearchIndex(at rootURL: URL, visibilityRules: SearchVisibilityRules) {} diff --git a/macos/Tests/LitheSearchModuleTests/SearchModuleTests.swift b/macos/Tests/LitheSearchModuleTests/SearchModuleTests.swift index 7b5cd7d6ad..ad2b68ae22 100644 --- a/macos/Tests/LitheSearchModuleTests/SearchModuleTests.swift +++ b/macos/Tests/LitheSearchModuleTests/SearchModuleTests.swift @@ -97,6 +97,108 @@ struct SearchModuleTests { #expect(operations.completedOperations == ["warm", "invalidate", "update"]) } + @Test + func partialResultsStayVisibleWhileSearchingAndOldCompletionCannotFinishANewerQuery() async throws { + let operations = StreamingSearchOperations() + defer { operations.firstFinish.signal(); operations.secondFinish.signal() } + let feature = SearchFeatureModel(operations: operations) + defer { feature.reset() } + let first = Task { await runSearch(feature, query: "first") } + defer { first.cancel() } + try #require(await waitUntil { feature.searchEverywhereResults.fileMatches.first?.preview == "first" }) + #expect(feature.isSearchingEverywhere) + let second = Task { await runSearch(feature, query: "second") } + defer { second.cancel() } + try #require(await waitUntil { feature.searchEverywhereResults.fileMatches.first?.preview == "second" }) + operations.firstFinish.signal() + await first.value + #expect(feature.isSearchingEverywhere, "An old completion must not hide the new query's progress") + #expect(feature.searchEverywhereResults.fileMatches.first?.preview == "second") + operations.secondFinish.signal() + await second.value + #expect(!feature.isSearchingEverywhere) + #expect(feature.searchEverywhereResults.fileMatches.first?.preview == "second-final") + } + + @Test(arguments: [false, true]) + func resetAndTaskCancellationRejectLateBatches(reset: Bool) async throws { + let operations = StreamingSearchOperations() + defer { operations.firstFinish.signal(); operations.secondFinish.signal() } + let feature = SearchFeatureModel(operations: operations) + defer { feature.reset() } + let request = Task { await runSearch(feature, query: "first") } + defer { request.cancel() } + try #require(await waitUntil { !feature.searchEverywhereResults.fileMatches.isEmpty }) + if reset { feature.reset() } else { request.cancel() } + operations.firstFinish.signal() + await request.value + #expect(!feature.isSearchingEverywhere) + #expect(feature.searchEverywhereResults.fileMatches.first?.preview == (reset ? nil : "first")) + } + + @Test(arguments: ["normal", "failure", "duplicate", "repeat"]) + func continuationPagesAppendRetryAndStopWithoutATotalCap(mode: String) async throws { + let operations = PagedSearchOperations(mode: mode) + let feature = SearchFeatureModel(operations: operations) + defer { feature.reset() } + await runSearch(feature, query: "name") + #expect(feature.searchEverywhereResults.totalCount == 50) + #expect(feature.searchEverywhereResults.nextCursor == "50") + await feature.loadNextSearchPage() + if mode == "failure" { + #expect(feature.searchEverywhereFailed) + #expect(feature.searchEverywhereResults.totalCount == 50) + #expect(feature.searchEverywhereResults.nextCursor == "50") + await feature.loadNextSearchPage() + #expect(await operations.calls == 2, "Automatic loading pauses after an error") + await feature.loadNextSearchPage(retry: true) + #expect(!feature.searchEverywhereFailed) + } + if mode == "duplicate" || mode == "repeat" { + #expect(feature.searchEverywhereResults.nextCursor == nil) + #expect(feature.searchEverywhereResults.totalCount == (mode == "duplicate" ? 50 : 100)) + await feature.loadNextSearchPage() + #expect(await operations.calls == 2) + return + } + for _ in 0..<4 { await feature.loadNextSearchPage() } + let results = feature.searchEverywhereResults + #expect(results.totalCount == 257) + #expect(Set(results.allMatches.map(\.id)).count == 257) + #expect(results.nextCursor == nil) + #expect(results.fileMatches.map(\.preview) == (0..<257).map(String.init)) + #expect(results.fileMatches.allSatisfy { feature.searchEverywherePageByID[$0.id] == Int($0.preview)! / 50 }) + let calls = await operations.calls + await feature.loadNextSearchPage() + #expect(await operations.calls == calls) + } + + @Test + func pendingContinuationIsNotDuplicatedAndCannotAppendToANewerQuery() async throws { + let operations = PagedSearchOperations(mode: "blocking") + let feature = SearchFeatureModel(operations: operations) + defer { operations.pageFinish.signal(); feature.reset() } + await runSearch(feature, query: "first") + let page = Task { await feature.loadNextSearchPage() } + defer { page.cancel() } + try #require(await operations.pageStarted.waitUntilSignaled(timeout: .seconds(2))) + await feature.loadNextSearchPage() + #expect(await operations.calls == 2) + await runSearch(feature, query: "second") + operations.pageFinish.signal() + await page.value + #expect(feature.searchEverywhereResults.totalCount == 50) + #expect(feature.searchEverywhereResults.nextCursor == "50") + #expect(!feature.isSearchingEverywhere) + #expect(!feature.searchEverywhereFailed) + } + + private func runSearch(_ feature: SearchFeatureModel, query: String) async { + await feature.searchEverywhere(at: URL(fileURLWithPath: "/test-workspace"), query: query, + options: .default, visibilityRules: .init(hiddenDirectoryNames: [], hiddenFilePatterns: []), + isCurrent: { true }) + } + private func waitUntil( timeout: Duration = .seconds(2), condition: @MainActor () -> Bool @@ -226,3 +328,71 @@ private final class AsyncTestSignal: @unchecked Sendable { } } } + +/// Deliberately returns late callbacks even after cancellation to exercise the +/// feature's generation boundary independently of Core's cooperative cancellation. +private final class StreamingSearchOperations: SearchOperations, @unchecked Sendable { + let firstFinish = DispatchSemaphore(value: 0) + let secondFinish = DispatchSemaphore(value: 0) + + func searchEverywhere(at rootURL: URL, query: String, options: ProjectSearchOptions, + visibilityRules: SearchVisibilityRules, includeGeneratedFileNames: Bool, + kinds: Set, onResults: @escaping @Sendable (SearchEverywhereResults) -> Void + ) async -> SearchEverywhereResults? { + await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { [self] in + func results(_ text: String) -> SearchEverywhereResults { + .init(fileMatches: [.init(url: rootURL.appendingPathComponent(text), line: nil, preview: text, kind: .file)]) + } + onResults(results(query)) + let gate = query == "first" ? firstFinish : secondFinish + #expect(gate.wait(timeout: .now() + 5) == .success) + // A fast source can outrun the main actor; the final response must win. + for index in 0..<300 { onResults(results("\(query)-late-\(index)")) } + continuation.resume(returning: results(query + "-final")) + } + } + } + func search(at rootURL: URL, query: String, options: ProjectSearchOptions, visibilityRules: SearchVisibilityRules) -> [FileSearchResult]? { [] } + func searchEverywhere(at rootURL: URL, query: String, options: ProjectSearchOptions, visibilityRules: SearchVisibilityRules, includeGeneratedFileNames: Bool, kinds: Set) -> SearchEverywhereResults? { nil } + func previewReplacement(at rootURL: URL, query: String, replacement: String, options: ProjectSearchOptions, paths: [String], textOverrides: [String: String], visibilityRules: SearchVisibilityRules) -> [ProjectReplacementFile]? { [] } + func readFile(at rootURL: URL, relativePath: String) -> String? { nil } + func writeFile(_ text: String, at rootURL: URL, relativePath: String) -> Bool { false } +} + +private actor PagedSearchOperations: SearchOperations { + let pageStarted = AsyncTestSignal() + let pageFinish = DispatchSemaphore(value: 0) + let mode: String + var calls = 0 + init(mode: String) { self.mode = mode } + nonisolated func search(at rootURL: URL, query: String, options: ProjectSearchOptions, visibilityRules: SearchVisibilityRules) -> [FileSearchResult]? { [] } + nonisolated func searchEverywhere(at rootURL: URL, query: String, options: ProjectSearchOptions, visibilityRules: SearchVisibilityRules, includeGeneratedFileNames: Bool, kinds: Set) -> SearchEverywhereResults? { .init() } + func searchEverywherePage(at rootURL: URL, query: String, options: ProjectSearchOptions, + visibilityRules: SearchVisibilityRules, includeGeneratedFileNames: Bool, kinds: Set, + cursor: String?, onResults: @escaping @Sendable (SearchEverywhereResults) -> Void + ) async throws -> SearchEverywhereResults { + calls += 1 + if mode == "failure" && calls == 2 { throw NSError(domain: "search-test", code: 1) } + if mode == "blocking" && cursor != nil { + pageStarted.signal() + let gate = pageFinish + await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + #expect(gate.wait(timeout: .now() + 2) == .success) + continuation.resume() + } + } + } + let start = mode == "duplicate" ? 0 : Int(cursor ?? "0")! + let end = min(start + 50, 257) + let matches = (start.. [ProjectReplacementFile]? { [] } + nonisolated func readFile(at rootURL: URL, relativePath: String) -> String? { nil } + nonisolated func writeFile(_ text: String, at rootURL: URL, relativePath: String) -> Bool { false } +} diff --git a/macos/Tests/LitheTests/LitheSplitPaneCollapseTests.swift b/macos/Tests/LitheTests/LitheSplitPaneCollapseTests.swift index 9e5c8a1311..0f20609e20 100644 --- a/macos/Tests/LitheTests/LitheSplitPaneCollapseTests.swift +++ b/macos/Tests/LitheTests/LitheSplitPaneCollapseTests.swift @@ -6,6 +6,41 @@ import Testing @MainActor @Suite("Split pane collapse") struct LitheSplitPaneCollapseTests { + @Test("Empty and multi-view pane builders retain separate pane bounds", + arguments: [0, 1, 2], [LitheSplitAxis.horizontal, .vertical]) + func paneBuildersKeepSeparateBounds(childCount: Int, axis: LitheSplitAxis) throws { + let surfaces = CollapseSurfaceRecorder() + let host = NSHostingView(rootView: LitheSplitPaneView( + axis: axis, placement: .leading, defaultSize: 200, minimum: 72, maximum: 400, + flexibleMinimum: 100, sized: { + ForEach(0.. 0 && previewFrame.height > 0) + #expect(host.bounds.contains(previewFrame)) + #expect((axis == .horizontal ? previewFrame.width : previewFrame.height) <= 395) + for index in 0.. 0 && resultFrame.height > 0) + #expect(host.bounds.contains(resultFrame)) + #expect(!resultFrame.intersects(previewFrame)) + } + } + @Test("A declared page size avoids content-driven intrinsic sizing while panes still follow window bounds", arguments: [LitheSplitAxis.horizontal, .vertical]) func declaredPageSizePreservesPaneGeometry(axis: LitheSplitAxis) throws { diff --git a/macos/Tests/LitheTests/SearchEverywhereBehaviorTests.swift b/macos/Tests/LitheTests/SearchEverywhereBehaviorTests.swift index 72a9b62987..5f4ca2f5b0 100644 --- a/macos/Tests/LitheTests/SearchEverywhereBehaviorTests.swift +++ b/macos/Tests/LitheTests/SearchEverywhereBehaviorTests.swift @@ -187,6 +187,40 @@ struct SearchEverywhereBehaviorTests { #expect(journal.snapshot.isEmpty && journal.runningOperationIDs.isEmpty) } + @Test + func rustPortDeliversNamesBeforeSymbolsAndCancelsAnObservedRequest() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try "class Name { void NameMethod() {} }".write(to: root.appendingPathComponent("Name.java"), + atomically: true, encoding: .utf8) + let operations = RustWorkspaceOperations(core: RustCoreBridge()) + let batches = OSAllocatedUnfairLock(initialState: [SearchEverywhereResults]()) + let final = try #require(await operations.searchEverywhere(at: root, query: "Name", options: .default, + visibilityRules: .init(hiddenDirectoryNames: [], hiddenFilePatterns: []), includeGeneratedFileNames: true, + kinds: [.file, .type, .symbol], onResults: { batch in batches.withLock { $0.append(batch) } })) + let snapshots = batches.withLock { $0 } + #expect(snapshots.first?.fileMatches.first?.url.lastPathComponent == "Name.java") + #expect(snapshots.first?.classMatches.isEmpty == true) + #expect(snapshots.contains { !$0.classMatches.isEmpty }) + #expect(final.classMatches.first?.symbolName == "Name") + let started = TestGate(), resume = TestGate() + defer { resume.open() } + let cancelled = Task { + await operations.searchEverywhere(at: root, query: "Name", options: .default, + visibilityRules: .init(hiddenDirectoryNames: [], hiddenFilePatterns: []), includeGeneratedFileNames: true, + kinds: [.file, .type], onResults: { _ in + started.open() + #expect(resume.waitSynchronously()) + }) + } + defer { cancelled.cancel() } + try #require(await started.waitUntilOpen()) + cancelled.cancel() + resume.open() + #expect(await cancelled.value == nil) + } + @Test func searchExclusionsPersistWithoutHidingProjectFiles() throws { let suite = "lithe-search-exclusions-" + UUID().uuidString @@ -558,11 +592,159 @@ struct SearchEverywhereBehaviorTests { } @Test(arguments: [ColorScheme.dark, .light]) - func previewSelectionKeepsFocusColorAndMouseScrollPosition(scheme: ColorScheme) async throws { - let operations = PreviewSelectionOperations() + func nativeScrollLoadsPast200AndKeepsViewportAndPreview(scheme: ColorScheme) async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("search-pages-" + UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + for index in 0..<257 { + try "preview".write(to: root.appendingPathComponent(String(format: "name%03d.swift", index)), atomically: true, encoding: .utf8) + } + let feature = SearchFeatureModel(operations: RustWorkspaceOperations(core: RustCoreBridge())) + defer { feature.reset() } + await feature.searchEverywhere(at: root, query: "name", options: .default, + visibilityRules: .init(hiddenDirectoryNames: [], hiddenFilePatterns: []), kinds: [.file], isCurrent: { true }) + #expect(feature.searchEverywhereResults.totalCount == 50) + let session = SearchSessionFeatureModel() + session.beginSearchEverywhere(query: "name") + session.isEverywherePreviewVisible = true + let suite = "search-pages-" + UUID().uuidString + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + var previewedURL: URL? + let view = SearchEverywhereView(feature: feature, session: session, + settings: AppSettings(store: MacUserDefaultsStore(defaults: defaults)), + actionMatches: { _ in [LitheAction(id: "fixture.name", title: "Name action", subtitle: "", group: .window, perform: {})] }, + search: { _, _ in }, dismiss: {}, openResult: { _ in }, performAction: { _ in }, + revealInFinder: { _ in }, copyPath: { _, _ in }, relativePath: { $0.lastPathComponent }, moduleLabel: { _ in "" }, + loadPreviewDocument: { url, _ in EditorDocument(url: url, text: "preview", modificationDate: nil) }, + makePreviewEditor: { document, _ in + previewedURL = document.url + return AnyView(TextField("Preview fixture", text: .constant(document.text))) + }) + let parent = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 1000, height: 800), + styleMask: [.borderless], backing: .buffered, defer: false) + parent.isReleasedWhenClosed = false + parent.appearance = NSAppearance(named: scheme == .dark ? .darkAqua : .aqua) + let host = NSHostingView(rootView: view.environment(\.colorScheme, scheme).frame(width: 1000, height: 800)) + parent.contentView = host + parent.makeKeyAndOrderFront(nil) + defer { session.endSearchEverywhere(); parent.contentView = nil; parent.close() } + func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) } + func settle(_ condition: () -> Bool) async -> Bool { + let deadline = ContinuousClock().now.advanced(by: .seconds(2)) + repeat { + host.layoutSubtreeIfNeeded() + parent.childWindows?.first?.contentView?.layoutSubtreeIfNeeded() + if condition() { return true } + await Task.yield() + } while ContinuousClock().now < deadline + return condition() + } + try #require(await settle { previewedURL != nil }) + let content = try #require(parent.childWindows?.first?.contentView) + let scroll = try #require(descendants(content).compactMap { $0 as? NSScrollView } + .first { ($0.documentView?.bounds.height ?? 0) > $0.contentSize.height }) + let document = try #require(scroll.documentView) + let selected = previewedURL + for expected in [100, 150, 200, 250, 257] { + let ids = feature.searchEverywhereResults.fileMatches.map(\.id) + let offset = NSPoint(x: 0, y: max(0, document.bounds.height - scroll.contentSize.height - 80)) + scroll.contentView.scroll(to: offset) + scroll.reflectScrolledClipView(scroll.contentView) + try #require(await settle { feature.searchEverywhereResults.totalCount == expected && !feature.isSearchingEverywhere }, + "expected \(expected), loaded \(feature.searchEverywhereResults.totalCount), failed \(feature.searchEverywhereFailed), offset \(scroll.contentView.bounds), document \(document.bounds)") + #expect(Array(feature.searchEverywhereResults.fileMatches.prefix(ids.count)).map(\.id) == ids) + #expect(abs(scroll.contentView.bounds.origin.y - offset.y) < 1) + #expect(previewedURL == selected) + #expect(scroll.visibleRect.height >= 72) + } + #expect(feature.searchEverywhereResults.nextCursor == nil) + #expect(Set(feature.searchEverywhereResults.allMatches.map(\.id)).count == 257) + } + + @Test(arguments: [true, false]) + func implicitSelectionFollowsFilesAfterActionsAndBetterCandidates(actionsFirst: Bool) async throws { + let finish = TestGate() + let feature = SearchFeatureModel(operations: PreviewSelectionOperations( + resultCount: 2, finishStreaming: actionsFirst ? nil : finish)) + defer { finish.open(); feature.reset() } + let session = SearchSessionFeatureModel() + session.beginSearchEverywhere(query: "") + session.isEverywherePreviewVisible = true + let suite = "search-default-selection-" + UUID().uuidString + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + var previewed: URL? + var opened: URL? + var actionCount = 0 + let view = SearchEverywhereView(feature: feature, session: session, + settings: AppSettings(store: MacUserDefaultsStore(defaults: defaults)), + actionMatches: { query in actionsFirst && !query.isEmpty ? [LitheAction(id: "fixture.name", title: "Name action", subtitle: "", group: .window, perform: {})] : [] }, + search: { _, _ in }, dismiss: {}, openResult: { opened = $0.url }, + performAction: { _ in actionCount += 1 }, revealInFinder: { _ in }, copyPath: { _, _ in }, + relativePath: { $0.lastPathComponent }, moduleLabel: { _ in "" }, + loadPreviewDocument: { url, _ in EditorDocument(url: url, text: "preview", modificationDate: nil) }, + makePreviewEditor: { document, _ in + previewed = document.url + return AnyView(TextField("Preview fixture", text: .constant(document.text))) + }) + let parent = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 1000, height: 800), + styleMask: [.borderless], backing: .buffered, defer: false) + parent.isReleasedWhenClosed = false + let host = NSHostingView(rootView: view) + parent.contentView = host + parent.makeKeyAndOrderFront(nil) + defer { session.endSearchEverywhere(); parent.contentView = nil; parent.close() } + func settle(_ condition: () -> Bool) async -> Bool { + let deadline = ContinuousClock().now.advanced(by: .seconds(2)) + repeat { + host.layoutSubtreeIfNeeded() + parent.childWindows?.first?.contentView?.layoutSubtreeIfNeeded() + if condition() { return true } + await Task.yield() + } while ContinuousClock().now < deadline + return condition() + } + try #require(await settle { parent.childWindows?.first?.initialFirstResponder != nil }) + let popup = try #require(parent.childWindows?.first) + let field = try #require(popup.initialFirstResponder as? NSTextField) + try #require(await settle { field.currentEditor() != nil }) + let editor = try #require(field.currentEditor() as? NSTextView) + editor.insertText("name", replacementRange: editor.selectedRange()) + try #require(await settle { field.stringValue == "name" && popup.frame.height > 84 }) + func enter() throws { + NSApp.sendEvent(try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], + timestamp: 0, windowNumber: popup.windowNumber, context: nil, characters: "\r", charactersIgnoringModifiers: "\r", + isARepeat: false, keyCode: 36))) + } + if actionsFirst { try enter(); #expect(actionCount == 1) } + let request = Task { + await feature.searchEverywhere(at: URL(fileURLWithPath: "/fixture"), query: "name", options: .default, + visibilityRules: .init(hiddenDirectoryNames: [], hiddenFilePatterns: []), isCurrent: { true }) + } + defer { finish.open(); request.cancel() } + if !actionsFirst { + try #require(await settle { previewed?.lastPathComponent == "name00.swift" }) + } + finish.open() + await request.value + let expected = actionsFirst ? "name00.swift" : "name00-first.swift" + #expect(await settle { previewed?.lastPathComponent == expected }) + try enter() + #expect(opened?.lastPathComponent == expected) + #expect(actionCount == (actionsFirst ? 1 : 0), "Enter must activate the current first file without navigation") + } + + @Test(arguments: [ColorScheme.dark, .light], [40, 199, SearchEverywhereResults.providerLimit]) + func previewSelectionKeepsFocusColorAndMouseScrollPosition(scheme: ColorScheme, resultCount: Int) async throws { + let finishStreaming = TestGate() + let operations = PreviewSelectionOperations(resultCount: resultCount, finishStreaming: finishStreaming) let feature = SearchFeatureModel(operations: operations) - await feature.searchEverywhere(at: URL(fileURLWithPath: "/fixture"), query: "name", options: .default, - visibilityRules: .init(hiddenDirectoryNames: [], hiddenFilePatterns: []), isCurrent: { true }) + let request = Task { + await feature.searchEverywhere(at: URL(fileURLWithPath: "/fixture"), query: "name", options: .default, + visibilityRules: .init(hiddenDirectoryNames: [], hiddenFilePatterns: []), isCurrent: { true }) + } + defer { finishStreaming.open(); request.cancel(); feature.reset() } let session = SearchSessionFeatureModel() session.everywhereQuery = "name" session.isEverywherePreviewVisible = true @@ -621,23 +803,43 @@ struct SearchEverywhereBehaviorTests { #expect(openedURL == nil, "A single mouse click must only preview") let previewField = try #require(descendants(content).compactMap { $0 as? NSTextField } .first { $0.stringValue == "preview" }) + // Large result sets must retain both panes; it must not displace either pane. + // SwiftUI may extend the native scroll view beyond its clipping host + // to accommodate scroller chrome (17pt on CI). Assert the visible pane. + let listFrame = scroll.convert(scroll.visibleRect, to: content) + let previewFrame = previewField.convert(previewField.bounds, to: content) + #expect(listFrame.width == content.bounds.width) + #expect(listFrame.height >= 72) + #expect(previewFrame.height > 0) + #expect(content.bounds.contains(listFrame)) + #expect(content.bounds.contains(previewFrame)) + #expect(!listFrame.intersects(previewFrame)) #expect(popup.makeFirstResponder(previewField)) await settle { previewField.currentEditor() != nil } #expect(scroll.contentView.bounds.origin == originalOffset, "Mouse selection must not recenter the list") #expect(popup.frame == originalFrame, "Selecting a file must not move or resize the popup") - func selectedColor() throws -> NSColor { + func selectedColor(row: Int = 7) throws -> NSColor { content.layoutSubtreeIfNeeded() - let bitmap = try #require(content.bitmapImageRepForCachingDisplay(in: content.bounds)) - content.cacheDisplay(in: content.bounds, to: bitmap) - let sample = document.convert(NSPoint(x: 400, y: 4 + 7 * 24 + 12), to: content) - let scale = CGFloat(bitmap.pixelsWide) / content.bounds.width - let y = content.isFlipped ? sample.y : content.bounds.height - sample.y - return try #require(bitmap.colorAt(x: Int(sample.x * scale), y: Int(y * scale))?.usingColorSpace(.deviceRGB)) + let sample = document.convert(NSPoint(x: 400, y: 4 + row * 24 + 12), to: content) + // The assertion needs one background pixel, not a full popup snapshot on every poll. + let sampleRect = NSRect(origin: sample, size: NSSize(width: 1, height: 1)) + let bitmap = try #require(content.bitmapImageRepForCachingDisplay(in: sampleRect)) + content.cacheDisplay(in: sampleRect, to: bitmap) + return try #require(bitmap.colorAt(x: 0, y: 0)?.usingColorSpace(.deviceRGB)) } await settle("focused color") { (try? selectedColor()).map { $0.blueComponent > $0.redComponent + 0.1 } == true } let color = try selectedColor() #expect(color.blueComponent > color.redComponent + 0.1, "Preview focus keeps the active selection color") + #expect(feature.isSearchingEverywhere, "The list and preview are usable before the search finishes") + finishStreaming.open() + await request.value + #expect(!feature.isSearchingEverywhere) + await settle("selection survives a higher-ranked candidate arriving") { + previewedURL?.lastPathComponent == "name07.swift" + && (try? selectedColor(row: 8)).map { $0.blueComponent > $0.redComponent + 0.1 } == true + } + #expect(scroll.contentView.bounds.origin == originalOffset) let searchField = try #require(popup.initialFirstResponder as? NSTextField) #expect(popup.makeFirstResponder(searchField)) for _ in 0..<20 { @@ -645,6 +847,8 @@ struct SearchEverywhereBehaviorTests { timestamp: 0, windowNumber: popup.windowNumber, context: nil, characters: "", charactersIgnoringModifiers: "", isARepeat: false, keyCode: 125))) await Task.yield() + // Stop once native keyboard navigation has demonstrated the required scroll. + if scroll.contentView.bounds.origin.y > originalOffset.y { break } } await settle { scroll.contentView.bounds.origin.y > originalOffset.y } #expect(scroll.contentView.bounds.origin.y > originalOffset.y, "Keyboard navigation must still reveal offscreen rows") @@ -655,14 +859,14 @@ struct SearchEverywhereBehaviorTests { // The first row begins after the list's 4pt vertical padding. let firstRow = NSRect(x: 0, y: 4, width: 1, height: 24) await settle("new query starts at the first result") { - previewedURL?.lastPathComponent == "name00.swift" && scroll.contentView.bounds.contains(firstRow) + previewedURL?.lastPathComponent == "name00-first.swift" && scroll.contentView.bounds.contains(firstRow) } #expect(scroll.contentView.bounds.contains(firstRow)) // Non-mouse activation must open even while the preview is visible. NSApp.sendEvent(try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], timestamp: 0, windowNumber: popup.windowNumber, context: nil, characters: "\r", charactersIgnoringModifiers: "\r", isARepeat: false, keyCode: 36))) - #expect(openedURL?.lastPathComponent == "name00.swift", "Return must open the selected result with preview enabled") + #expect(openedURL?.lastPathComponent == "name00-first.swift", "Return must open the selected result with preview enabled") } @Test(arguments: [ColorScheme.dark, .light]) @@ -973,17 +1177,33 @@ struct SearchEverywhereBehaviorTests { } private struct PreviewSelectionOperations: SearchOperations { + var resultCount = 40 + var finishStreaming: TestGate? var onInvalidate: @Sendable () -> Void = {} var onSearch: @Sendable (Set) -> Void = { _ in } func invalidateSearchIndex(at rootURL: URL, visibilityRules: SearchVisibilityRules) { onInvalidate() } func search(at rootURL: URL, query: String, options: ProjectSearchOptions, visibilityRules: SearchVisibilityRules) -> [FileSearchResult]? { [] } func searchEverywhere(at rootURL: URL, query: String, options: ProjectSearchOptions, visibilityRules: SearchVisibilityRules, includeGeneratedFileNames: Bool, kinds: Set) -> SearchEverywhereResults? { onSearch(kinds) - return .init(fileMatches: (0..<40).map { index in + return .init(fileMatches: (0.., + onResults: @escaping @Sendable (SearchEverywhereResults) -> Void + ) async -> SearchEverywhereResults? { + guard let initial = searchEverywhere(at: rootURL, query: query, options: options, + visibilityRules: visibilityRules, includeGeneratedFileNames: includeGeneratedFileNames, kinds: kinds) + else { return nil } + onResults(initial) + guard let finishStreaming else { return initial } + #expect(await finishStreaming.waitUntilOpen(timeout: .seconds(10))) + let higherRanked = FileSearchResult(url: rootURL.appendingPathComponent("name00-first.swift"), + line: nil, preview: "", kind: .file, matchingDegree: 1000) + return .init(fileMatches: [higherRanked] + initial.fileMatches.dropLast()) + } func previewReplacement(at rootURL: URL, query: String, replacement: String, options: ProjectSearchOptions, paths: [String], textOverrides: [String: String], visibilityRules: SearchVisibilityRules) -> [ProjectReplacementFile]? { [] } func readFile(at rootURL: URL, relativePath: String) -> String? { nil } func writeFile(_ text: String, at rootURL: URL, relativePath: String) -> Bool { false } diff --git a/rust/lithe-core/src/git/execution_events.rs b/rust/lithe-core/src/git/execution_events.rs index 4d9e089576..9ba45c460e 100644 --- a/rust/lithe-core/src/git/execution_events.rs +++ b/rust/lithe-core/src/git/execution_events.rs @@ -87,7 +87,8 @@ impl Drop for ObserverScope { } } -pub(super) fn has_sink() -> bool { +/// Whether the current request has an incremental event consumer. +pub(crate) fn has_sink() -> bool { CURRENT.with(|current| current.borrow().is_some()) } diff --git a/rust/lithe-core/src/lib.rs b/rust/lithe-core/src/lib.rs index 45d4696b24..5698e04648 100644 --- a/rust/lithe-core/src/lib.rs +++ b/rust/lithe-core/src/lib.rs @@ -52,7 +52,7 @@ pub fn jdt_workspace_key(workspace_root: &Path, workspace_fingerprint: Option<&s #[cfg(test)] mod tests; -/// Executes a request with sanitized Git process events and Agent install progress. +/// Executes a request with Git diagnostics, search snapshots, and Agent progress. /// The observer is scoped to this call; callbacks complete before the final JSON returns. pub fn execute_json_with_events( request: &str, diff --git a/rust/lithe-core/src/project/files.rs b/rust/lithe-core/src/project/files.rs index 0654ffe3b1..6e4c5bf91b 100644 --- a/rust/lithe-core/src/project/files.rs +++ b/rust/lithe-core/src/project/files.rs @@ -2,6 +2,7 @@ use super::file_name_order::FileNameKey; use super::search_index::{self, UpdateOutcome, WorkspaceSearchIndex}; +use super::search_paging::SearchPage; use crate::protocol::{invalid_relative_path, CoreError, ErrorCode}; use crate::protocol::{ DirectoryListing, DirectoryListingResponse, FileReadResponse, FileWriteResponse, @@ -83,7 +84,7 @@ pub struct DirectoryListingRequest { pub hidden_file_patterns: Vec, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] /// Bounded file, content, and symbol search options. pub struct SearchRequest { @@ -311,7 +312,7 @@ pub fn search(request: SearchRequest) -> Result { let index = index .read() .map_err(|_| CoreError::new(ErrorCode::Unknown, "Search index lock was poisoned"))?; - search_with_index(&root, &request, &query, &index) + search_with_index(&root, &request, &query, &index, None) } fn search_with_index( @@ -319,6 +320,7 @@ fn search_with_index( request: &SearchRequest, query: &str, index: &WorkspaceSearchIndex, + page: Option<&SearchPage>, ) -> Result { let matcher = Matcher::new( query, @@ -361,7 +363,11 @@ fn search_with_index( } let mut content_matches = 0; - for id in index.candidate_ids(query, request.regular_expression) { + let mut candidates = index.candidate_ids(query, request.regular_expression); + if page.is_some() { + candidates.sort_by_key(|id| index.file(*id).map(|file| file.path.as_str())); + } + for id in candidates { crate::protocol::cancellation::check()?; if matches.len() >= limit || content_matches >= content_limit { break; @@ -370,7 +376,7 @@ fn search_with_index( continue; }; let path = &indexed_file.path; - if !file_mask_allows(&masks, path) { + if page.is_some_and(|page| !page.accepts_path(path)) || !file_mask_allows(&masks, path) { continue; } let file = root.join(path); @@ -380,7 +386,7 @@ fn search_with_index( for (index, line) in text.split('\n').enumerate() { crate::protocol::cancellation::check()?; if matcher.matches(line) { - matches.push(SearchMatch { + let result = SearchMatch { kind: "content".to_string(), path: path.clone(), line: Some(index + 1), @@ -389,7 +395,11 @@ fn search_with_index( name_match_ranges: Vec::new(), matching_degree: None, is_ignored: None, - }); + }; + if page.is_some_and(|page| !page.accepts(&result)) { + continue; + } + matches.push(result); content_matches += 1; if matches.len() >= limit || content_matches >= content_limit { break; @@ -403,6 +413,14 @@ fn search_with_index( /// Combines file, content, and Java-symbol results for Search Everywhere. pub fn search_everywhere(request: SearchRequest) -> Result { + search_everywhere_with_page(request, None) +} + +/// Searches one bounded page when a continuation state is supplied. +pub(crate) fn search_everywhere_with_page( + request: SearchRequest, + mut page: Option<&mut SearchPage>, +) -> Result { let root = existing_root(&request.root)?; let query = request.query.trim().to_string(); if query.is_empty() { @@ -422,26 +440,28 @@ pub fn search_everywhere(request: SearchRequest) -> Result 0 || content_limit > 0 { - Some(search_index::get_or_build(&root, &rules)?) + let file_limit = if page.as_ref().is_some_and(|page| page.exhausted("file")) { + 0 } else { - None + request.max_file_results.unwrap_or(limit).min(limit) }; - let index = shared_index + let symbol_limit = if page .as_ref() - .map(|index| { - index - .read() - .map_err(|_| CoreError::new(ErrorCode::Unknown, "Search index lock was poisoned")) - }) - .transpose()?; + .is_some_and(|page| page.exhausted("type") && page.exhausted("symbol")) + { + 0 + } else { + request.max_symbol_results.unwrap_or(limit).min(limit) + }; + let content_limit = if page.as_ref().is_some_and(|page| page.exhausted("content")) { + 0 + } else { + request.max_content_results.unwrap_or(limit).min(limit) + }; let mut files = Vec::new(); let mut types = Vec::new(); let mut symbols = Vec::new(); + let mut last_flush = None; if file_limit > 0 { let name_rules = VisibilityRules { // Generated output is a file-name source, not a content/symbol index. @@ -487,9 +507,50 @@ pub fn search_everywhere(request: SearchRequest) -> Result 0 || content_limit > 0 { + Some(search_index::get_or_build(&root, &rules)?) + } else { + None + }; + let index = shared_index + .as_ref() + .map(|index| { + index + .read() + .map_err(|_| CoreError::new(ErrorCode::Unknown, "Search index lock was poisoned")) + }) + .transpose()?; if symbol_limit > 0 { let index = index .as_ref() @@ -504,30 +565,64 @@ pub fn search_everywhere(request: SearchRequest) -> Result 0 { + if page.is_some() { + content_request.max_results = content_limit + 1; + content_request.max_content_results = Some(content_limit + 1); + } + let mut content = if content_limit > 0 { search_with_index( &root, &content_request, @@ -535,11 +630,15 @@ pub fn search_everywhere(request: SearchRequest) -> Result Result std::cmp::Ordering { + b.matching_degree + .cmp(&a.matching_degree) + .then_with(|| a.path.cmp(&b.path)) + .then_with(|| a.line.cmp(&b.line)) + .then_with(|| a.symbol_name.cmp(&b.symbol_name)) +} + +fn rank_search_bucket(bucket: &mut Vec, cap: usize) { + bucket.sort_by(compare_search_matches); + bucket.truncate(cap); +} + +// Replaceable snapshots let slow hosts keep only the newest batch. Ranking +// before each cap preserves the final top matches even when better hits arrive late. +fn emit_search_results( + buckets: [(&mut Vec, usize); 3], + limit: usize, + last_flush: &mut Option, + now: std::time::Instant, + force: bool, +) { + if !crate::git::execution_events::has_sink() + || buckets.iter().all(|(bucket, _)| bucket.is_empty()) + || (!force + && last_flush.is_some_and(|last| { + now.duration_since(last) < std::time::Duration::from_millis(100) + })) + { + return; + } + let mut matches = Vec::new(); + for (bucket, cap) in buckets { + rank_search_bucket(bucket, cap + 1); + matches.extend( + bucket + .iter() + .take(cap.min(limit.saturating_sub(matches.len()))) + .cloned(), + ); + } + *last_flush = Some(now); + crate::git::execution_events::emit(serde_json::json!({ + "type": "searchResults", "results": SearchResponse { matches } + })); +} + fn match_everywhere_name( request: &SearchRequest, literal: &Matcher, @@ -1544,6 +1691,60 @@ fn is_word_character(character: char) -> bool { mod event_filter_tests { use super::*; + #[test] + fn search_snapshots_merge_at_100ms_and_keep_the_best_bounded_candidates() { + use std::sync::{Arc, Mutex}; + use std::time::{Duration, Instant}; + let events = Arc::new(Mutex::new(Vec::::new())); + let received = events.clone(); + crate::git::execution_events::with_sink( + Arc::new(move |raw| { + received + .lock() + .unwrap() + .push(serde_json::from_str(raw).unwrap()); + }), + || { + let make_match = |name: &str, degree| SearchMatch { + kind: "file".into(), + path: name.into(), + line: None, + preview: name.into(), + symbol_name: None, + name_match_ranges: vec![], + matching_degree: Some(degree), + is_ignored: None, + }; + let mut files = vec![make_match("First", 1)]; + let (mut types, mut symbols, mut last) = (vec![], vec![], None); + let now = Instant::now(); + let mut flush = |files: &mut Vec, milliseconds, force| { + emit_search_results( + [(files, 1), (&mut types, 1), (&mut symbols, 1)], + 1, + &mut last, + now + Duration::from_millis(milliseconds), + force, + ); + }; + flush(&mut files, 0, false); + files.push(make_match("Better", 10)); + flush(&mut files, 99, false); + assert_eq!(events.lock().unwrap().len(), 1); + flush(&mut files, 100, false); + files.push(make_match("Best", 20)); + flush(&mut files, 101, true); + }, + ); + let events = events.lock().unwrap(); + assert_eq!(events.len(), 3); + for (event, path) in events.iter().zip(["First", "Better", "Best"]) { + assert_eq!(event["type"], "searchResults"); + assert_eq!(event["results"]["matches"].as_array().unwrap().len(), 1); + assert_eq!(event["results"]["matches"][0]["path"], path); + } + } + #[test] fn shared_workspace_change_fixture() { let fixture: serde_json::Value = serde_json::from_str(include_str!( diff --git a/rust/lithe-core/src/project/mod.rs b/rust/lithe-core/src/project/mod.rs index a1b711f315..f57fca41ea 100644 --- a/rust/lithe-core/src/project/mod.rs +++ b/rust/lithe-core/src/project/mod.rs @@ -10,6 +10,8 @@ mod maven_dependency_tree; mod maven_test_reports; mod name_matching; mod search_index; +mod search_paging; +pub(crate) use search_paging::{search_page, SearchPageRequest}; pub(crate) use document_lifecycle::*; pub(crate) use files::*; diff --git a/rust/lithe-core/src/project/search_paging.rs b/rust/lithe-core/src/project/search_paging.rs new file mode 100644 index 0000000000..75814449eb --- /dev/null +++ b/rust/lithe-core/src/project/search_paging.rs @@ -0,0 +1,194 @@ +//! Bounded keyset pages over the existing search indexes, without retaining sessions. + +use super::files::{ + compare_search_matches, search_everywhere, search_everywhere_with_page, SearchRequest, +}; +use crate::protocol::{CoreError, ErrorCode, SearchMatch, SearchResponse}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::cmp::Ordering; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +/// Optional paging fields leave the existing synchronous request contract unchanged. +pub(crate) struct SearchPageRequest { + #[serde(flatten)] + search: SearchRequest, + page_size: Option, + cursor: Option, + result_kinds: Option>, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct PageResponse { + #[serde(flatten)] + results: SearchResponse, + next_cursor: Option, +} + +#[derive(Clone, Deserialize, Serialize)] +// A source has one hit per path/line/symbol; long preview text is not cursor state. +struct Position { + degree: Option, + path: String, + line: Option, + symbol: Option, +} + +impl Position { + fn of(item: &SearchMatch) -> Self { + Self { + degree: item.matching_degree, + path: item.path.clone(), + line: item.line, + symbol: item.symbol_name.clone(), + } + } + fn compare(&self, other: &SearchMatch) -> Ordering { + other + .matching_degree + .cmp(&self.degree) + .then_with(|| self.path.cmp(&other.path)) + .then_with(|| self.line.cmp(&other.line)) + .then_with(|| self.symbol.cmp(&other.symbol_name)) + } +} + +#[derive(Deserialize, Serialize)] +/// The query fingerprint prevents a continuation from crossing roots, hosts or options. +pub(crate) struct SearchPage { + fingerprint: String, + after: [Option; 4], + exhausted: [bool; 4], +} + +fn bucket(kind: &str) -> usize { + match kind { + "file" => 0, + "type" => 1, + "symbol" => 2, + _ => 3, + } +} + +impl SearchPage { + pub(crate) fn exhausted(&self, kind: &str) -> bool { + self.exhausted[bucket(kind)] + } + + pub(crate) fn accepts(&self, item: &SearchMatch) -> bool { + let index = bucket(&item.kind); + !self.exhausted[index] + && self.after[index] + .as_ref() + .is_none_or(|after| after.compare(item) == Ordering::Less) + } + + pub(crate) fn accepts_path(&self, path: &str) -> bool { + self.after[3] + .as_ref() + .is_none_or(|after| path >= after.path.as_str()) + } + + pub(crate) fn push(&self, items: &mut Vec, item: SearchMatch, cap: usize) { + if cap == 0 || !self.accepts(&item) { + return; + } + let index = items + .binary_search_by(|candidate| compare_search_matches(candidate, &item)) + .unwrap_or_else(|index| index); + // ponytail: sorted insertion moves at most 201 items per candidate. Use a + // bounded heap if larger page sizes become a measured requirement. + if index <= cap { + items.insert(index, item); + items.truncate(cap + 1); + } + } + + pub(crate) fn finish(&mut self, kind: &str, items: &mut Vec, cap: usize) { + let index = bucket(kind); + self.exhausted[index] = items.len() <= cap; + items.truncate(cap); + if let Some(last) = items.last() { + self.after[index] = Some(Position::of(last)); + } + } +} + +/// Executes legacy requests unchanged, or bounded pages with a query-bound continuation. +pub(crate) fn search_page(request: SearchPageRequest) -> Result { + let Some(size) = request.page_size else { + return search_everywhere(request.search) + .map(|response| serde_json::to_value(response).unwrap()); + }; + if !(1..=200).contains(&size) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Search page size must be between 1 and 200", + )); + } + let identity = + crate::remote::workspace::identity_key(std::path::Path::new(&request.search.root)); + let fingerprint = format!( + "{:x}", + Sha256::digest( + serde_json::to_vec(&(1, &request.search, &request.result_kinds, size, identity)) + .unwrap() + ) + ); + let mut page = if let Some(cursor) = request.cursor { + if cursor.len() > 65536 { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Search cursor is too large", + )); + } + let page: SearchPage = serde_json::from_str(&cursor) + .map_err(|_| CoreError::new(ErrorCode::InvalidRequest, "Invalid search cursor"))?; + if page.fingerprint != fingerprint { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Search cursor belongs to another query", + )); + } + page + } else { + SearchPage { + fingerprint, + after: [None, None, None, None], + exhausted: [false; 4], + } + }; + if let Some(kinds) = &request.result_kinds { + let names = ["file", "type", "symbol", "content"]; + if kinds.iter().any(|kind| !names.contains(&kind.as_str())) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid search result kind", + )); + } + for (index, name) in names.iter().enumerate() { + page.exhausted[index] |= !kinds.iter().any(|kind| kind == name); + } + } + if request.search.query.trim().is_empty() { + page.exhausted = [true; 4]; + } + let mut search = request.search; + search.max_results = size * 4; + search.max_file_results = Some(search.max_file_results.unwrap_or(size).min(size)); + search.max_symbol_results = Some(search.max_symbol_results.unwrap_or(size).min(size)); + search.max_content_results = Some(search.max_content_results.unwrap_or(size).min(size)); + let results = search_everywhere_with_page(search, Some(&mut page))?; + let next_cursor = if page.exhausted.iter().all(|done| *done) { + None + } else { + Some(serde_json::to_string(&page).unwrap()) + }; + Ok(serde_json::to_value(PageResponse { + results, + next_cursor, + }) + .unwrap()) +} diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index 643c16dbd7..976bb6b76a 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -664,7 +664,7 @@ fn execute(request: &str) -> CoreResponse { } } CoreCommand::WorkspaceSearchEverywhere => { - match serde_json::from_value::(parsed.payload) + match serde_json::from_value::(parsed.payload) .map_err(|error| { CoreError::new( ErrorCode::InvalidRequest, @@ -672,7 +672,7 @@ fn execute(request: &str) -> CoreResponse { ) .with_details(error.to_string()) }) - .and_then(project::search_everywhere) + .and_then(project::search_page) { Ok(data) => CoreResponse::success( id, diff --git a/rust/lithe-core/src/tests/project.rs b/rust/lithe-core/src/tests/project.rs index e4080233dc..7f7d5fb8d4 100644 --- a/rust/lithe-core/src/tests/project.rs +++ b/rust/lithe-core/src/tests/project.rs @@ -1409,3 +1409,230 @@ fn document_lifecycle_matches_the_cross_platform_fixture() { ); } } + +#[test] +fn search_everywhere_streams_names_before_content_index_and_preserves_the_final_response() { + use std::sync::{Arc, Mutex}; + let root = temporary_root("search-stream-names-first"); + struct Cleanup(std::path::PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + let _cleanup = Cleanup(root.clone()); + fs::create_dir_all(&root).unwrap(); + let file = root.join("Name.java"); + fs::write(&file, "class Before {}").unwrap(); + let events = Arc::new(Mutex::new(Vec::::new())); + let received = events.clone(); + let request = serde_json::json!({ "command": "workspace.searchEverywhere", "operationId": "stream-search", + "timeoutMilliseconds": 5000, "payload": { "root": root, "query": "Name", "maxResults": 20, + "maxFileResults": 2, "maxSymbolResults": 2, "maxContentResults": 0 } }).to_string(); + let response: Value = serde_json::from_str(&crate::execute_json_with_events( + &request, + Arc::new(move |raw| { + let event: Value = serde_json::from_str(raw).unwrap(); + if event["type"] != "searchResults" { + return; + } + let mut events = received.lock().unwrap(); + if events.is_empty() { + assert_eq!(event["results"]["matches"][0]["kind"], "file"); + // If content was indexed first, this new class would be absent from the final response. + fs::write(&file, "class Name { void NameMethod() {} }").unwrap(); + } + assert_eq!(event["operationId"], "stream-search"); + events.push(event); + }), + )) + .unwrap(); + assert_eq!(response["ok"], true, "{response}"); + assert!(events.lock().unwrap().len() >= 2); + assert!(response["data"]["matches"] + .as_array() + .unwrap() + .iter() + .any(|item| item["kind"] == "type")); + let synchronous: Value = serde_json::from_str(&execute_json(&request)).unwrap(); + assert_eq!(response["data"], synchronous["data"]); + + // A query replaced while the first batch is being delivered stops before later providers. + let cancelled: Value = serde_json::from_str(&crate::execute_json_with_events( + &request, + Arc::new(|raw| { + let event: Value = serde_json::from_str(raw).unwrap(); + if event["type"] == "searchResults" { + assert!(crate::cancel_operation("stream-search")); + } + }), + )) + .unwrap(); + assert_eq!(cancelled["error"]["code"], "cancelled", "{cancelled}"); +} + +#[test] +fn search_everywhere_pages_all_sources_past_200_without_duplicates_or_missing_matches() { + let root = temporary_root("search-keyset-pages"); + struct Cleanup(std::path::PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + let _cleanup = Cleanup(root.clone()); + fs::create_dir_all(&root).unwrap(); + for index in 0..257 { + fs::write( + root.join(format!("Name{index:03}.java")), + format!("class Name{index:03} {{\n void NameMethod{index:03}() {{}}\n}}\n"), + ) + .unwrap(); + } + fs::write(root.join("NameHidden.java"), "class NameHidden {}\n").unwrap(); + let call = |payload: Value| -> Value { + serde_json::from_str(&execute_json(&serde_json::json!({ + "command": "workspace.searchEverywhere", "timeoutMilliseconds": 5000, "payload": payload + }).to_string())).unwrap() + }; + let base = serde_json::json!({ "root": root, "query": "Name", "pageSize": 50, + "hiddenFilePatterns": ["NameHidden.java"] }); + for kinds in [ + vec!["file"], + vec!["type"], + vec!["symbol"], + vec!["content"], + vec!["file", "type", "symbol", "content"], + ] { + let mut payload = base.clone(); + payload["resultKinds"] = serde_json::json!(kinds); + let mut seen = std::collections::HashSet::new(); + let mut sizes = Vec::new(); + for _ in 0..16 { + let response = call(payload.clone()); + assert_eq!(response["ok"], true, "{response}"); + let matches = response["data"]["matches"].as_array().unwrap(); + sizes.push(matches.len()); + assert!(matches.len() <= 50 * kinds.len()); + for item in matches { + assert!(kinds.contains(&item["kind"].as_str().unwrap())); + assert_ne!(item["path"], "NameHidden.java"); + assert!( + seen.insert(( + item["kind"].clone().to_string(), + item["path"].clone().to_string(), + item["line"].clone().to_string(), + item["symbolName"].clone().to_string() + )), + "duplicate {item}" + ); + } + let cursor = &response["data"]["nextCursor"]; + if cursor.is_null() { + break; + } + assert_ne!(cursor, &payload["cursor"]); + payload["cursor"] = cursor.clone(); + } + let counts: Vec = kinds + .iter() + .map(|kind| if *kind == "content" { 514 } else { 257 }) + .collect(); + let expected: Vec = (0..counts.iter().max().unwrap().div_ceil(50)) + .map(|page| { + counts + .iter() + .map(|count| count.saturating_sub(page * 50).min(50)) + .sum() + }) + .collect(); + assert_eq!(sizes, expected, "{kinds:?}"); + assert_eq!(seen.len(), counts.iter().sum::()); + } + let mut payload = base.clone(); + payload["resultKinds"] = serde_json::json!(["file"]); + let first = call(payload.clone()); + payload["cursor"] = first["data"]["nextCursor"].clone(); + for (key, value) in [ + ("query", serde_json::json!("Other")), + ("pageSize", serde_json::json!(49)), + ("resultKinds", serde_json::json!(["type"])), + ("root", serde_json::json!(root.join("other"))), + ("hiddenFilePatterns", serde_json::json!([])), + ("cursor", serde_json::json!("malformed")), + ] { + let mut changed = payload.clone(); + changed[key] = value; + assert_eq!( + call(changed)["ok"], + false, + "cursor must reject a different {key}" + ); + } + for (query, expected) in [("^Name0[0-4][0-9]\\.java$", 50), ("^missing$", 0), ("", 0)] { + let mut exact = base.clone(); + exact["query"] = serde_json::json!(query); + exact["regularExpression"] = serde_json::json!(true); + exact["resultKinds"] = serde_json::json!(["file"]); + let response = call(exact); + assert_eq!(response["ok"], true, "{response}"); + assert_eq!( + response["data"]["matches"].as_array().unwrap().len(), + expected + ); + assert!(response["data"]["nextCursor"].is_null()); + } + let ranked = root.join("ranked"); + for path in [ + "Name.java", + "NameExtra.java", + "OtherName.java", + "NameLongSuffix.java", + "nested/Name.java", + ] { + let file = ranked.join(path); + fs::create_dir_all(file.parent().unwrap()).unwrap(); + fs::write(file, "").unwrap(); + } + let mut ranking = serde_json::json!({"root": ranked, "query": "Name", "maxFileResults": 50, + "maxSymbolResults": 0, "maxContentResults": 0}); + let legacy = call(ranking.clone()); + assert_eq!(legacy["ok"], true); + ranking["pageSize"] = serde_json::json!(2); + let mut matches = Vec::new(); + for _ in 0..3 { + let response = call(ranking.clone()); + assert_eq!(response["ok"], true); + matches.extend( + response["data"]["matches"] + .as_array() + .unwrap() + .iter() + .cloned(), + ); + ranking["cursor"] = response["data"]["nextCursor"].clone(); + } + assert!(ranking["cursor"].is_null()); + assert_eq!(serde_json::json!(matches), legacy["data"]["matches"]); + let large = root.join("large"); + fs::create_dir_all(&large).unwrap(); + fs::write( + large.join("minified.json"), + format!("Name{}\nName second\n", "x".repeat(70_000)), + ) + .unwrap(); + let mut long_line = serde_json::json!({"root": large, "query": "Name", "pageSize": 1, "resultKinds": ["content"]}); + let first = call(long_line.clone()); + assert_eq!(first["ok"], true); + assert!(first["data"]["nextCursor"].as_str().unwrap().len() < 1024); + long_line["cursor"] = first["data"]["nextCursor"].clone(); + let second = call(long_line); + assert_eq!(second["ok"], true); + assert_eq!(second["data"]["matches"][0]["line"], 2); + assert!(second["data"]["nextCursor"].is_null()); + for size in [0, 201] { + let mut invalid = base.clone(); + invalid["pageSize"] = serde_json::json!(size); + assert_eq!(call(invalid)["ok"], false); + } +} diff --git a/rust/lithe-remote/src/server/daemon.rs b/rust/lithe-remote/src/server/daemon.rs index 161b370033..875b8e0fc4 100644 --- a/rust/lithe-remote/src/server/daemon.rs +++ b/rust/lithe-remote/src/server/daemon.rs @@ -166,13 +166,54 @@ pub fn run_proxy(options: &PersistentOptions) -> io::Result<()> { let stream = match UnixStream::connect(&paths.socket) { Ok(stream) => stream, Err(_) => { - start_daemon(options, &paths)?; - wait_for_socket(&paths.socket)? + // Serialize launchers, not just daemon ownership. Otherwise a losing + // daemon can miss the winning socket's whole lifetime and restart + // the session after its explicit shutdown. + let deadline = Instant::now() + DAEMON_START_TIMEOUT; + let _startup = startup_lock(&paths.lock.with_extension("start"), deadline)?; + match UnixStream::connect(&paths.socket) { + Ok(stream) => stream, + Err(_) => { + start_daemon(options, &paths)?; + wait_for_socket(&paths.socket, deadline)? + } + } } }; relay(stream) } +// Keep the inode after unlock: unlinking would let later launchers lock a +// different file while an existing waiter still owns the old one. +fn startup_lock(path: &Path, deadline: Instant) -> io::Result { + let lock = OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .mode(0o600) + .open(path)?; + loop { + // SAFETY: flock operates on a descriptor owned by this process. + if unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 { + return Ok(lock); + } + let error = io::Error::last_os_error(); + if !matches!( + error.kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted + ) { + return Err(error); + } + if Instant::now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "another proxy did not finish starting the session", + )); + } + thread::sleep(DAEMON_START_POLL); + } +} + fn start_daemon(options: &PersistentOptions, paths: &Paths) -> io::Result<()> { let log = OpenOptions::new() .create(true) @@ -204,8 +245,7 @@ fn start_daemon(options: &PersistentOptions, paths: &Paths) -> io::Result<()> { command.spawn().map(|_| ()) } -fn wait_for_socket(socket: &Path) -> io::Result { - let deadline = Instant::now() + DAEMON_START_TIMEOUT; +fn wait_for_socket(socket: &Path, deadline: Instant) -> io::Result { loop { match UnixStream::connect(socket) { Ok(stream) => return Ok(stream), @@ -411,6 +451,25 @@ fn write_pid(mut lock: &File) -> io::Result<()> { mod identity_tests { use super::*; + #[test] + fn startup_lock_is_exclusive_bounded_and_released_on_drop() { + struct Cleanup(PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = fs::remove_file(&self.0); + } + } + let path = std::env::temp_dir().join(format!("lithe-startup-lock-{}", std::process::id())); + let _cleanup = Cleanup(path.clone()); + let owner = startup_lock(&path, Instant::now()).unwrap(); + assert_eq!( + startup_lock(&path, Instant::now()).unwrap_err().kind(), + io::ErrorKind::TimedOut + ); + drop(owner); + let _next = startup_lock(&path, Instant::now()).unwrap(); + } + #[test] fn same_version_binary_rebuilds_cannot_reuse_a_daemon() { let key = "a".repeat(64); diff --git a/rust/lithe-remote/tests/persistence.rs b/rust/lithe-remote/tests/persistence.rs index eeebd03392..a773c70b55 100644 --- a/rust/lithe-remote/tests/persistence.rs +++ b/rust/lithe-remote/tests/persistence.rs @@ -286,9 +286,21 @@ fn concurrent_connections_share_one_daemon() { .filter(|entry| entry.file_name().to_string_lossy().ends_with(".lock")) .count(); assert_eq!(locks, 1); - assert!(daemon.pid().is_some()); - survivor.shutdown(STEP_TIMEOUT); - assert!(daemon.wait_until_stopped()); + let pid = daemon.pid().expect("the shared daemon owns its lock"); + survivor + .request_value( + methods::SERVER_SHUTDOWN, + serde_json::Value::Null, + STEP_TIMEOUT, + ) + .unwrap_or_else(|error| panic!("shutdown failed: {error}\n{}", daemon.log())); + drop(survivor); + assert!( + daemon.wait_until_stopped(), + "daemon {pid} remained or restarted as {:?} after acknowledged shutdown\n{}", + daemon.pid(), + daemon.log() + ); } #[test] diff --git a/shared/contracts/remote-workspace-protocol-v1.md b/shared/contracts/remote-workspace-protocol-v1.md index f6783f8efa..042a03a4d1 100644 --- a/shared/contracts/remote-workspace-protocol-v1.md +++ b/shared/contracts/remote-workspace-protocol-v1.md @@ -19,6 +19,11 @@ socket, lock and log under `` (default `$HOME/.lithe-server`) in directories only the user can access, and rejects peers with another user id. A session has one client at a time: a new connection replaces the current one. A session without a client ends after its idle timeout. +Launchers serialize socket rechecking and daemon creation with a per-identity +`.start` advisory lock in the private `run/` directory, bounded by the existing +startup deadline. The lock is released when its owning descriptor closes; its +inode is retained so concurrent waiters cannot lock different files. This keeps +a redundant launcher from restarting an explicitly closed session. ## Stream diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 085801cc55..16ec7ab9ab 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -2042,6 +2042,47 @@ existing defaults. macOS forwards the selected workspace sources through these existing caps: Actions-only stays local, Text-only requests contents, Files and Agent completion request names, and Classes/Symbols share the symbol index. Search module activation does not eagerly build a content index. + +Optional `pageSize` (1–200 per source), `cursor`, and `resultKinds` +(`file`, `type`, `symbol`, `content`) enable bounded keyset pages. The response +adds nullable `nextCursor`; absent/null means exhausted. macOS uses 50 per source +and loads another page near the scroll edge, with no 200-result total ceiling. +The opaque cursor binds the workspace/remote identity, query, options, source caps, +visibility rules and page size; clients must resend the same request options. +Each source retains at most pageSize + 1 ranked candidates, using the extra item +to detect exhaustion. No all-matches array or retained search session is created. +Name/symbol continuation scans the existing index to select the next ranked page; +text pages follow path/line order and stop after the lookahead match. Pages observe +the live index: edits inserting a match before the cursor require a fresh query. +Requests without pageSize retain the legacy capped response and schema. + +Observed `workspace.searchEverywhere` calls may emit +`{ "type": "searchResults", "operationId": "…", "results": { "matches": […] } }` +on the existing request event channel. Each event replaces the previous snapshot; +never append its matches. File-name candidates are searched and published before +building the content/symbol index. The first candidate is sent immediately; +subsequent name/type/symbol candidates are merged for approximately 100 ms, +with a flush at source boundaries. Each snapshot keeps the same ranked per-source +and total caps as the final response, so a later better candidate may replace an +earlier one. Content matches and Git ignore metadata arrive with the authoritative +final response. A partial snapshot's absent `isIgnored` means unknown, not false. +Callbacks remain request-scoped, use the existing cancellation registration, and +finish before the final JSON returns. Calls without an observer retain the same +complete response schema. Cold name-index enumeration and pending index update/ +invalidation barriers still precede candidates; this is not a 100 ms end-to-end +latency guarantee. + +macOS consumes replaceable snapshots with a newest-only buffer, checks query +generation and cancellation for every update, and keeps selection by result ID +when ranking changes. Closing/resetting or replacing a query cancels its Core +operation; late callbacks cannot overwrite the next query or its loading state. +First-page snapshots remain replaceable; completed continuation pages append with +ID deduplication. macOS keeps each loaded page's order, including actions after the +first names page, so later pages do not insert above the visible viewport. Repeated +cursors or pages with no new IDs stop loading. Failure preserves rows/cursor and +pauses automatic loading until Retry. The input debounce remains 180 ms. Windows +continues to consume the legacy final response; this change does not claim +incremental or automatically paged Windows presentation. Basename matches rank above relative-path fallback matches, even when the query contains no slash (for example `src` matches `src/Foo.swift`). Path-only matches have `matchingDegree: -1` and no basename ranges; literal basename hits use degree @@ -2077,8 +2118,8 @@ file/path globs still apply. It does not enable generated-file content or symbol indexing and does not change `workspace.search`, tree, watcher, or replacement visibility. Names share index invalidation and reported file updates; changes inside unwatched generated directories require workspace/index refresh. No index -files or resources are written to disk. macOS requests 200 matches per bucket -and 800 total; these are bounded candidate limits, not a claim of unbounded IDEA paging. +files or resources are written to disk. macOS Search Everywhere requests pages of +50 matches per source; legacy synchronous consumers retain their requested caps. Java type and method results still use the existing syntax index and include a one-based line, `symbolName`, and source line in `preview`; language-provider workspace-symbol integration and external-library search are not implied. diff --git a/shared/platform-feature-matrix/features/remote-workspace.json b/shared/platform-feature-matrix/features/remote-workspace.json index b46851c862..b70a687c03 100644 --- a/shared/platform-feature-matrix/features/remote-workspace.json +++ b/shared/platform-feature-matrix/features/remote-workspace.json @@ -19,7 +19,9 @@ "rust/lithe-remote/src/server/connection/tests.rs", "rust/lithe-remote/src/server/run_directory.rs", "rust/lithe-remote/tests/run_directories.rs", - "rust/lithe-remote/src/server/upload/tests.rs" + "rust/lithe-remote/src/server/upload/tests.rs", + "rust/lithe-remote/src/server/daemon.rs", + "rust/lithe-remote/tests/persistence.rs" ], "implementationStatus": "partial", "verificationStatus": "pending" @@ -33,5 +35,5 @@ "verificationStatus": "pending" }, "owner": "Remote", - "verification": "Windows 验证连接、密码提示、远程路径、断线和重连;macOS 从欢迎页远程开发入口连接 Linux SSH 主机,验证文件树、编辑保存冲突、断线通知与重连。macOS 路由与断线 fixture 已有自动测试,真实 SSH 流程仍待验收;完整远程开发进度见 remote-development-workspace。 文件树新建、目录、复制、重命名与主机废纸篓已接入,14 项 Swift 路由/监听 fixture 通过;真实 SSH 验证外部创建、编辑、移动、删除、溢出重扫和 watcher 取消后无资源残留。 构建与插件接入进度同步至 remote-development-workspace;本行保持 partial/pending,不能以本机 fixture 替代真实 SSH 验收。 通用资源启动在读取请求时预留身份,启动期间的关闭由创建者保留最终清理责任;验证清理早于注册、工作线程饱和、失败启动、无效关闭参数,以及不同资源独立性;区分常驻会话断线保留、非驻留断线清理和会话关闭/空闲到期后的迟到资源清理。RemotePluginLaunchPreparerTests 使用真实声明覆盖目标架构、远端状态目录、缺失资源、准备中重连和取消清理;本机 fixture 不替代 SSH/Linux 验收。Java/JDT 等语言专属集成不在本轮通用能力续作范围。 会话拥有完整插件快照:验证分配回复丢失后按原身份释放、重复释放不接管已有目录、跨会话隔离、父目录权限与链接拒绝、删除失败(包括等待创建者清理)保留原身份供重试、普通断线清理、常驻断线保留及关闭/空闲到期回收;服务端强杀后的回收仍未实现。上传验证同文件并发 BUSY、不同文件独立提交,以及取消/到期不等待文件锁。Agent 后续单独处理,本轮不扩展。" + "verification": "Windows 验证连接、密码提示、远程路径、断线和重连;macOS 从欢迎页远程开发入口连接 Linux SSH 主机,验证文件树、编辑保存冲突、断线通知与重连。macOS 路由与断线 fixture 已有自动测试,真实 SSH 流程仍待验收;完整远程开发进度见 remote-development-workspace。 文件树新建、目录、复制、重命名与主机废纸篓已接入,14 项 Swift 路由/监听 fixture 通过;真实 SSH 验证外部创建、编辑、移动、删除、溢出重扫和 watcher 取消后无资源残留。 构建与插件接入进度同步至 remote-development-workspace;本行保持 partial/pending,不能以本机 fixture 替代真实 SSH 验收。 通用资源启动在读取请求时预留身份,启动期间的关闭由创建者保留最终清理责任;验证清理早于注册、工作线程饱和、失败启动、无效关闭参数,以及不同资源独立性;区分常驻会话断线保留、非驻留断线清理和会话关闭/空闲到期后的迟到资源清理。RemotePluginLaunchPreparerTests 使用真实声明覆盖目标架构、远端状态目录、缺失资源、准备中重连和取消清理;本机 fixture 不替代 SSH/Linux 验收。Java/JDT 等语言专属集成不在本轮通用能力续作范围。 会话拥有完整插件快照:验证分配回复丢失后按原身份释放、重复释放不接管已有目录、跨会话隔离、父目录权限与链接拒绝、删除失败(包括等待创建者清理)保留原身份供重试、普通断线清理、常驻断线保留及关闭/空闲到期回收;服务端强杀后的回收仍未实现。上传验证同文件并发 BUSY、不同文件独立提交,以及取消/到期不等待文件锁。Agent 后续单独处理,本轮不扩展。 常驻服务并发启动按身份先取得有界.start文件锁,重新检查socket后才创建daemon;锁覆盖至socket就绪,关闭描述符释放,inode保留。验证互斥、立即期限、释放后可重取,以及竞争连接收到server.shutdown确认后daemon不重新启动。macOS真实进程回归与Linux/macOS编译检查分别记录,Linux运行结果以CI为准。" } diff --git a/shared/platform-feature-matrix/features/search-global.json b/shared/platform-feature-matrix/features/search-global.json index ababfe5f6f..37dab4a133 100644 --- a/shared/platform-feature-matrix/features/search-global.json +++ b/shared/platform-feature-matrix/features/search-global.json @@ -17,7 +17,14 @@ "macos/Sources/Lithe/Application/Features/DocumentFeatureModel.swift", "shared/fixtures/search/path-fallback.json", "macos/Sources/LitheSearchModule/Application/SearchFeatureModel.swift", - "shared/fixtures/search/unicode-contiguous.json" + "shared/fixtures/search/unicode-contiguous.json", + "macos/Sources/Lithe/Views/Components/LitheSplitPaneView.swift", + "macos/Tests/LitheTests/LitheSplitPaneCollapseTests.swift", + "rust/lithe-core/src/project/files.rs", + "macos/Sources/Lithe/Core/Rust/RustWorkspaceOperations.swift", + "macos/Tests/LitheSearchModuleTests/SearchModuleTests.swift", + "rust/lithe-core/src/project/search_paging.rs", + "macos/Sources/Lithe/Views/Components/LitheScrollLoadTrigger.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -31,5 +38,5 @@ "verificationStatus": "pending" }, "owner": "Search", - "verification": "使用快捷键搜索命令、设置和工作区内容,确认结果来源和跳转行为。 macOS 双 Shift 打开 Search Everywhere,按 Community Islands 源码核对普通字重的圆角分类选中框、统一弹窗背景、内嵌圆角搜索框及 IDEA 明暗搜索/筛选/关闭图标;在深浅主题与窄窗口检查外框裁剪、搜索框不越界、输入焦点与中文组词,切换分类、清除、筛选、Esc、点击外部及执行命令保持原行为。 搜索弹窗使用独立无边框 NSPanel 和系统 NORMAL 阴影,不绘制 SwiftUI 模糊投影;父窗口移动/缩放后仍居中于顶部指定位置,点击外部与失焦关闭,筛选子菜单获焦时搜索保留;关闭与重新打开均无残留子窗口。 空查询只显示分类与搜索框,输入后展开、清空后收起;原生窗口使用 sRGB backing store,顶部分类复用共享标签聚焦/失焦状态,并从面板自身读取 controlActiveState,父窗口失焦不将活动分类错误画灰。 打开后无需点击即可直接输入首个字符;搜索进行中在输入框右侧、命令提示之前显示加载指示器,分类栏不重复显示;完成后加载指示器消失,输入后命令提示仍保留。 使用同一工作区输入 name,确认 .build/checkouts/Name.swift、rust/target 中的名称及 NativeMenu/navigation-menu 的分段命中;文件名范围来自 Core 的 UTF-16 nameMatchRanges,选中行高亮,名称先排序再按来源截断。漏斗只筛选已实现的 Classes/Files/Symbols/Actions,All/None/Invert 生效,All 包含动作;单独标签不受 All 来源筛选影响。预览按钮开启后,单击/上下键选中文件更新独立只读 Monaco,不能输入、格式化或重命名;回车/双击仍打开,关闭预览不改变主编辑器可写状态或未保存内容。生成目录名称更新沿用索引刷新;未覆盖 IDEA 的外部库、语言服务全量符号、历史/机器学习排序及无限分页。 点击只读预览后结果行仍为活动焦点色;鼠标选择文件保持列表滚动偏移和面板位置/尺寸不变,上下键仅在必要时滚动显示选中项,不强制居中。 新查询或切换来源仍恢复首项可见。 在深浅主题与宽窄窗口按住顶部空白区拖向右下,确认窗口实际位置改变(原生命中区域须填满标题栏高度),输入展开、预览切换和父窗缩放后保持拖动位置,重开恢复默认;标签/输入/按钮不被拖动区域抢占。筛选行按名称排序,使用原始 IDEA 明暗复选框与共享行悬停/选中,固定底部 72×28 All/None/Invert 按钮,200pt 最小高度、共享弹层外框;连续勾选和批量操作不关闭菜单。 筛选内容背景沿 Islands Table/Panel 继承 tool-window-bg(深/浅 #191A1C/#FFFFFF),共享原生下拉面板使用 sRGB;复选框保留 24pt viewBox 自带边距,不叠加普通菜单图标的水平内边距。 筛选行 hover 是共享失焦灰,点击才保留主体蓝,移动到另一行同时保留蓝色选中行与灰色 hover 行,hover 不改变勾选。批量按钮上方使用滚动列表的 1pt 内侧矩形底边(Borders.color,深/浅 #26282C/#D1D3D9),不使用系统 Divider。 关闭时按来源保存非空查询,会话内每来源去重保留 50 条,All 重开恢复最后草稿并全选(包括空草稿);清空关闭后重开必须为空,不从非空历史回填,项目 reset 清除历史,侧栏查询保持独立。输入框清除按钮使用 16pt 槽位。结果文件名复用 Git 文件状态前景色,选中匹配片段保留金底黑字;被 Git 忽略的文件由 Core 批量 check-ignore 获取真实规则,包含 info/exclude、否定规则和已跟踪文件例外,按 Islands Dark/Light 的 #D69A6B/#8C4F00 着色;Git 不可用时保留未知,不按目录名猜测。右上角设置左侧的搜索图标与双 Shift 调用同一动作,提示快捷键,无工作区时禁用;浅深主题和宽窄窗口核对位置、hover 和点击。Windows 仅兼容可选 isIgnored 字段,ignored 前景色与此工具栏入口尚未同步。 打开搜索时激活并刷新惰性 Git 模块,将已归一化的 GitTreeStatusProjection 发布到当前搜索会话,未打开 Commit 也能得到未跟踪/修改状态色;工作区切换或关闭后不回写旧快照。 顶栏搜索使用 Community 原始 search@20x20 明暗 SVG,不放大 16pt 图标;与设置同用 LitheMainToolbarButtonStyle,放入 spacing=0 的同组工具栏,移除外层 9pt 间距和侧栏 37pt 槽位差异。 关闭搜索释放干净的暂存预览并撤销未完成打开;有未保存输入则保留为正式文档,项目替换仍显示时由替换面板继续持有并负责清理。 Text 预览沿用当前大小写/整词/正则选项,名称预览保持默认;生成名称索引扫描和增量更新均排除嵌套 linked worktree。 审核修复:Problems 与搜索共用或分别预览时,关闭搜索只释放搜索使用权,最后使用方退出才清理;待加载共享请求亦同。Text 只读预览用实际 Monaco nativeFind 显示大小写/整词/正则匹配而不抢焦点。搜索使用用户指定 Git且保持非交互。macOS 双 Shift 漏斗面板可编辑、应用并持久保存仅此窗口生效的排除表达式(*.log、**/.*/、.*、cache/),通用设置入口移除,侧栏搜索、项目替换和 Agent 补全不受影响;树与监听不隐藏;清空应用后重开仍为空并恢复范围,不同规则缓存随文件修改同步。Windows 共享 Rust 换行文件名仅 Unix 创建,保留全平台空格文件名测试;Windows 设置 UI 未同步。 全局搜索显式启用生成目录名称,Agent 的空 @ 与 @Name 都只用项目可见性,不受 *.log 搜索排除影响。cache/ 与 **/.*/ 必须在进入目录前剪枝,真实枚举探针不记录这些目录;直接上报排除后代的增量事件也不回填。连续修改排除规则只使搜索缓存失效,不重建项目树;失效屏障不被后续更新跳过,每根目录最多一个内容及一个名称缓存,旧分配在请求完成后释放;覆盖 32 次切换和更新。 排除规则改为漏斗列表首项(Actions 上方),与来源行共用复选框和 hover/点击样式,右侧原始 16pt 编辑图标打开由搜索窗口持有的独立编辑弹窗;漏斗不再内嵌文本框。应用按钮使用主题主色,应用保存并关闭、取消/Escape 丢弃未保存草稿但不关闭搜索;多行 Enter 换行。勾选开关独立记忆,取消勾选不删除规则,编辑已禁用规则不重新启用,重新勾选刷新当前搜索;来源批量按钮不改此开关。覆盖保存、取消、清空、关闭重开及禁用后重新启用,侧栏/替换/Agent 范围继续不受影响。 排除规则右侧编辑按钮按 Community ActionButton 小动作规格使用原始 16pt 明暗 edit.svg、22pt 点击和 hover 区、四边各 3pt 留白及 4pt 圆角,采用专用 ActionButton hover/pressed 主题色而非通用按钮底色;默认箭头光标,不影响行勾选。深浅主题检查普通、hover、按下、移出恢复及点击打开弹窗。 规则编辑输入框打开即获得原生焦点,点击弹窗空白保留光标/选区;复用共享多行输入框的单一外部焦点绑定与 2pt 焦点描边/向外 1pt 扩展,不能重复绑定导致焦点色或宽度脱节。深浅主题检查真实 firstResponder、空白点击不丢选区及两列边框像素,取消/应用和 Tab 导航仍可用。 排除规则行的灰色悬停和蓝色选中背景覆盖右侧编辑按钮区域,与来源行右边缘对齐;编辑按钮独立悬停,点击只编辑、不切换规则开关。 名称索引遇到不可访问或消失的后代目录时保留正常源码和 Text 结果,根目录错误及取消仍传播;搜索预览连续切换 A/B/C、清空结果或收起时按路径和请求标识释放旧持有,保留其他面板及未保存内容,旧请求晚返回不得误清新请求。 核对 src/Foo/src/Foo.swift 的路径回退以及 Agent @src 候选,文件名命中排在目录回退之前,路径命中不绘制文件名片段;Actions-only 不发送工作区请求,Text-only 不构建生成名称索引,Files-only 不构建正文索引,All 切换来源会重查并丢弃过期来源响应。2000 个生成文件冷索引回归核对构建种类、目录访问及驻留文件数。预览开启后单击只选择,辅助功能默认 Open、命名 Open 与 Return 可打开文件;VoiceOver 实机状态单独验收。 Unicode 名称连续匹配保持整串小写语义:i、i 加组合点及组合点本身均命中 İ.txt;补充非 BMP 前缀与希腊 sigma 回归,并核对原始 UTF-16 范围。" + "verification": "使用快捷键搜索命令、设置和工作区内容,确认结果来源和跳转行为。 macOS 双 Shift 打开 Search Everywhere,按 Community Islands 源码核对普通字重的圆角分类选中框、统一弹窗背景、内嵌圆角搜索框及 IDEA 明暗搜索/筛选/关闭图标;在深浅主题与窄窗口检查外框裁剪、搜索框不越界、输入焦点与中文组词,切换分类、清除、筛选、Esc、点击外部及执行命令保持原行为。 搜索弹窗使用独立无边框 NSPanel 和系统 NORMAL 阴影,不绘制 SwiftUI 模糊投影;父窗口移动/缩放后仍居中于顶部指定位置,点击外部与失焦关闭,筛选子菜单获焦时搜索保留;关闭与重新打开均无残留子窗口。 空查询只显示分类与搜索框,输入后展开、清空后收起;原生窗口使用 sRGB backing store,顶部分类复用共享标签聚焦/失焦状态,并从面板自身读取 controlActiveState,父窗口失焦不将活动分类错误画灰。 打开后无需点击即可直接输入首个字符;搜索进行中在输入框右侧、命令提示之前显示加载指示器,分类栏不重复显示;完成后加载指示器消失,输入后命令提示仍保留。 使用同一工作区输入 name,确认 .build/checkouts/Name.swift、rust/target 中的名称及 NativeMenu/navigation-menu 的分段命中;文件名范围来自 Core 的 UTF-16 nameMatchRanges,选中行高亮,名称先排序再按来源截断。漏斗只筛选已实现的 Classes/Files/Symbols/Actions,All/None/Invert 生效,All 包含动作;单独标签不受 All 来源筛选影响。预览按钮开启后,单击/上下键选中文件更新独立只读 Monaco,不能输入、格式化或重命名;回车/双击仍打开,关闭预览不改变主编辑器可写状态或未保存内容。生成目录名称更新沿用索引刷新;未覆盖 IDEA 的外部库、语言服务全量符号、历史/机器学习排序。 点击只读预览后结果行仍为活动焦点色;鼠标选择文件保持列表滚动偏移和面板位置/尺寸不变,上下键仅在必要时滚动显示选中项,不强制居中。 新查询或切换来源仍恢复首项可见。 在深浅主题与宽窄窗口按住顶部空白区拖向右下,确认窗口实际位置改变(原生命中区域须填满标题栏高度),输入展开、预览切换和父窗缩放后保持拖动位置,重开恢复默认;标签/输入/按钮不被拖动区域抢占。筛选行按名称排序,使用原始 IDEA 明暗复选框与共享行悬停/选中,固定底部 72×28 All/None/Invert 按钮,200pt 最小高度、共享弹层外框;连续勾选和批量操作不关闭菜单。 筛选内容背景沿 Islands Table/Panel 继承 tool-window-bg(深/浅 #191A1C/#FFFFFF),共享原生下拉面板使用 sRGB;复选框保留 24pt viewBox 自带边距,不叠加普通菜单图标的水平内边距。 筛选行 hover 是共享失焦灰,点击才保留主体蓝,移动到另一行同时保留蓝色选中行与灰色 hover 行,hover 不改变勾选。批量按钮上方使用滚动列表的 1pt 内侧矩形底边(Borders.color,深/浅 #26282C/#D1D3D9),不使用系统 Divider。 关闭时按来源保存非空查询,会话内每来源去重保留 50 条,All 重开恢复最后草稿并全选(包括空草稿);清空关闭后重开必须为空,不从非空历史回填,项目 reset 清除历史,侧栏查询保持独立。输入框清除按钮使用 16pt 槽位。结果文件名复用 Git 文件状态前景色,选中匹配片段保留金底黑字;被 Git 忽略的文件由 Core 批量 check-ignore 获取真实规则,包含 info/exclude、否定规则和已跟踪文件例外,按 Islands Dark/Light 的 #D69A6B/#8C4F00 着色;Git 不可用时保留未知,不按目录名猜测。右上角设置左侧的搜索图标与双 Shift 调用同一动作,提示快捷键,无工作区时禁用;浅深主题和宽窄窗口核对位置、hover 和点击。Windows 仅兼容可选 isIgnored 字段,ignored 前景色与此工具栏入口尚未同步。 打开搜索时激活并刷新惰性 Git 模块,将已归一化的 GitTreeStatusProjection 发布到当前搜索会话,未打开 Commit 也能得到未跟踪/修改状态色;工作区切换或关闭后不回写旧快照。 顶栏搜索使用 Community 原始 search@20x20 明暗 SVG,不放大 16pt 图标;与设置同用 LitheMainToolbarButtonStyle,放入 spacing=0 的同组工具栏,移除外层 9pt 间距和侧栏 37pt 槽位差异。 关闭搜索释放干净的暂存预览并撤销未完成打开;有未保存输入则保留为正式文档,项目替换仍显示时由替换面板继续持有并负责清理。 Text 预览沿用当前大小写/整词/正则选项,名称预览保持默认;生成名称索引扫描和增量更新均排除嵌套 linked worktree。 审核修复:Problems 与搜索共用或分别预览时,关闭搜索只释放搜索使用权,最后使用方退出才清理;待加载共享请求亦同。Text 只读预览用实际 Monaco nativeFind 显示大小写/整词/正则匹配而不抢焦点。搜索使用用户指定 Git且保持非交互。macOS 双 Shift 漏斗面板可编辑、应用并持久保存仅此窗口生效的排除表达式(*.log、**/.*/、.*、cache/),通用设置入口移除,侧栏搜索、项目替换和 Agent 补全不受影响;树与监听不隐藏;清空应用后重开仍为空并恢复范围,不同规则缓存随文件修改同步。Windows 共享 Rust 换行文件名仅 Unix 创建,保留全平台空格文件名测试;Windows 设置 UI 未同步。 全局搜索显式启用生成目录名称,Agent 的空 @ 与 @Name 都只用项目可见性,不受 *.log 搜索排除影响。cache/ 与 **/.*/ 必须在进入目录前剪枝,真实枚举探针不记录这些目录;直接上报排除后代的增量事件也不回填。连续修改排除规则只使搜索缓存失效,不重建项目树;失效屏障不被后续更新跳过,每根目录最多一个内容及一个名称缓存,旧分配在请求完成后释放;覆盖 32 次切换和更新。 排除规则改为漏斗列表首项(Actions 上方),与来源行共用复选框和 hover/点击样式,右侧原始 16pt 编辑图标打开由搜索窗口持有的独立编辑弹窗;漏斗不再内嵌文本框。应用按钮使用主题主色,应用保存并关闭、取消/Escape 丢弃未保存草稿但不关闭搜索;多行 Enter 换行。勾选开关独立记忆,取消勾选不删除规则,编辑已禁用规则不重新启用,重新勾选刷新当前搜索;来源批量按钮不改此开关。覆盖保存、取消、清空、关闭重开及禁用后重新启用,侧栏/替换/Agent 范围继续不受影响。 排除规则右侧编辑按钮按 Community ActionButton 小动作规格使用原始 16pt 明暗 edit.svg、22pt 点击和 hover 区、四边各 3pt 留白及 4pt 圆角,采用专用 ActionButton hover/pressed 主题色而非通用按钮底色;默认箭头光标,不影响行勾选。深浅主题检查普通、hover、按下、移出恢复及点击打开弹窗。 规则编辑输入框打开即获得原生焦点,点击弹窗空白保留光标/选区;复用共享多行输入框的单一外部焦点绑定与 2pt 焦点描边/向外 1pt 扩展,不能重复绑定导致焦点色或宽度脱节。深浅主题检查真实 firstResponder、空白点击不丢选区及两列边框像素,取消/应用和 Tab 导航仍可用。 排除规则行的灰色悬停和蓝色选中背景覆盖右侧编辑按钮区域,与来源行右边缘对齐;编辑按钮独立悬停,点击只编辑、不切换规则开关。 名称索引遇到不可访问或消失的后代目录时保留正常源码和 Text 结果,根目录错误及取消仍传播;搜索预览连续切换 A/B/C、清空结果或收起时按路径和请求标识释放旧持有,保留其他面板及未保存内容,旧请求晚返回不得误清新请求。 核对 src/Foo/src/Foo.swift 的路径回退以及 Agent @src 候选,文件名命中排在目录回退之前,路径命中不绘制文件名片段;Actions-only 不发送工作区请求,Text-only 不构建生成名称索引,Files-only 不构建正文索引,All 切换来源会重查并丢弃过期来源响应。2000 个生成文件冷索引回归核对构建种类、目录访问及驻留文件数。预览开启后单击只选择,辅助功能默认 Open、命名 Open 与 Return 可打开文件;VoiceOver 实机状态单独验收。 Unicode 名称连续匹配保持整串小写语义:i、i 加组合点及组合点本身均命中 İ.txt;补充非 BMP 前缀与希腊 sigma 回归,并核对原始 UTF-16 范围。 #1564:macOS 开启预览,在深浅主题下分别返回 199 和 200 条文件结果;列表大批结果到达后,文件列表与预览仍各在自己的面板内、互不重叠,鼠标选择、键盘滚动与 Return 打开保持有效;共享分隔容器覆盖横纵布局的空、单项及多项面板。 #1564 增量搜索:macOS 文件名候选先于正文/符号索引发布,首批立即发送,后续约100ms替换快照刷新并在来源结束flush;最终结果保留排名、上限及Git忽略状态。真实Rust桥接、取消、旧查询晚返回、reset及突发批次最终结果回归;深浅主题40/199/200条结果追加高排名候选后选择、预览与滚动保持稳定。输入防抖仍180ms,冷名称枚举及失效屏障仍需完成;Text随最终结果返回。Windows仍等待最终响应,未宣称流式展示验收。 #1564 自动分页:删除更多提示及每来源200条总上限,每来源50条、接近底部自动续查;首屏仍流式,后续整页去重追加并保持已加载页和动作顺序。深浅主题实际滚动加载257文件,保持非零像素偏移、选择和预览;Core四来源超过200、整页/空页耗尽、错误/跨查询游标拒绝。模块覆盖失败暂停、显式Retry、重复游标/无新增停止;取消与过期响应仍隔离。名称/符号每页扫描已有索引,只保留51个候选;实时索引游标前新增项需重查。Windows保留旧最终响应,未同步自动分页。 默认选择与主动选择分开:未进行鼠标/键盘导航时,Actions先到或首屏更优候选后到,预览和Enter跟随当前首项;主动选择后仍按ID保持,所选项消失才退回默认首项。原生窗口回归覆盖两种到达顺序并保留主动选择重排用例。" } diff --git a/shared/platform-feature-matrix/features/workbench-panes.json b/shared/platform-feature-matrix/features/workbench-panes.json index 0544d4395d..8d559a373a 100644 --- a/shared/platform-feature-matrix/features/workbench-panes.json +++ b/shared/platform-feature-matrix/features/workbench-panes.json @@ -23,7 +23,8 @@ "macos/Sources/Lithe/Views/Components/WorkbenchSurfaceBackground.swift", "macos/Sources/Lithe/Views/Workbench/OutputTextView.swift", "macos/Tests/LitheTests/ConsoleInlineInputTests.swift", - "macos/Tests/LitheTests/LitheToolWindowHoverTests.swift" + "macos/Tests/LitheTests/LitheToolWindowHoverTests.swift", + "macos/Tests/LitheTests/LitheSplitPaneCollapseTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -37,6 +38,6 @@ "verificationStatus": "pending" }, "owner": "Workbench", - "verification": "打开、关闭、移动和调整面板,验证布局持久化及高频拖动稳定性;macOS 主工作区窗口不再被固定的 980×640 内容下限阻止缩小,侧栏、右侧工具窗、编辑区和底部工具窗按 IDEA 主分隔器默认 30pt 下限与实时可用空间调整,无固定 520/760pt 上限,极窄窗口不产生负尺寸或越界,恢复窗口后保留原先保存的面板宽度;拖动右侧 Maven 工具窗时保留当前 Commit 侧栏宽度并由编辑区承受剩余空间变化,底部 Git Log 与终端始终横跨 Project、编辑区及右侧 Maven 所在的整行,向上拖动时上半行按共享 30pt 主分隔器下限收缩,Commit 不设专属高度下限;未打开文件时,将编辑区缩窄或缩矮,空状态提示保持原始文字尺寸、不换行或缩字,超出内容在编辑区内裁剪,恢复尺寸后完整显示;编辑区和左右工具窗共用按可见尺寸布局的面板容器,在 30pt 高度下 Project、Commit、Search 及右侧 Maven 标题仍贴顶且四角圆角保持可见;macOS Project、Commit、Search、Database 和 Pull Requests 侧栏标题右端使用 IDEA 明暗收起图标与共用悬停样式,点击后收起侧栏并保留活动栏和编辑区,再点击对应活动栏图标恢复侧栏及原宽度;在 macOS 启用背景图后检查项目栏、编辑器、底部及右侧工具窗之间是否仍有清晰的分割区间;将 macOS 左侧工具窗缩至 30pt 并确认目录树始终贴左、不随宽度向右移动、内容不溢出、右上和右下圆角仍可见,关闭并重新打开项目后仍保持窄侧栏及其他布局尺寸,重新展开后布局正常;在 Maven 项目产生构建输出时确认左侧模块按钮可滚动、设置按钮始终完整显示;在亮色主题且有未读通知时确认未打开的铃铛图标清晰可见、打开通知面板后白色图标与蓝色背景成对出现;在深色 Lithe 主题下对照外框渐变、面板对比度、背景图片回退、左右工具窗的 40pt 按钮槽、30pt 选中块及 IDEA Community 图标对比度,悬停工具窗按钮时确认箭头光标,检查项目栏标题颜色和弹窗主按钮主题色;检查 macOS 40pt 顶栏 Logo 与系统窗口按钮垂直居中,项目切换器使用 20pt 项目字母头像和 30pt 悬停块,项目与分支切换块悬停保持箭头光标,红绿灯右侧留白与 IDEA 接近,项目头像、欢迎列表及切换菜单按标准化项目路径共享稳定颜色(超过 9 个项目、移除后重开仍一致),顶栏运行配置使用 16pt 图标与 13pt Regular 文字、内容自然宽度和 6pt 图文间距;运行与调试使用 Community 描边资源、34×40pt 槽位及 30×30pt 悬停区域,运行、调试、更新和背景按钮悬停保持默认箭头,检查深浅主题、菜单开合、窄窗口和原动作;顶栏使用 IDEA Islands 主题项目色并按 85% 混色、600pt 水平和 300pt 垂直范围渐变,深色窗口分隔条四角与底栏缺口无浅色亮斑,缩放窗口及从欢迎页进入工作区再返回后仍对齐。 macOS 主工具栏仅保留运行配置、Run、Debug 及执行中的 Stop;确认右上角无新增搜索和设置按钮,原运行/调试动作及既有快捷键、应用菜单入口正常。 macOS 共享分隔条普通、hover 和按住拖动使用相同分隔线颜色,不增亮;原调整光标、命中范围和释放保存尺寸保持可用。 macOS 设置入口位于主工具栏最右侧,移除左下重复入口,沿用 IDEA settings SVG 和原设置动作,核对点击、悬停与窄窗口显示。 右上设置按钮先打开共享动作菜单,仅有设置/插件、分隔线、主题/快捷键四项,设置显示当前 Keymap 快捷键;各项打开现有通用/插件/快捷键分类,主题在通用主题设置中配置,Esc/外部点击关闭菜单。 右上齿轮与右侧通知/数据库入口共用竖直中心线,窄宽窗口保持对齐;右侧活动栏不再显示插件按钮,齿轮菜单插件项仍有效。 设置图标普通、悬停及菜单展开时使用右侧入口的 toolWindowButtonText 前景色,深浅主题跟随相同 token;保留现有菜单和悬停背景。 运行配置/运行/调试组与末端设置之间有可收缩留白,宽窗口对照参考位置左移,窄窗口收缩留白且设置保持右侧栏对齐;打开运行配置弹窗及运行/调试动作保持原行为。 A/B 分屏后在 A 发起跨文件 Rename/Code Action,验证 B 的同步 owner 与保存/关闭屏障不被接管,待处理输入先排空,修订递增并检查撤销;各组右键 Other/Left/Right/All 按组内重排后的可见顺序关闭文档,不关闭其他组或混合终端标签。 macOS 内置与声明式插件工具窗口通过稳定贡献 ID 注册,宿主菜单支持底部/右侧位置、顺序、入口显隐和默认布局恢复;几何尺寸提交不覆盖这些偏好。 macOS Project、Commit、Search 和 Pull Requests 入口通过贡献注册表显示(Pull Requests 仍受原功能开关限制);把内置侧栏和插件工具窗移动到左侧/右侧/底部,检查原内容、收起按钮、独立显隐与按工作区保存的入口顺序,重开项目恢复左栏选择和展开状态;旧会话名称与旧尺寸配置继续可读,禁用模块不实例化且关闭内容,插件左栏恢复必须激活所属模块。 macOS 动作状态:确认窗口入口、工具栏、菜单与命令面板共用可用和勾选状态,激活中命令禁用且 Enter/鼠标均不能触发,窗口入口允许取消;同时打开同一插件的两个窗口只构造一次模块,取消其中一个不影响另一个;激活中禁用再启用,确认无资源残留或晚到贡献,旧动作引用不绕过状态校验。 插件卸载后恢复包含旧侧栏 ID 的会话,确认回退项目入口且不出现未知底部加载窗口。 macOS Run/Debug/Stop 主工具栏入口与插件按钮共用显隐和顺序偏好,重开项目及尺寸提交后保持;找回隐藏工具栏或菜单入口不执行命令,菜单项可从菜单命令管理入口恢复。 恢复先前可见的 Services 后,不借助其他入口完成所属模块激活并能操作;Show 不会 toggle 关闭已经可见的窗口。Maven renderer 在非 Maven 项目不可见时,旧布局中的可见标记不占据停靠内容空间。prepareForSleep 挂起时并发 activate 等待休眠完成,只在新实例中重新激活,贡献不重复发布、旧实例资源全部释放。 关闭模块在 gate 暂停时取消等待激活的调用方,放行关闭后构造与贡献发布仍各一次,无实例及资源残留;首次点击和 Show 打开 A 时暂停激活,同组选择 B 后放行 A,最终仍显示 B,不同组可以同时显示;默认右侧 Database → Agent → Maven 中将 Maven 前移,再后移,检查实际 tools(in:) 顺序随之变化。 开启/替换/关闭工作台背景时,检查Debug Console正文、左侧工具栏及Performance展开/收起区均露出同一工作台背景;移除背景恢复主题底色,保留ANSI背景、选区、输入草稿和原生文本控件身份。 所有主工具窗口隐藏按钮共用整块面板悬停环境;鼠标离开时隐藏且不可鼠标命中,保留槽位与键盘/VoiceOver 焦点显示;分别检查普通宿主与 Terminal/Git 保留宿主,Project 正文悬停也显示定位、刷新和展开/收起动作。", + "verification": "打开、关闭、移动和调整面板,验证布局持久化及高频拖动稳定性;macOS 主工作区窗口不再被固定的 980×640 内容下限阻止缩小,侧栏、右侧工具窗、编辑区和底部工具窗按 IDEA 主分隔器默认 30pt 下限与实时可用空间调整,无固定 520/760pt 上限,极窄窗口不产生负尺寸或越界,恢复窗口后保留原先保存的面板宽度;拖动右侧 Maven 工具窗时保留当前 Commit 侧栏宽度并由编辑区承受剩余空间变化,底部 Git Log 与终端始终横跨 Project、编辑区及右侧 Maven 所在的整行,向上拖动时上半行按共享 30pt 主分隔器下限收缩,Commit 不设专属高度下限;未打开文件时,将编辑区缩窄或缩矮,空状态提示保持原始文字尺寸、不换行或缩字,超出内容在编辑区内裁剪,恢复尺寸后完整显示;编辑区和左右工具窗共用按可见尺寸布局的面板容器,在 30pt 高度下 Project、Commit、Search 及右侧 Maven 标题仍贴顶且四角圆角保持可见;macOS Project、Commit、Search、Database 和 Pull Requests 侧栏标题右端使用 IDEA 明暗收起图标与共用悬停样式,点击后收起侧栏并保留活动栏和编辑区,再点击对应活动栏图标恢复侧栏及原宽度;在 macOS 启用背景图后检查项目栏、编辑器、底部及右侧工具窗之间是否仍有清晰的分割区间;将 macOS 左侧工具窗缩至 30pt 并确认目录树始终贴左、不随宽度向右移动、内容不溢出、右上和右下圆角仍可见,关闭并重新打开项目后仍保持窄侧栏及其他布局尺寸,重新展开后布局正常;在 Maven 项目产生构建输出时确认左侧模块按钮可滚动、设置按钮始终完整显示;在亮色主题且有未读通知时确认未打开的铃铛图标清晰可见、打开通知面板后白色图标与蓝色背景成对出现;在深色 Lithe 主题下对照外框渐变、面板对比度、背景图片回退、左右工具窗的 40pt 按钮槽、30pt 选中块及 IDEA Community 图标对比度,悬停工具窗按钮时确认箭头光标,检查项目栏标题颜色和弹窗主按钮主题色;检查 macOS 40pt 顶栏 Logo 与系统窗口按钮垂直居中,项目切换器使用 20pt 项目字母头像和 30pt 悬停块,项目与分支切换块悬停保持箭头光标,红绿灯右侧留白与 IDEA 接近,项目头像、欢迎列表及切换菜单按标准化项目路径共享稳定颜色(超过 9 个项目、移除后重开仍一致),顶栏运行配置使用 16pt 图标与 13pt Regular 文字、内容自然宽度和 6pt 图文间距;运行与调试使用 Community 描边资源、34×40pt 槽位及 30×30pt 悬停区域,运行、调试、更新和背景按钮悬停保持默认箭头,检查深浅主题、菜单开合、窄窗口和原动作;顶栏使用 IDEA Islands 主题项目色并按 85% 混色、600pt 水平和 300pt 垂直范围渐变,深色窗口分隔条四角与底栏缺口无浅色亮斑,缩放窗口及从欢迎页进入工作区再返回后仍对齐。 macOS 主工具栏仅保留运行配置、Run、Debug 及执行中的 Stop;确认右上角无新增搜索和设置按钮,原运行/调试动作及既有快捷键、应用菜单入口正常。 macOS 共享分隔条普通、hover 和按住拖动使用相同分隔线颜色,不增亮;原调整光标、命中范围和释放保存尺寸保持可用。 macOS 设置入口位于主工具栏最右侧,移除左下重复入口,沿用 IDEA settings SVG 和原设置动作,核对点击、悬停与窄窗口显示。 右上设置按钮先打开共享动作菜单,仅有设置/插件、分隔线、主题/快捷键四项,设置显示当前 Keymap 快捷键;各项打开现有通用/插件/快捷键分类,主题在通用主题设置中配置,Esc/外部点击关闭菜单。 右上齿轮与右侧通知/数据库入口共用竖直中心线,窄宽窗口保持对齐;右侧活动栏不再显示插件按钮,齿轮菜单插件项仍有效。 设置图标普通、悬停及菜单展开时使用右侧入口的 toolWindowButtonText 前景色,深浅主题跟随相同 token;保留现有菜单和悬停背景。 运行配置/运行/调试组与末端设置之间有可收缩留白,宽窗口对照参考位置左移,窄窗口收缩留白且设置保持右侧栏对齐;打开运行配置弹窗及运行/调试动作保持原行为。 A/B 分屏后在 A 发起跨文件 Rename/Code Action,验证 B 的同步 owner 与保存/关闭屏障不被接管,待处理输入先排空,修订递增并检查撤销;各组右键 Other/Left/Right/All 按组内重排后的可见顺序关闭文档,不关闭其他组或混合终端标签。 macOS 内置与声明式插件工具窗口通过稳定贡献 ID 注册,宿主菜单支持底部/右侧位置、顺序、入口显隐和默认布局恢复;几何尺寸提交不覆盖这些偏好。 macOS Project、Commit、Search 和 Pull Requests 入口通过贡献注册表显示(Pull Requests 仍受原功能开关限制);把内置侧栏和插件工具窗移动到左侧/右侧/底部,检查原内容、收起按钮、独立显隐与按工作区保存的入口顺序,重开项目恢复左栏选择和展开状态;旧会话名称与旧尺寸配置继续可读,禁用模块不实例化且关闭内容,插件左栏恢复必须激活所属模块。 macOS 动作状态:确认窗口入口、工具栏、菜单与命令面板共用可用和勾选状态,激活中命令禁用且 Enter/鼠标均不能触发,窗口入口允许取消;同时打开同一插件的两个窗口只构造一次模块,取消其中一个不影响另一个;激活中禁用再启用,确认无资源残留或晚到贡献,旧动作引用不绕过状态校验。 插件卸载后恢复包含旧侧栏 ID 的会话,确认回退项目入口且不出现未知底部加载窗口。 macOS Run/Debug/Stop 主工具栏入口与插件按钮共用显隐和顺序偏好,重开项目及尺寸提交后保持;找回隐藏工具栏或菜单入口不执行命令,菜单项可从菜单命令管理入口恢复。 恢复先前可见的 Services 后,不借助其他入口完成所属模块激活并能操作;Show 不会 toggle 关闭已经可见的窗口。Maven renderer 在非 Maven 项目不可见时,旧布局中的可见标记不占据停靠内容空间。prepareForSleep 挂起时并发 activate 等待休眠完成,只在新实例中重新激活,贡献不重复发布、旧实例资源全部释放。 关闭模块在 gate 暂停时取消等待激活的调用方,放行关闭后构造与贡献发布仍各一次,无实例及资源残留;首次点击和 Show 打开 A 时暂停激活,同组选择 B 后放行 A,最终仍显示 B,不同组可以同时显示;默认右侧 Database → Agent → Maven 中将 Maven 前移,再后移,检查实际 tools(in:) 顺序随之变化。 开启/替换/关闭工作台背景时,检查Debug Console正文、左侧工具栏及Performance展开/收起区均露出同一工作台背景;移除背景恢复主题底色,保留ANSI背景、选区、输入草稿和原生文本控件身份。 所有主工具窗口隐藏按钮共用整块面板悬停环境;鼠标离开时隐藏且不可鼠标命中,保留槽位与键盘/VoiceOver 焦点显示;分别检查普通宿主与 Terminal/Git 保留宿主,Project 正文悬停也显示定位、刷新和展开/收起动作。 共享分隔容器在横向/纵向、面板为空或含多个直接子视图时,仍保留两块独立面板及分隔条;搜索的更多提示不改变面板数量。核对尺寸限制、收起恢复、嵌套拖动和原生表面身份不变。", "notes": " 工作台共享表面向后代传递背景图片状态,原生输出视图使用同一状态控制滚动视图底色,防止局部不透明背景遮挡;同样覆盖既有共享输出调用者。" }