From f14e93bfda4d359b9f53fae704f9106c0849a668 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Wed, 4 Nov 2026 21:39:12 -0800 Subject: [PATCH 1/7] =?UTF-8?q?feat(core):=20#1662=20=E8=BD=A6=E9=81=93=20?= =?UTF-8?q?D=20LSP/=E7=BC=96=E8=BE=91=E5=99=A8=E8=A7=84=E5=88=99=E4=B8=8B?= =?UTF-8?q?=E6=B2=89=E5=88=B0=20Rust=20Core?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 Core 命令并让平台改用 Core 结果,删除对应的平台本地实现: - LSP-03 lsp.planWorkspaceEdit:workspace edit 目标校验与提交顺序 - LSP-04 lsp.classifyNavigationTarget:跳转目标工作区/外部只读/拒绝分类 - LSP-08 lsp.activationPolicy:语言服务器失败后的自动启动策略 - LSP-09 lsp.mergeCompletions:多 provider 补全候选合并(不含语言关键词表) - LSP-13 editor.planTextEdits:插件文本编辑批次校验与排序,复用 lsp.applyTextEdits 行索引规则 - LSP-14 document.planSaveBatch / reduceSaveBatch:批量保存计划与结果归并 - LSP-15 editor.navigationHistory.reduce:导航历史 reducer - LSP-16 editor.workbenchState.reduce:标签/分组 reducer - LSP-17 lsp.problems.query:Problems 过滤、排序、分组与计数 附带共享 fixture、契约文档、平台功能矩阵和语言特定词基线更新。 Co-Authored-By: Claude Opus 5.5 --- .../2026-10-12-editor-tab-split-docking.md | 4 +- .../Composition/AppModelFeatureGraph.swift | 2 +- .../Features/EditorDocumentService.swift | 40 +- .../Features/EditorGroupLayout.swift | 48 +- .../Features/EditorTabOrderFeatureModel.swift | 241 +- .../Features/EditorTextEditPlanner.swift | 200 +- .../NavigationHistoryFeatureModel.swift | 148 +- .../Core/Rust/RustLanguageToolingPolicy.swift | 44 + .../Lithe/Models/Editor/ProblemsQuery.swift | 81 + .../Platform/MacOS/MacServiceContainer.swift | 1 + .../LanguageServerTextEditApplicator.swift | 63 +- .../Views/Language/JavaProblemsView.swift | 58 +- .../Language/LanguageToolingPolicyCore.swift | 80 + .../Providers/LanguageFeatureProvider.swift | 183 +- .../LanguageToolingSessionManager.swift | 113 +- .../LanguageIntelligenceModuleTests.swift | 85 + .../EditorDocumentServiceTests.swift | 12 +- .../EditorTabOrderFeatureModelTests.swift | 17 + .../EditorTextEditPlannerTests.swift | 131 +- .../LanguageFeatureProviderTests.swift | 99 +- .../NavigationHistoryFeatureModelTests.swift | 54 +- .../ProblemsPanelPresentationTests.swift | 31 + .../src/diagnostics/request_trace.rs | 2 + rust/lithe-core/src/editor/mod.rs | 3 + .../src/editor/navigation_history.rs | 189 ++ rust/lithe-core/src/editor/tests.rs | 161 ++ rust/lithe-core/src/editor/text_edit_plan.rs | 161 ++ rust/lithe-core/src/editor/workbench_state.rs | 626 +++++ .../src/lsp/host_policy/activation.rs | 149 + .../src/lsp/host_policy/completion_merge.rs | 76 + rust/lithe-core/src/lsp/host_policy/mod.rs | 13 + .../src/lsp/host_policy/problems.rs | 397 +++ rust/lithe-core/src/lsp/host_policy/tests.rs | 73 + .../src/lsp/interface/document_uri.rs | 28 +- rust/lithe-core/src/lsp/interface/mod.rs | 1 + rust/lithe-core/src/lsp/lightweight/edits.rs | 294 +- rust/lithe-core/src/lsp/mod.rs | 4 + rust/lithe-core/src/lsp/navigation_target.rs | 122 + .../lithe-core/src/lsp/workspace_edit_plan.rs | 186 ++ rust/lithe-core/src/lsp/workspace_scope.rs | 57 + .../src/project/document_save_batch.rs | 214 ++ rust/lithe-core/src/project/mod.rs | 1 + rust/lithe-core/src/protocol/command.rs | 35 + rust/lithe-core/src/runtime/dispatcher.rs | 50 + rust/lithe-core/src/tests/project.rs | 35 + .../language-specifics-baseline.json | 1 - shared/contracts/rust-core-api.md | 156 ++ shared/fixtures/documents/save-batch-v1.json | 410 +++ .../editor/navigation-history-v1.json | 531 ++++ shared/fixtures/editor/text-edit-plan-v1.json | 841 ++++++ .../fixtures/editor/workbench-state-v1.json | 2429 +++++++++++++++++ shared/fixtures/lsp/activation-policy-v1.json | 182 ++ shared/fixtures/lsp/completion-merge-v1.json | 206 ++ shared/fixtures/lsp/navigation-target-v1.json | 305 +++ shared/fixtures/lsp/problems-query-v1.json | 1244 +++++++++ shared/fixtures/lsp/text-edits-v1.json | 53 + .../fixtures/lsp/workspace-edit-plan-v1.json | 613 +++++ .../features/diagnostics-workspace.json | 13 +- .../features/editor-text-tabs.json | 12 +- .../features/lsp-completion-hover.json | 11 +- .../features/lsp-lifecycle.json | 14 +- ...ugin-host-editor-file-window-services.json | 6 +- .../components/diagnostics-pane.tsx | 259 +- .../diagnostics/utils/problems-query.test.ts | 128 + .../diagnostics/utils/problems-query.ts | 143 + .../editor/components/monaco-editor.tsx | 18 +- .../language-server-activation-policy.test.ts | 62 + .../lsp/language-server-activation-policy.ts | 49 + .../language-server-failure-cooldown.test.ts | 40 - .../lsp/language-server-failure-cooldown.ts | 37 - .../src/features/editor/lsp/lsp-client.ts | 37 +- .../editor/stores/jump-list.store.test.ts | 241 +- .../features/editor/stores/jump-list.store.ts | 270 +- .../editor/utils/jump-navigation.test.ts | 1 - .../editor/utils/mouse-cursor-history.test.ts | 65 +- .../editor/utils/mouse-cursor-history.ts | 30 +- .../commands/navigation-command-actions.ts | 96 +- ...igation-command-actions.web-viewer.test.ts | 3 +- .../quick-open/hooks/use-quick-open.ts | 2 +- 79 files changed, 11717 insertions(+), 1403 deletions(-) create mode 100644 macos/Sources/Lithe/Core/Rust/RustLanguageToolingPolicy.swift create mode 100644 macos/Sources/Lithe/Models/Editor/ProblemsQuery.swift create mode 100644 macos/Sources/LitheCoreContracts/Language/LanguageToolingPolicyCore.swift create mode 100644 rust/lithe-core/src/editor/navigation_history.rs create mode 100644 rust/lithe-core/src/editor/text_edit_plan.rs create mode 100644 rust/lithe-core/src/editor/workbench_state.rs create mode 100644 rust/lithe-core/src/lsp/host_policy/activation.rs create mode 100644 rust/lithe-core/src/lsp/host_policy/completion_merge.rs create mode 100644 rust/lithe-core/src/lsp/host_policy/mod.rs create mode 100644 rust/lithe-core/src/lsp/host_policy/problems.rs create mode 100644 rust/lithe-core/src/lsp/host_policy/tests.rs create mode 100644 rust/lithe-core/src/lsp/navigation_target.rs create mode 100644 rust/lithe-core/src/lsp/workspace_edit_plan.rs create mode 100644 rust/lithe-core/src/lsp/workspace_scope.rs create mode 100644 rust/lithe-core/src/project/document_save_batch.rs create mode 100644 shared/fixtures/documents/save-batch-v1.json create mode 100644 shared/fixtures/editor/navigation-history-v1.json create mode 100644 shared/fixtures/editor/text-edit-plan-v1.json create mode 100644 shared/fixtures/editor/workbench-state-v1.json create mode 100644 shared/fixtures/lsp/activation-policy-v1.json create mode 100644 shared/fixtures/lsp/completion-merge-v1.json create mode 100644 shared/fixtures/lsp/navigation-target-v1.json create mode 100644 shared/fixtures/lsp/problems-query-v1.json create mode 100644 shared/fixtures/lsp/workspace-edit-plan-v1.json create mode 100644 windows/tauri/src/features/diagnostics/utils/problems-query.test.ts create mode 100644 windows/tauri/src/features/diagnostics/utils/problems-query.ts create mode 100644 windows/tauri/src/features/editor/lsp/language-server-activation-policy.test.ts create mode 100644 windows/tauri/src/features/editor/lsp/language-server-activation-policy.ts delete mode 100644 windows/tauri/src/features/editor/lsp/language-server-failure-cooldown.test.ts delete mode 100644 windows/tauri/src/features/editor/lsp/language-server-failure-cooldown.ts diff --git a/.agents/notes/implemented/architecture/2026-10-12-editor-tab-split-docking.md b/.agents/notes/implemented/architecture/2026-10-12-editor-tab-split-docking.md index 3bfc65ff3a..4eeaa0487a 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-editor-tab-split-docking.md +++ b/.agents/notes/implemented/architecture/2026-10-12-editor-tab-split-docking.md @@ -16,7 +16,7 @@ Git 的工作区、工作树、目录、分支、历史提交和 Stash/Shelf 比 ## 决策 -编辑分组的二叉布局树(每次把一组拆成两组)与标签位置、选中项由已有 `EditorTabOrderFeatureModel` 管理。 +编辑分组的二叉布局树(每次把一组拆成两组)、标签归属、选中项和聚焦组的规则由 Rust Core 的 `editor.workbenchState.reduce` 统一持有(`rust/lithe-core/src/editor/workbench_state.rs`,样例 `shared/fixtures/editor/workbench-state-v1.json`);`EditorTabOrderFeatureModel` 只保存 Core 返回的状态,新分组和分隔条 ID 由宿主生成后交给 Core。关闭或移走组内选中的标签后,该组选中接替原位置的标签(越过末尾则选新的末尾),不再回到第一个标签,与 Windows 窗格的既有规则一致。拖动时 `canDrop` 每次指针移动都会查询,宿主按当前状态缓存 Core 的结论,状态变化后清空,不逐帧跨 FFI。Windows 窗格树仍在 `pane-tree.ts`:它允许同一缓冲区同时出现在多个窗格,带预览、固定、锁定和底部窗格树,并由同步 zustand 调用链驱动打开与关闭流程,需要后续整体迁移到同一命令。 普通分组编辑器的 Monaco 原生容器按项目及文档复用;同一个文档在组间移动时重新挂载原 WebView,不重建其 Monaco 模型,保留撤销/重做栈和视图状态。SwiftUI 内容身份也按文档固定,避免切换标签时继续持有另一文档的 StateObject。移动文件前通过原文档的同步屏障排空输入,失败保留原布局,较旧的待处理落点不能覆盖新请求;解绑时检查同步所有者,不能清除另一组已接管的保存回调。 跨文件 Rename / Code Action 读取其他文档快照前,先调用目标文档原有同步屏障;注册编辑镜像只能观察文本变化,不能接管目标文档保存与关闭的同步回调。目标 WebView 继续通过已有 replace 的 pushEditOperations 接收修改,保留自身撤销栈。标签关闭菜单按所属组当前混合标签顺序计算 Other / Left / Right / All,仅关闭文档类型,忽略终端等其他类型。 文档容器只绑定自己的文档,旧挂载退出不能移除目标容器中的编辑器。后台光标事件及没有原生 first responder 的迟到焦点事件不能切换活动组。分组选择变化后通过公开 DOM blur 和 Monaco focus 同步正文焦点,避免不同 WebView 同时保留闪烁光标。 @@ -128,6 +128,8 @@ Windows 的既有分栏实现不受此次 macOS 呈现改动影响,完整两 - `macos/Sources/Lithe/Application/Features/EditorTabOrderFeatureModel.swift` - `macos/Sources/Lithe/Application/Features/EditorGroupLayout.swift` +- `rust/lithe-core/src/editor/workbench_state.rs` +- `shared/fixtures/editor/workbench-state-v1.json` - `macos/Sources/Lithe/Models/AppModel/AppModel+EditorGroups.swift` - `macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift` - `macos/Sources/Lithe/Views/Editor/EditorAreaView.swift` diff --git a/macos/Sources/Lithe/Application/Composition/AppModelFeatureGraph.swift b/macos/Sources/Lithe/Application/Composition/AppModelFeatureGraph.swift index 71614b53b6..a7093f5c67 100644 --- a/macos/Sources/Lithe/Application/Composition/AppModelFeatureGraph.swift +++ b/macos/Sources/Lithe/Application/Composition/AppModelFeatureGraph.swift @@ -117,7 +117,7 @@ final class AppModelFeatureGraph { binaryFileViewerRegistry: services.binaryFileViewerRegistry, confirmHardLinkSave: { services.platformUI.confirmHardLinkSave(at: $0) } ) - navigationHistory = NavigationHistoryFeatureModel() + navigationHistory = NavigationHistoryFeatureModel(logSink: services.logSink) java = JavaFeatureModel(operations: services.javaMavenOperations) spring = SpringFeatureModel(operations: services.javaMavenOperations) mybatis = MybatisFeatureModel(operations: services.javaMavenOperations) diff --git a/macos/Sources/Lithe/Application/Features/EditorDocumentService.swift b/macos/Sources/Lithe/Application/Features/EditorDocumentService.swift index f81aa5af05..b612111746 100644 --- a/macos/Sources/Lithe/Application/Features/EditorDocumentService.swift +++ b/macos/Sources/Lithe/Application/Features/EditorDocumentService.swift @@ -78,7 +78,7 @@ final class EditorDocumentService: EditorDocumentServicing { func listDocuments(scope: EditorDocumentScope) throws -> [EditorDocumentSummary] { try validate(scope) - return documents().map { summary(of: $0, index: EditorTextLineIndex($0.text)) } + return try documents().map { try summary(of: $0) } } func snapshot(scope: EditorDocumentScope, documentID: UUID) throws -> EditorDocumentSnapshot { @@ -86,14 +86,13 @@ final class EditorDocumentService: EditorDocumentServicing { // Text and version are read together on the main actor, so they always // describe the same buffer state. Keystrokes still crossing the editor // bridge appear in a later version and make edits based on this one fail. - return EditorDocumentSnapshot(summary: summary(of: document, index: EditorTextLineIndex(document.text)), - text: document.text) + return EditorDocumentSnapshot(summary: try summary(of: document), text: document.text) } func activeDocument(scope: EditorDocumentScope) throws -> EditorDocumentSummary? { try validate(scope) guard let id = activeDocumentID(), let document = documents().first(where: { $0.id == id }) else { return nil } - return summary(of: document, index: EditorTextLineIndex(document.text)) + return try summary(of: document) } func selections(scope: EditorDocumentScope, documentID: UUID) async throws -> EditorSelectionsSnapshot { @@ -105,10 +104,9 @@ final class EditorDocumentService: EditorDocumentServicing { guard reported.version == document.contentVersion else { throw EditorDocumentServiceError.versionConflict(expected: reported.version, actual: document.contentVersion) } - let index = EditorTextLineIndex(document.text) - guard reported.selections.allSatisfy({ index.offset(of: $0.anchor) != nil && index.offset(of: $0.active) != nil }) else { - throw EditorDocumentServiceError.editorUnavailable - } + // Every reported endpoint must exist in this side's text of the same version. + _ = try EditorTextEditPlanner.plan([], in: document.text, + positions: reported.selections.flatMap { [$0.anchor, $0.active] }) let primary = reported.selections.first let sorted = reported.selections.sorted { ($0.range.start, $0.range.end) < ($1.range.start, $1.range.end) @@ -125,17 +123,27 @@ final class EditorDocumentService: EditorDocumentServicing { guard document.contentVersion == expectedVersion else { throw EditorDocumentServiceError.versionConflict(expected: expectedVersion, actual: document.contentVersion) } - let index = EditorTextLineIndex(document.text) - let plan = try EditorTextEditPlanner.plan(edits, in: index) - guard !plan.isEmpty else { + // Plan off the main actor: the whole text crosses the Core boundary. + let text = document.text + let plan = try await Task.detached(priority: .userInitiated) { + try EditorTextEditPlanner.plan(edits, in: text) + }.value + try Task.checkCancellation() + // The plan describes `text`; reject it if the buffer moved on meanwhile. + guard try resolve(scope, documentID) === document else { throw EditorDocumentServiceError.documentNotFound } + guard !document.isReadOnly else { throw EditorDocumentServiceError.readOnly } + guard document.contentVersion == expectedVersion else { + throw EditorDocumentServiceError.versionConflict(expected: expectedVersion, actual: document.contentVersion) + } + guard !plan.edits.isEmpty else { return EditorDocumentEditResult(documentID: documentID, version: expectedVersion, applied: false) } guard let surface = document.editorSurface else { throw EditorDocumentServiceError.editorUnavailable } - // No suspension between the checks above and this call: the surface + // No suspension between the re-checks above and this call: the surface // compares the editor's own revision against the same instant. let version = try await surface.apply( - EditorSurfaceEditRequest(expectedVersion: expectedVersion, edits: plan, - lineSeparator: index.insertionSeparator), + EditorSurfaceEditRequest(expectedVersion: expectedVersion, edits: plan.edits, + lineSeparator: plan.lineSeparator), to: document) return EditorDocumentEditResult(documentID: documentID, version: version, applied: true) } @@ -174,12 +182,12 @@ final class EditorDocumentService: EditorDocumentServicing { return document } - private func summary(of document: EditorDocument, index: EditorTextLineIndex) -> EditorDocumentSummary { + private func summary(of document: EditorDocument) throws -> EditorDocumentSummary { EditorDocumentSummary( documentID: document.id, version: document.contentVersion, workspaceRelativePath: relativePath(of: document.url), languageID: languageIdentifier(document.url), isReadOnly: document.isReadOnly, isDirty: document.isDirty, encoding: document.readEncoding, - lineEnding: index.lineEnding) + lineEnding: try EditorTextEditPlanner.plan([], in: document.text).lineEnding) } private func relativePath(of url: URL) -> String? { diff --git a/macos/Sources/Lithe/Application/Features/EditorGroupLayout.swift b/macos/Sources/Lithe/Application/Features/EditorGroupLayout.swift index a69ec06b78..534fc4c754 100644 --- a/macos/Sources/Lithe/Application/Features/EditorGroupLayout.swift +++ b/macos/Sources/Lithe/Application/Features/EditorGroupLayout.swift @@ -1,6 +1,7 @@ import Foundation -/// Editor groups own tab placement and selection; documents and sessions keep their existing owners. +/// Editor group split tree as returned by Rust Core (`editor.workbenchState.reduce`). +/// Core owns how the tree splits and collapses; this type only presents it. indirect enum EditorGroupLayout: Equatable { case group(UUID) case split(id: UUID, vertical: Bool, first: EditorGroupLayout, second: EditorGroupLayout) @@ -11,36 +12,41 @@ indirect enum EditorGroupLayout: Equatable { case .split(_, _, let first, let second): first.groups + second.groups } } +} - func replacing(_ id: UUID, with replacement: EditorGroupLayout) -> EditorGroupLayout { - switch self { - case .group(let current): current == id ? replacement : self - case .split(let splitID, let vertical, let first, let second): - .split(id: splitID, vertical: vertical, first: first.replacing(id, with: replacement), second: second.replacing(id, with: replacement)) - } +extension EditorGroupLayout: Codable { + private struct Split: Codable { + let id: UUID + let orientation: String + let first: EditorGroupLayout + let second: EditorGroupLayout } - /// Splits `id` along `side`; a leading edge puts the new group first. - func splitting(_ id: UUID, by side: EditorGroupDropSide, newGroup: UUID) -> EditorGroupLayout { - let new = Self.group(newGroup), old = Self.group(id) - let leading = side == .left || side == .top - let layout = Self.split(id: UUID(), vertical: side.isVertical, first: leading ? new : old, second: leading ? old : new) - return replacing(id, with: layout) + private enum CodingKeys: String, CodingKey { case group, split } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + if let id = try container.decodeIfPresent(UUID.self, forKey: .group) { + self = .group(id) + return + } + let split = try container.decode(Split.self, forKey: .split) + self = .split(id: split.id, vertical: split.orientation == "vertical", first: split.first, second: split.second) } - func keeping(_ ids: Set) -> EditorGroupLayout? { + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) switch self { - case .group(let id): return ids.contains(id) ? self : nil + case .group(let id): + try container.encode(id, forKey: .group) case .split(let id, let vertical, let first, let second): - let a = first.keeping(ids), b = second.keeping(ids) - if let a, let b { return .split(id: id, vertical: vertical, first: a, second: b) } - return a ?? b + try container.encode( + Split(id: id, orientation: vertical ? "vertical" : "horizontal", first: first, second: second), + forKey: .split) } } } -enum EditorGroupDropSide: CaseIterable { +enum EditorGroupDropSide: String, CaseIterable, Encodable { case center, left, right, top, bottom - - var isVertical: Bool { self == .top || self == .bottom } } diff --git a/macos/Sources/Lithe/Application/Features/EditorTabOrderFeatureModel.swift b/macos/Sources/Lithe/Application/Features/EditorTabOrderFeatureModel.swift index 7be829f774..fee08b6043 100644 --- a/macos/Sources/Lithe/Application/Features/EditorTabOrderFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/EditorTabOrderFeatureModel.swift @@ -39,7 +39,9 @@ final class EditorTabOrderFeatureModel: ObservableObject { @Published var documentPreviewModes: [UUID: DocumentPreviewMode] = [:] @Published private(set) var items: [EditorTabItem] = [] { didSet { - reconcileGroups() + if !isApplyingWorkbenchState { + reduceWorkbench(.sync(tabs: items.map(Self.workbenchID)), previousItems: oldValue) + } let open = Set(agentTabIDs) if agentSessionByTab.keys.contains(where: { !open.contains($0) }) { agentSessionByTab = agentSessionByTab.filter { open.contains($0.key) } @@ -58,6 +60,10 @@ final class EditorTabOrderFeatureModel: ObservableObject { @Published private(set) var agentSessionByTab: [UUID: AgentEditorConversation] = [:] private var placements: [EditorTabItem: UUID] = [:] private var focusedGroup: UUID? + private var isApplyingWorkbenchState = false + /// Drop eligibility per state; drags query it on every pointer move, so + /// Core answers each tab/group/side once until the state changes. + private var dropEligibility: [String: Bool] = [:] var groupDropRequestID = UUID() func items(in group: UUID) -> [EditorTabItem] { @@ -122,85 +128,35 @@ final class EditorTabOrderFeatureModel: ObservableObject { func selectedItem(in group: UUID) -> EditorTabItem? { groupSelections[group] } func focus(_ item: EditorTabItem) { - guard let group = placements[item] else { return } - focusedGroup = group - if groupSelections[group] != item { groupSelections[group] = item } + guard let group = placements[item], focusedGroup != group || groupSelections[group] != item else { return } + reduceWorkbench(.focus(tab: Self.workbenchID(item))) } func canDrop(_ item: EditorTabItem, into group: UUID, side: EditorGroupDropSide) -> Bool { - guard items.contains(item), groupLayout.groups.contains(group) else { return false } - return placements[item] != group || (side != .center && items(in: group).count > 1) + let key = "\(Self.workbenchID(item))|\(group.uuidString)|\(side.rawValue)" + if let cached = dropEligibility[key] { return cached } + let allowed = reduceWorkbench(.canDrop(tab: Self.workbenchID(item), group: group, side: side)) + dropEligibility[key] = allowed + return allowed } /// A drop moves membership only; it never closes or reopens the underlying resource. @discardableResult func drop(_ item: EditorTabItem, into group: UUID, side: EditorGroupDropSide) -> Bool { - guard canDrop(item, into: group, side: side) else { return false } - if side == .center { - placements[item] = group - var ordered = items.filter { $0 != item } - let last = ordered.lastIndex { placements[$0] == group } - ordered.insert(item, at: last.map { $0 + 1 } ?? ordered.count) - if ordered != items { items = ordered } - } else { - let created = UUID() - groupLayout = groupLayout.splitting(group, by: side, newGroup: created) - placements[item] = created - } - reconcileGroups() - focus(item) - return true + let split = side == .center ? nil : (group: UUID(), split: UUID()) + return reduceWorkbench(.drop( + tab: Self.workbenchID(item), group: group, side: side, + newGroup: split?.group, newSplit: split?.split)) } /// Adds a tab from outside the editor; an edge always splits because the target group does not own it yet. @discardableResult func insert(_ item: EditorTabItem, into group: UUID?, side: EditorGroupDropSide) -> Bool { - guard !items.contains(item) else { return false } - let groups = groupLayout.groups - guard let group else { - let destination = focusedGroup.flatMap { groups.contains($0) ? $0 : nil } ?? groups[0] - placements[item] = destination - var next = items - next.append(item) - items = next - focus(item) - return true - } - guard groups.contains(group) else { return false } - if side == .center { - placements[item] = group - var next = items - let last = next.lastIndex { placements[$0] == group } - next.insert(item, at: last.map { $0 + 1 } ?? next.count) - items = next - } else { - let created = UUID() - groupLayout = groupLayout.splitting(group, by: side, newGroup: created) - placements[item] = created - var next = items - next.append(item) - items = next - } - focus(item) - return true - } - - private func reconcileGroups() { - let groups = groupLayout.groups - let destination = focusedGroup.flatMap { groups.contains($0) ? $0 : nil } ?? groups[0] - placements = placements.filter { items.contains($0.key) } - for item in items where placements[item] == nil { placements[item] = destination } - let occupied = Set(placements.values) - groupLayout = groupLayout.keeping(occupied) ?? .group(groups[0]) - let remaining = groupLayout.groups - if !remaining.contains(destination) { focusedGroup = remaining[0] } - var selections: [UUID: EditorTabItem] = [:] - for group in remaining { - let tabs = items(in: group) - if let selected = groupSelections[group], tabs.contains(selected) { selections[group] = selected } - else { selections[group] = tabs.first } - } - if selections != groupSelections { groupSelections = selections } + let split = group == nil || side == .center ? nil : (group: UUID(), split: UUID()) + return reduceWorkbench( + .insert(tab: Self.workbenchID(item), group: group, side: side, + newGroup: split?.group, newSplit: split?.split), + adding: [item]) } @Published var repositoryDiffSelected = false @@ -337,14 +293,149 @@ final class EditorTabOrderFeatureModel: ObservableObject { relativeTo target: EditorTabItem, insertAfter: Bool ) -> Bool { - guard item != target, items.contains(target) else { return false } - var next = items - next.removeAll { $0 == item } - guard let targetIndex = next.firstIndex(of: target) else { return false } - next.insert(item, at: targetIndex + (insertAfter ? 1 : 0)) - guard next != items || placements[item] != placements[target] else { return false } - if let destination = placements[target] { placements[item] = destination } - items = next + reduceWorkbench( + .move(tab: Self.workbenchID(item), target: Self.workbenchID(target), + position: insertAfter ? "after" : "before"), + adding: [item]) + } + + // MARK: Core-owned group membership + + /// Rust Core (`editor.workbenchState.reduce`) owns placement, split and + /// collapse, selection and focus; this model only stores the result. + private enum WorkbenchAction: Encodable { + case sync(tabs: [String]) + case drop(tab: String, group: UUID, side: EditorGroupDropSide, newGroup: UUID?, newSplit: UUID?) + case canDrop(tab: String, group: UUID, side: EditorGroupDropSide) + case insert(tab: String, group: UUID?, side: EditorGroupDropSide, newGroup: UUID?, newSplit: UUID?) + case move(tab: String, target: String, position: String) + case focus(tab: String) + + private enum CodingKeys: String, CodingKey { + case type, tabs, tab, group, side, newGroup, newSplit, target, position + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .sync(let tabs): + try container.encode("sync", forKey: .type) + try container.encode(tabs, forKey: .tabs) + case .drop(let tab, let group, let side, let newGroup, let newSplit): + try container.encode("drop", forKey: .type) + try container.encode(tab, forKey: .tab) + try container.encode(group, forKey: .group) + try container.encode(side, forKey: .side) + try container.encodeIfPresent(newGroup, forKey: .newGroup) + try container.encodeIfPresent(newSplit, forKey: .newSplit) + case .canDrop(let tab, let group, let side): + try container.encode("canDrop", forKey: .type) + try container.encode(tab, forKey: .tab) + try container.encode(group, forKey: .group) + try container.encode(side, forKey: .side) + case .insert(let tab, let group, let side, let newGroup, let newSplit): + try container.encode("insert", forKey: .type) + try container.encode(tab, forKey: .tab) + try container.encode(group, forKey: .group) + try container.encode(side, forKey: .side) + try container.encodeIfPresent(newGroup, forKey: .newGroup) + try container.encodeIfPresent(newSplit, forKey: .newSplit) + case .move(let tab, let target, let position): + try container.encode("move", forKey: .type) + try container.encode(tab, forKey: .tab) + try container.encode(target, forKey: .target) + try container.encode(position, forKey: .position) + case .focus(let tab): + try container.encode("focus", forKey: .type) + try container.encode(tab, forKey: .tab) + } + } + } + + private struct WorkbenchState: Codable { + struct Tab: Codable { let id: String; let group: UUID? } + struct Selection: Codable { let group: UUID; let tab: String? } + let layout: EditorGroupLayout + let tabs: [Tab] + let selections: [Selection] + let focusedGroup: UUID? + } + + private struct WorkbenchRequest: Encodable { + let state: WorkbenchState + let action: WorkbenchAction + } + + private struct WorkbenchReduction: Decodable { + let applied: Bool + let state: WorkbenchState + } + + private static func workbenchID(_ item: EditorTabItem) -> String { + switch item { + case .document(let id): "document:\(id.uuidString)" + case .terminal(let id): "terminal:\(id.uuidString)" + case .media(let id): "media:\(id.uuidString)" + case .repositoryDiff(let id): "repositoryDiff:\(id.uuidString)" + case .agentConversation(let id): "agentConversation:\(id.uuidString)" + case .languageLogs: "languageLogs" + } + } + + /// Sends the current state and one action to Core and stores an applied + /// result. `previousItems` describes the state a `sync` started from; + /// `adding` names tabs the action may introduce. + @discardableResult + private func reduceWorkbench( + _ action: WorkbenchAction, + previousItems: [EditorTabItem]? = nil, + adding: [EditorTabItem] = [] + ) -> Bool { + let source = previousItems ?? items + let state = WorkbenchState( + layout: groupLayout, + tabs: source.map { WorkbenchState.Tab(id: Self.workbenchID($0), group: placements[$0]) }, + selections: groupLayout.groups.map { + WorkbenchState.Selection(group: $0, tab: groupSelections[$0].map(Self.workbenchID)) + }, + focusedGroup: focusedGroup) + let result: Result = RustCoreBridge().executeResult( + command: "editor.workbenchState.reduce", payload: WorkbenchRequest(state: state, action: action)) + guard case .success(let reduction) = result else { + // log-exempt: 同一次失败已经记录过:Core 请求轨迹已记录这次失败,标签分组保持原状态 + return false + } + if case .canDrop = action { return reduction.applied } + guard reduction.applied else { return false } + var lookup: [EditorTabItem] = items + lookup.append(contentsOf: source) + lookup.append(contentsOf: adding) + applyWorkbench(reduction.state, lookup: Dictionary( + lookup.map { (Self.workbenchID($0), $0) }, uniquingKeysWith: { first, _ in first })) return true } + + private func applyWorkbench(_ state: WorkbenchState, lookup: [String: EditorTabItem]) { + var nextPlacements: [EditorTabItem: UUID] = [:] + var nextItems: [EditorTabItem] = [] + for tab in state.tabs { + guard let item = lookup[tab.id] else { continue } + nextItems.append(item) + if let group = tab.group { nextPlacements[item] = group } + } + var selections: [UUID: EditorTabItem] = [:] + for selection in state.selections { + if let tab = selection.tab, let item = lookup[tab] { selections[selection.group] = item } + } + dropEligibility.removeAll() + placements = nextPlacements + focusedGroup = state.focusedGroup + if groupLayout != state.layout { groupLayout = state.layout } + if groupSelections != selections { groupSelections = selections } + if nextItems != items { + isApplyingWorkbenchState = true + defer { isApplyingWorkbenchState = false } + items = nextItems + } + } } diff --git a/macos/Sources/Lithe/Application/Features/EditorTextEditPlanner.swift b/macos/Sources/Lithe/Application/Features/EditorTextEditPlanner.swift index 88cffe8734..4c0ffaa0f2 100644 --- a/macos/Sources/Lithe/Application/Features/EditorTextEditPlanner.swift +++ b/macos/Sources/Lithe/Application/Features/EditorTextEditPlanner.swift @@ -1,76 +1,5 @@ import Foundation -/// Line table of a text in UTF-16 code units. CR, LF and CRLF each terminate one -/// line, matching Monaco's normalized model and LSP. -struct EditorTextLineIndex: Sendable { - let units: [UInt16] - /// UTF-16 offset of the first unit of each line. - private(set) var lineStarts: [Int] = [0] - /// UTF-16 offset just before each line's terminator (or the text end). - private(set) var contentEnds: [Int] = [] - let lineEnding: EditorLineEnding - /// Separator written for inserted line breaks: the first one in the text, LF when none. - let insertionSeparator: String - - init(_ text: String) { - units = Array(text.utf16) - var seen: Set = [] - var first: String? - var index = 0 - while index < units.count { - let unit = units[index] - guard unit == 13 || unit == 10 else { index += 1; continue } - contentEnds.append(index) - let separator: String - if unit == 13, index + 1 < units.count, units[index + 1] == 10 { - separator = "\r\n"; index += 2 - } else { - separator = unit == 13 ? "\r" : "\n"; index += 1 - } - if first == nil { first = separator } - seen.insert(separator) - lineStarts.append(index) - } - contentEnds.append(units.count) - switch seen.count { - case 0, 1: - lineEnding = first == "\r\n" ? .crlf : first == "\r" ? .cr : .lf - default: - lineEnding = .mixed - } - insertionSeparator = first ?? "\n" - } - - var lineCount: Int { lineStarts.count } - - /// UTF-16 offset of a one-based position, or `nil` when it does not exist. - func offset(of position: EditorTextPosition) -> Int? { - guard position.line >= 1, position.line <= lineCount, position.column >= 1 else { return nil } - let start = lineStarts[position.line - 1] - let offset = start + position.column - 1 - return offset <= contentEnds[position.line - 1] ? offset : nil - } - - /// One-based position of a UTF-16 offset that is not inside a CRLF pair. - func position(at offset: Int) -> EditorTextPosition { - let clamped = min(max(offset, 0), units.count) - // Last line start not after the offset. - var low = 0, high = lineStarts.count - 1 - while low < high { - let mid = (low + high + 1) / 2 - if lineStarts[mid] <= clamped { low = mid } else { high = mid - 1 } - } - let column = min(clamped, contentEnds[low]) - lineStarts[low] + 1 - return EditorTextPosition(line: low + 1, column: column) - } - - /// True when `offset` lies between a high and a low surrogate. - func splitsSurrogatePair(at offset: Int) -> Bool { - guard offset > 0, offset < units.count else { return false } - return UTF16.isLeadSurrogate(units[offset - 1]) && UTF16.isTrailSurrogate(units[offset]) - } -} - /// One validated edit in UTF-16 offsets of the source text. struct PlannedEditorTextEdit: Hashable, Sendable { /// Position of the edit in the caller's batch. @@ -84,41 +13,96 @@ struct PlannedEditorTextEdit: Hashable, Sendable { let normalizedText: String } -/// Validates a whole batch before anything is applied: every range must exist, -/// keep surrogate pairs intact and not overlap another edit. Zero-length -/// insertions at the same position are allowed and keep the caller's order. +/// A batch validated against one exact text, in stable application order. +struct EditorTextEditPlan: Hashable, Sendable { + let lineEnding: EditorLineEnding + /// Separator for inserted line breaks: the text's first one, LF when none. + let lineSeparator: String + let edits: [PlannedEditorTextEdit] +} + +/// Plugin document edits are planned by Rust Core (`editor.planTextEdits`), +/// which shares its line index, ordering and overlap rules with +/// `lsp.applyTextEdits`. Every range must exist, keep surrogate pairs intact +/// and not overlap another edit; zero-length insertions at the same position +/// keep the caller's order. The host keeps versions, undo and the surface. enum EditorTextEditPlanner { - static func plan(_ edits: [EditorTextEdit], in index: EditorTextLineIndex) throws -> [PlannedEditorTextEdit] { - var planned: [PlannedEditorTextEdit] = [] - planned.reserveCapacity(edits.count) - for (position, edit) in edits.enumerated() { - guard let start = index.offset(of: edit.range.start), let end = index.offset(of: edit.range.end) else { - throw EditorDocumentServiceError.rangeOutOfBounds(editIndex: position) - } - guard start <= end else { - throw EditorDocumentServiceError.invalidRange(editIndex: position, reason: .reversed) - } - guard !index.splitsSurrogatePair(at: start), !index.splitsSurrogatePair(at: end) else { - throw EditorDocumentServiceError.invalidRange(editIndex: position, reason: .splitsSurrogatePair) - } - let normalized = normalizeLineBreaks(edit.text, to: "\n") - planned.append(PlannedEditorTextEdit( - index: position, range: edit.range, utf16Offset: start, utf16Length: end - start, - text: index.insertionSeparator == "\n" ? normalized - : normalized.replacingOccurrences(of: "\n", with: index.insertionSeparator), - normalizedText: normalized)) + private struct WirePosition: Codable { let line: Int; let column: Int } + private struct WireRange: Encodable { let start: WirePosition; let end: WirePosition } + private struct WireEdit: Encodable { let range: WireRange; let text: String } + private struct Request: Encodable { + let text: String + let edits: [WireEdit] + let positions: [WirePosition] + } + private struct Response: Decodable { + struct Edit: Decodable { + let index: Int + let utf16Offset: Int + let utf16Length: Int + let text: String + let normalizedText: String + } + struct Rejection: Decodable { + let reason: String + let editIndex: Int? + let otherEditIndex: Int? + let positionIndex: Int? + } + let lineEnding: String + let lineSeparator: String + let edits: [Edit] + let rejection: Rejection? + } + + /// Plans `edits` against `text`. `positions` must also exist in `text` + /// (for example reported selection endpoints); a missing one fails with + /// `editorUnavailable`, because it means the editor and its mirror diverged. + /// Copies the whole text across the Core boundary; call away from the main + /// actor for large batches. + nonisolated static func plan( + _ edits: [EditorTextEdit], + in text: String, + positions: [EditorTextPosition] = [] + ) throws -> EditorTextEditPlan { + func wire(_ position: EditorTextPosition) -> WirePosition { + // Core reads one-based unsigned values; zero is rejected there as out of bounds. + WirePosition(line: max(position.line, 0), column: max(position.column, 0)) + } + let request = Request( + text: text, + edits: edits.map { WireEdit(range: WireRange(start: wire($0.range.start), end: wire($0.range.end)), text: $0.text) }, + positions: positions.map(wire)) + let result: Result = RustCoreBridge().executeResult( + command: "editor.planTextEdits", payload: request) + guard case .success(let response) = result, + let lineEnding = EditorLineEnding(rawValue: response.lineEnding) else { + throw EditorDocumentServiceError.editorUnavailable } - // Stable order: by start, then end, then the caller's order. - planned.sort { - ($0.utf16Offset, $0.utf16Offset + $0.utf16Length, $0.index) - < ($1.utf16Offset, $1.utf16Offset + $1.utf16Length, $1.index) + if let rejection = response.rejection { + throw error(for: rejection) } - for (previous, next) in zip(planned, planned.dropFirst()) - where next.utf16Offset < previous.utf16Offset + previous.utf16Length { - throw EditorDocumentServiceError.overlappingRanges( - editIndex: min(previous.index, next.index), otherEditIndex: max(previous.index, next.index)) + return EditorTextEditPlan( + lineEnding: lineEnding, + lineSeparator: response.lineSeparator, + edits: try response.edits.map { edit in + guard edits.indices.contains(edit.index) else { throw EditorDocumentServiceError.editorUnavailable } + return PlannedEditorTextEdit( + index: edit.index, range: edits[edit.index].range, utf16Offset: edit.utf16Offset, + utf16Length: edit.utf16Length, text: edit.text, normalizedText: edit.normalizedText) + }) + } + + private nonisolated static func error(for rejection: Response.Rejection) -> EditorDocumentServiceError { + guard let index = rejection.editIndex else { return .editorUnavailable } + switch rejection.reason { + case "rangeOutOfBounds": return .rangeOutOfBounds(editIndex: index) + case "reversed": return .invalidRange(editIndex: index, reason: .reversed) + case "splitsSurrogatePair": return .invalidRange(editIndex: index, reason: .splitsSurrogatePair) + case "overlappingRanges": + return .overlappingRanges(editIndex: index, otherEditIndex: rejection.otherEditIndex ?? index) + default: return .editorUnavailable } - return planned } /// Applies a validated plan to the text it was planned against. @@ -129,16 +113,4 @@ enum EditorTextEditPlanner { } return result as String } - - static func normalizeLineBreaks(_ text: String, to separator: String) -> String { - guard text.contains(where: { $0 == "\r" || $0 == "\n" || $0 == "\r\n" }) else { return text } - var result = "" - result.reserveCapacity(text.utf16.count) - // Swift treats CRLF as one Character, so each Character is one line break. - for character in text { - if character == "\r\n" || character == "\r" || character == "\n" { result += separator } - else { result.append(character) } - } - return result - } } diff --git a/macos/Sources/Lithe/Application/Features/NavigationHistoryFeatureModel.swift b/macos/Sources/Lithe/Application/Features/NavigationHistoryFeatureModel.swift index 1007e28fc4..863e3b8eca 100644 --- a/macos/Sources/Lithe/Application/Features/NavigationHistoryFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/NavigationHistoryFeatureModel.swift @@ -1,5 +1,6 @@ import Combine import Foundation +import LitheModuleAPI struct EditorNavigationLocation: Hashable, Sendable { let url: URL @@ -36,18 +37,20 @@ struct NavigationHistorySnapshot: Equatable, Sendable { let forwardLocations: [EditorNavigationLocation] } -/// Owns bounded editor-location history independently from document tabs. -/// A jump records the live departure location so caret movement since the last -/// navigation is preserved when the user returns. +/// Holds editor-location history independently from document tabs. Rust Core +/// (`editor.navigationHistory.reduce`) owns the stack rules: departure +/// recording, deduplication, branch truncation, capacity and skipping entries +/// equal to the live location. A jump records the live departure location so +/// caret movement since the last navigation is preserved when the user returns. @MainActor final class NavigationHistoryFeatureModel: ObservableObject { @Published private(set) var backLocations: [EditorNavigationLocation] = [] @Published private(set) var forwardLocations: [EditorNavigationLocation] = [] - private let maximumEntryCount: Int + private let logSink: (any LogSink)? - init(maximumEntryCount: Int = 100) { - self.maximumEntryCount = max(1, maximumEntryCount) + init(logSink: (any LogSink)?) { + self.logSink = logSink } var canNavigateBack: Bool { !backLocations.isEmpty } @@ -57,25 +60,15 @@ final class NavigationHistoryFeatureModel: ObservableObject { from departure: EditorNavigationLocation?, to destination: EditorNavigationLocation ) { - guard let departure, departure != destination else { return } - append(departure, to: &backLocations) - forwardLocations.removeAll() + _ = reduce(.record(departure: departure.map(CoreLocation.init), destination: CoreLocation(destination))) } func navigateBack(from current: EditorNavigationLocation?) -> EditorNavigationLocation? { - guard let destination = backLocations.popLast() else { return nil } - if let current, current != destination { - append(current, to: &forwardLocations) - } - return destination + reduce(.back(current: current.map(CoreLocation.init))) } func navigateForward(from current: EditorNavigationLocation?) -> EditorNavigationLocation? { - guard let destination = forwardLocations.popLast() else { return nil } - if let current, current != destination { - append(current, to: &backLocations) - } - return destination + reduce(.forward(current: current.map(CoreLocation.init))) } func reset() { @@ -95,15 +88,116 @@ final class NavigationHistoryFeatureModel: ObservableObject { forwardLocations = snapshot.forwardLocations } - private func append( - _ location: EditorNavigationLocation, - to locations: inout [EditorNavigationLocation] - ) { - if locations.last != location { - locations.append(location) + /// Applies one Core transition and returns the location to open, if any. + /// A Core failure leaves history unchanged and is logged. + private func reduce(_ event: CoreEvent) -> EditorNavigationLocation? { + struct Request: Encodable { let state: CoreState; let event: CoreEvent } + struct Response: Decodable { let state: CoreState; let destination: CoreLocation? } + let state = CoreState( + back: backLocations.map(CoreLocation.init), + forward: forwardLocations.map(CoreLocation.init) + ) + let result: Result = RustCoreBridge().executeResult( + command: "editor.navigationHistory.reduce", payload: Request(state: state, event: event)) + switch result { + case .success(let response): + guard let back = response.state.back.editorLocations, + let forward = response.state.forward.editorLocations else { + logSink?.emit(AppCoreFallbackSelected( + error: LogFailure(domain: "RustCore", code: "invalid_location"), + stage: "editor.navigationHistory.reduce")) + return nil + } + backLocations = back + forwardLocations = forward + return response.destination?.editorLocation + case .failure(let error): + logSink?.emit(AppCoreFallbackSelected( + error: LogFailure(domain: "RustCore", code: error.code), + stage: "editor.navigationHistory.reduce")) + return nil } - if locations.count > maximumEntryCount { - locations.removeFirst(locations.count - maximumEntryCount) + } +} + +/// Wire form of one history location. Core compares `document`, `line` and +/// `column`; `context` carries the macOS presentation fields unchanged. +private struct CoreLocation: Codable { + struct Context: Codable { + let isReadOnly: Bool + let displayPath: String? + let virtualProviderID: String? + let selectsWholeLine: Bool + } + + let document: String + let line: Int + let column: Int + let context: Context? + + init(_ location: EditorNavigationLocation) { + document = location.url.absoluteString + line = location.line + column = location.utf16Column + context = Context( + isReadOnly: location.isReadOnly, + displayPath: location.displayPath, + virtualProviderID: location.virtualProviderID, + selectsWholeLine: location.selectsWholeLine + ) + } + + var editorLocation: EditorNavigationLocation? { + guard let url = URL(string: document) else { return nil } + return EditorNavigationLocation( + url: url, + line: line, + utf16Column: column, + isReadOnly: context?.isReadOnly ?? false, + displayPath: context?.displayPath, + virtualProviderID: context?.virtualProviderID, + selectsWholeLine: context?.selectsWholeLine ?? false + ) + } +} + +private struct CoreState: Codable { + let back: [CoreLocation] + let forward: [CoreLocation] +} + +private enum CoreEvent: Encodable { + case record(departure: CoreLocation?, destination: CoreLocation) + case back(current: CoreLocation?) + case forward(current: CoreLocation?) + + private enum CodingKeys: String, CodingKey { case type, departure, destination, current } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case let .record(departure, destination): + try container.encode("record", forKey: .type) + try container.encode(departure, forKey: .departure) + try container.encode(destination, forKey: .destination) + case let .back(current): + try container.encode("back", forKey: .type) + try container.encode(current, forKey: .current) + case let .forward(current): + try container.encode("forward", forKey: .type) + try container.encode(current, forKey: .current) + } + } +} + +private extension Array where Element == CoreLocation { + var editorLocations: [EditorNavigationLocation]? { + var locations: [EditorNavigationLocation] = [] + locations.reserveCapacity(count) + for element in self { + guard let location = element.editorLocation else { return nil } + locations.append(location) } + return locations } } diff --git a/macos/Sources/Lithe/Core/Rust/RustLanguageToolingPolicy.swift b/macos/Sources/Lithe/Core/Rust/RustLanguageToolingPolicy.swift new file mode 100644 index 0000000000..ec6fb552b1 --- /dev/null +++ b/macos/Sources/Lithe/Core/Rust/RustLanguageToolingPolicy.swift @@ -0,0 +1,44 @@ +import Foundation +import LitheCoreContracts + +/// Rust Core implementation of the shared language-tooling rules +/// (`lsp.activationPolicy`, `lsp.mergeCompletions`). +extension RustCoreBridge: LanguageToolingPolicyCore { + package func languageServerActivation( + state: LanguageServerActivationState?, + event: LanguageServerActivationEvent, + nowMs: UInt64 + ) -> Result { + struct Request: Encodable { + let state: LanguageServerActivationState? + let event: LanguageServerActivationEvent + let nowMs: UInt64 + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + // Core distinguishes "no history" (`null`) from a missing field. + try container.encode(state, forKey: .state) + try container.encode(event, forKey: .event) + try container.encode(nowMs, forKey: .nowMs) + } + + enum CodingKeys: String, CodingKey { case state, event, nowMs } + } + let result: Result = executeResult( + command: "lsp.activationPolicy", payload: Request(state: state, event: event, nowMs: nowMs)) + return result.mapError { LanguageToolingPolicyFailure(code: $0.code) } + } + + package func mergeCompletions( + labels lists: [[String]], incomplete: [Bool] + ) -> Result { + struct List: Encodable { let labels: [String]; let isIncomplete: Bool } + struct Request: Encodable { let lists: [List] } + struct Response: Decodable { let keep: [[Int]]; let isIncomplete: Bool } + let request = Request(lists: zip(lists, incomplete).map { List(labels: $0, isIncomplete: $1) }) + let result: Result = executeResult(command: "lsp.mergeCompletions", payload: request) + return result + .map { CompletionMergeSelection(keep: $0.keep, isIncomplete: $0.isIncomplete) } + .mapError { LanguageToolingPolicyFailure(code: $0.code) } + } +} diff --git a/macos/Sources/Lithe/Models/Editor/ProblemsQuery.swift b/macos/Sources/Lithe/Models/Editor/ProblemsQuery.swift new file mode 100644 index 0000000000..f460cf9c46 --- /dev/null +++ b/macos/Sources/Lithe/Models/Editor/ProblemsQuery.swift @@ -0,0 +1,81 @@ +import Foundation +import LitheModuleAPI + +/// Problems tool-window order and grouping returned by Rust Core +/// (`lsp.problems.query`). Core owns filtering, severity order, natural file +/// order, and counts; the view only renders the returned groups. +struct ProblemsQueryResult: Equatable, Sendable { + struct FileGroup: Equatable, Sendable { + let fileURL: URL + let diagnostics: [EditorDiagnostic] + } + + let groups: [FileGroup] + let errorCount: Int + let warningCount: Int + + static let empty = ProblemsQueryResult(groups: [], errorCount: 0, warningCount: 0) + + var diagnostics: [EditorDiagnostic] { groups.flatMap(\.diagnostics) } +} + +enum ProblemsQuery { + /// Runs the shared query off the main actor. `displayPaths` maps each + /// diagnostic file to its workspace-relative path, which decides file order. + static func run( + diagnostics: [EditorDiagnostic], + severities: Set, + displayPaths: [URL: String], + logSink: (any LogSink)? + ) -> ProblemsQueryResult { + guard !diagnostics.isEmpty else { return .empty } + struct Facts: Encodable { + let fileKey: String + let displayPath: String + let line: Int + let column: Int + let severity: Int + let message: String + let source: String? + let code: String? + } + struct Filter: Encodable { let severities: [Int] } + struct Request: Encodable { + let diagnostics: [Facts] + let groupBy = "file" + let sortBy = "severity" + let filter: Filter + } + struct Counts: Decodable { let error: Int; let warning: Int } + struct Group: Decodable { let indices: [Int] } + struct Response: Decodable { + let groups: [Group] + let visibleCounts: Counts + } + let request = Request( + diagnostics: diagnostics.map { + Facts(fileKey: $0.fileURL.absoluteString, + displayPath: displayPaths[$0.fileURL] ?? $0.fileURL.path, + line: $0.line, column: $0.utf16Column, severity: $0.severity.rawValue, + message: $0.message, source: $0.source, code: $0.code) + }, + filter: Filter(severities: severities.map(\.rawValue).sorted()) + ) + let result: Result = RustCoreBridge().executeResult( + command: "lsp.problems.query", payload: request) + switch result { + case .success(let response): + let groups = response.groups.compactMap { group -> ProblemsQueryResult.FileGroup? in + let members = group.indices.compactMap { diagnostics.indices.contains($0) ? diagnostics[$0] : nil } + guard let first = members.first else { return nil } + return ProblemsQueryResult.FileGroup(fileURL: first.fileURL, diagnostics: members) + } + return ProblemsQueryResult(groups: groups, errorCount: response.visibleCounts.error, + warningCount: response.visibleCounts.warning) + case .failure(let error): + logSink?.emit(AppCoreFallbackSelected(error: LogFailure(domain: "RustCore", code: error.code), + stage: "lsp.problems.query")) + return .empty + } + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 7cd7cff55b..9a6ea603df 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -633,6 +633,7 @@ final class MacServiceContainer { runtimes: registry.toolingRuntimes, runtimeFactory: runtimeFactory, builtinCore: rustCore, + policyCore: rustCore, extensionRequiredProviderIDs: pluginLanguageIDs, isLanguageServerEnabled: { providerID, workspaceURL in !workspaceLanguageServerPreferences.disabledProviderIDs(for: workspaceURL) diff --git a/macos/Sources/Lithe/Services/Language/LanguageServerTextEditApplicator.swift b/macos/Sources/Lithe/Services/Language/LanguageServerTextEditApplicator.swift index ccc42b86df..b6c42e3180 100644 --- a/macos/Sources/Lithe/Services/Language/LanguageServerTextEditApplicator.swift +++ b/macos/Sources/Lithe/Services/Language/LanguageServerTextEditApplicator.swift @@ -14,63 +14,18 @@ enum LanguageServerTextEditApplicator { } } + /// Core owns range validation, line counting, ordering and overlap rules, + /// shared with plugin document edits (`editor.planTextEdits`). static func apply(_ edits: [LanguageServerTextEdit], to text: String) throws -> String { - let core = RustCoreBridge() - if core.isAvailable { - switch core.applyLanguageServerTextEdits(edits, to: text) { - case .success(let payload): - return payload.text - case .failure(let error): - switch error.details { - case "overlappingEdits": throw Error.overlappingEdits - default: throw Error.invalidRange - } + switch RustCoreBridge().applyLanguageServerTextEdits(edits, to: text) { + case .success(let payload): + return payload.text + case .failure(let error): + switch error.details { + case "overlappingEdits": throw Error.overlappingEdits + default: throw Error.invalidRange } } - return try applyFallback(edits, to: text) - } - - private static func applyFallback(_ edits: [LanguageServerTextEdit], to text: String) throws -> String { - let source = text as NSString - var replacements: [(NSRange, String)] = [] - for edit in edits { - guard let start = utf16Offset(edit.range.start, in: source), - let end = utf16Offset(edit.range.end, in: source), - end >= start else { throw Error.invalidRange } - replacements.append((NSRange(location: start, length: end - start), edit.newText)) - } - replacements.sort { $0.0.location > $1.0.location } - for index in 0.. Int? { - guard position.line >= 0, position.utf16Column >= 0 else { return nil } - var line = 0 - var cursor = 0 - while cursor < source.length, line < position.line { - var lineEnd = 0 - source.getLineStart(nil, end: &lineEnd, contentsEnd: nil, for: NSRange(location: cursor, length: 0)) - guard lineEnd > cursor else { return nil } - cursor = lineEnd - line += 1 - } - guard line == position.line, cursor <= source.length else { return nil } - var lineEnd = source.length - var contentsEnd = source.length - source.getLineStart(nil, end: &lineEnd, contentsEnd: &contentsEnd, for: NSRange(location: cursor, length: 0)) - return cursor + min(position.utf16Column, max(0, contentsEnd - cursor)) } } diff --git a/macos/Sources/Lithe/Views/Language/JavaProblemsView.swift b/macos/Sources/Lithe/Views/Language/JavaProblemsView.swift index 1b98b32178..74a9852366 100644 --- a/macos/Sources/Lithe/Views/Language/JavaProblemsView.swift +++ b/macos/Sources/Lithe/Views/Language/JavaProblemsView.swift @@ -11,6 +11,7 @@ struct ProblemsView: View { @State private var selectedDiagnosticID: String? @State private var resolvedFileIcons: [URL: LitheIconKind] = [:] @State private var severityFilter = Set(DiagnosticSeverity.allCases) + @State private var queryResult = ProblemsQueryResult.empty init(initialScope: ProblemsPanelScope = .file, previewEnabled: Bool = false, runnableFileURLs: Set = []) { @@ -44,6 +45,7 @@ struct ProblemsView: View { } } .litheWorkbenchSurface(LitheTheme.editor) + .task(id: queryInput) { await refreshQuery(queryInput) } .background(LitheToolWindowActivityTracker(isActive: $toolWindowActive)) .onDisappear { model.documentFeature.discardPreviewDocuments(owner: .problems) } .onChange(of: previewEnabled) { enabled in @@ -118,8 +120,8 @@ struct ProblemsView: View { private var problemRows: [ProblemRow] { diagnosticGroups.flatMap { group in - [ProblemRow(id: "file:" + group.key.absoluteString, file: group.key, count: group.value.count)] - + group.value.map { ProblemRow(id: $0.id, diagnostic: $0) } + [ProblemRow(id: "file:" + group.fileURL.absoluteString, file: group.fileURL, count: group.diagnostics.count)] + + group.diagnostics.map { ProblemRow(id: $0.id, diagnostic: $0) } } } @@ -213,37 +215,41 @@ struct ProblemsView: View { } } - private var scopedDiagnostics: [EditorDiagnostic] { - scope.diagnostics(in: diagnosticsStore.diagnosticsByURL, currentFileURL: currentFileURL) - .sorted { - let left = model.relativePath(for: $0.fileURL) - let right = model.relativePath(for: $1.fileURL) - if left != right { return left.localizedStandardCompare(right) == .orderedAscending } - if $0.line != $1.line { return $0.line < $1.line } - return $0.utf16Column < $1.utf16Column - } + /// Everything the shared Problems query depends on; a change reruns it. + private struct QueryInput: Equatable, Sendable { + let diagnostics: [EditorDiagnostic] + let severities: Set } - private var diagnostics: [EditorDiagnostic] { - scopedDiagnostics.filter { severityFilter.contains($0.severity) } + private var queryInput: QueryInput { + QueryInput( + diagnostics: scope.diagnostics(in: diagnosticsStore.diagnosticsByURL, currentFileURL: currentFileURL), + severities: severityFilter + ) } - private var errorCount: Int { - diagnostics.filter { $0.severity == .error }.count + private func refreshQuery(_ input: QueryInput) async { + var displayPaths: [URL: String] = [:] + for diagnostic in input.diagnostics where displayPaths[diagnostic.fileURL] == nil { + displayPaths[diagnostic.fileURL] = model.relativePath(for: diagnostic.fileURL) + } + let logSink = model.services.logSink + let result = await Task.detached(priority: .userInitiated) { + ProblemsQuery.run(diagnostics: input.diagnostics, severities: input.severities, + displayPaths: displayPaths, logSink: logSink) + }.value + // A newer input restarted the task; drop this obsolete result. + guard !Task.isCancelled else { return } + queryResult = result } - private var warningCount: Int { - diagnostics.filter { $0.severity == .warning }.count - } + private var diagnostics: [EditorDiagnostic] { queryResult.diagnostics } - private var diagnosticGroups: [(key: URL, value: [EditorDiagnostic])] { - Dictionary(grouping: diagnostics, by: \.fileURL).sorted { - let comparison = model.relativePath(for: $0.key) - .localizedStandardCompare(model.relativePath(for: $1.key)) - return comparison == .orderedSame - ? $0.key.path < $1.key.path : comparison == .orderedAscending - } - } + private var errorCount: Int { queryResult.errorCount } + + private var warningCount: Int { queryResult.warningCount } + + private var diagnosticGroups: [ProblemsQueryResult.FileGroup] { queryResult.groups } private func fileHeader(_ url: URL, count: Int) -> some View { HStack(spacing: LitheTheme.Tree.iconTextGap) { diff --git a/macos/Sources/LitheCoreContracts/Language/LanguageToolingPolicyCore.swift b/macos/Sources/LitheCoreContracts/Language/LanguageToolingPolicyCore.swift new file mode 100644 index 0000000000..4c5efa8661 --- /dev/null +++ b/macos/Sources/LitheCoreContracts/Language/LanguageToolingPolicyCore.swift @@ -0,0 +1,80 @@ +import Foundation + +/// Startup failure history returned by Rust Core (`lsp.activationPolicy`). +/// The session manager stores it per provider and workspace root verbatim. +package struct LanguageServerActivationState: Codable, Equatable, Sendable { + package let consecutiveFailures: Int + /// Monotonic milliseconds of the latest failure. + package let lastFailureAtMs: UInt64 + + package init(consecutiveFailures: Int, lastFailureAtMs: UInt64) { + self.consecutiveFailures = consecutiveFailures + self.lastFailureAtMs = lastFailureAtMs + } +} + +package enum LanguageServerActivationEvent: String, Codable, Sendable { + /// A start triggered by opening or synchronizing a document. + case automaticStart + /// A start the user requested. + case explicitStart + /// A start or a running session failed. + case startFailed + /// The session reached its ready state. + case ready +} + +package enum LanguageServerActivationDecision: String, Codable, Sendable { + case allow + case wait + case explicitRetryRequired +} + +package struct LanguageServerActivationOutcome: Codable, Equatable, Sendable { + package let state: LanguageServerActivationState? + package let decision: LanguageServerActivationDecision + package let retryAfterMs: UInt64? + + package init(state: LanguageServerActivationState?, decision: LanguageServerActivationDecision, + retryAfterMs: UInt64?) { + self.state = state + self.decision = decision + self.retryAfterMs = retryAfterMs + } +} + +/// Candidates to keep after merging prioritized completion providers. +package struct CompletionMergeSelection: Equatable, Sendable { + /// For each submitted list, the indices to keep in order. + package let keep: [[Int]] + package let isIncomplete: Bool + + package init(keep: [[Int]], isIncomplete: Bool) { + self.keep = keep + self.isIncomplete = isIncomplete + } +} + +/// A Core call that could not be answered, identified by its stable error code. +package struct LanguageToolingPolicyFailure: Error, Equatable, Sendable { + package let code: String + + package init(code: String) { + self.code = code + } +} + +/// Shared language-tooling rules evaluated by Rust Core. A failure means Core +/// could not answer; the caller logs it and keeps the session usable. +package protocol LanguageToolingPolicyCore: Sendable { + func languageServerActivation( + state: LanguageServerActivationState?, + event: LanguageServerActivationEvent, + nowMs: UInt64 + ) -> Result + + /// `lists` are completion labels ordered from the highest-priority provider. + func mergeCompletions( + labels lists: [[String]], incomplete: [Bool] + ) -> Result +} diff --git a/macos/Sources/LitheLanguageIntelligenceModule/Providers/LanguageFeatureProvider.swift b/macos/Sources/LitheLanguageIntelligenceModule/Providers/LanguageFeatureProvider.swift index 9076538196..05f42378d1 100644 --- a/macos/Sources/LitheLanguageIntelligenceModule/Providers/LanguageFeatureProvider.swift +++ b/macos/Sources/LitheLanguageIntelligenceModule/Providers/LanguageFeatureProvider.swift @@ -120,11 +120,9 @@ package final class BuiltinLanguageFeatureProvider: LanguageFeatureProvider { self.init(core: UnavailableBuiltinLanguageFeatureCore()) } - package func supports(_ feature: LanguageFeature, in context: LanguageFeatureRequestContext) -> Bool { + package func supports(_ feature: LanguageFeature, in _: LanguageFeatureRequestContext) -> Bool { switch feature { - case .completion: - return core.isBuiltinLanguageFeatureAvailable || Self.keywordLanguage(for: context) != nil - case .hover, .navigation: + case .completion, .hover, .navigation: return core.isBuiltinLanguageFeatureAvailable } } @@ -133,18 +131,17 @@ package final class BuiltinLanguageFeatureProvider: LanguageFeatureProvider { in context: LanguageFeatureRequestContext, completion: @escaping (Result<[LanguageServerCompletionItem], Error>) -> Void ) throws { - let symbols = core.isBuiltinLanguageFeatureAvailable - ? core.builtinLanguageCompletions( - fileURL: context.fileURL, - text: context.text, - position: context.position - ) ?? [] - : [] - let keywords = Self.keywordCompletions(in: context) - - var seenLabels = Set() - let merged = (symbols + keywords).filter { seenLabels.insert($0.label).inserted } - completion(.success(merged)) + // Language keywords come from the language server or plugin; the + // fallback only offers current-file identifiers computed by Core. + completion(.success( + core.isBuiltinLanguageFeatureAvailable + ? core.builtinLanguageCompletions( + fileURL: context.fileURL, + text: context.text, + position: context.position + ) ?? [] + : [] + )) } package func hover( @@ -265,157 +262,3 @@ package final class LanguageServerFeatureProvider: LanguageFeatureProvider { } } } - -private extension BuiltinLanguageFeatureProvider { - enum KeywordLanguage { - case go - case swift - case rust - case python - case javaScript - case typeScript - - var displayName: String { - switch self { - case .go: "Go" - case .swift: "Swift" - case .rust: "Rust" - case .python: "Python" - case .javaScript: "JavaScript" - case .typeScript: "TypeScript" - } - } - - var keywords: Set { - switch self { - case .go: - return [ - "break", "case", "chan", "const", "continue", "default", "defer", "else", - "fallthrough", "for", "func", "go", "goto", "if", "import", "interface", - "map", "package", "range", "return", "select", "struct", "switch", "type", "var" - ] - case .swift: - return [ - "actor", "any", "as", "associatedtype", "async", "await", "break", "case", - "catch", "class", "continue", "convenience", "default", "defer", "deinit", "didSet", - "distributed", "do", "dynamic", "else", "enum", "extension", "fallthrough", "fileprivate", - "final", "for", "func", "get", "guard", "if", "import", "in", "indirect", "infix", - "init", "inout", "internal", "is", "isolated", "lazy", "let", "macro", "mutating", - "nonisolated", "nonmutating", "open", "operator", "optional", "override", "package", - "postfix", "precedencegroup", "prefix", "private", "protocol", "public", "repeat", "required", - "rethrows", "return", "set", "some", "static", "struct", "subscript", "super", "switch", - "throws", "try", "typealias", "unowned", "var", "weak", "where", "while", "willSet" - ] - case .rust: - return [ - "Self", "abstract", "as", "async", "await", "become", "box", "break", "const", "continue", - "crate", "do", "dyn", "else", "enum", "extern", "false", "final", "fn", "for", "gen", - "if", "impl", "in", "let", "loop", "macro", "match", "mod", "move", "mut", "override", - "priv", "pub", "ref", "return", "self", "static", "struct", "super", "trait", "true", - "try", "type", "typeof", "union", "unsafe", "unsized", "use", "virtual", "where", "while", "yield" - ] - case .python: - return [ - "False", "None", "True", "and", "as", "assert", "async", "await", "break", "case", - "class", "continue", "def", "del", "elif", "else", "except", "finally", "for", "from", - "global", "if", "import", "in", "is", "lambda", "match", "nonlocal", "not", "or", "pass", - "raise", "return", "try", "while", "with", "yield" - ] - case .javaScript: - return Self.javaScriptKeywords - case .typeScript: - return Self.javaScriptKeywords.union([ - "abstract", "any", "as", "asserts", "bigint", "boolean", "constructor", "declare", "enum", - "from", "get", "implements", "infer", "interface", "is", "keyof", "module", "namespace", - "never", "number", "object", "override", "private", "protected", "public", "readonly", "require", - "set", "string", "symbol", "type", "undefined", "unique", "unknown" - ]) - } - } - - private static let javaScriptKeywords: Set = [ - "async", "await", "break", "case", "catch", "class", "const", "continue", "debugger", "default", - "delete", "do", "else", "export", "extends", "false", "finally", "for", "function", "if", "import", - "in", "instanceof", "let", "new", "null", "of", "return", "static", "super", "switch", "this", - "throw", "true", "try", "typeof", "var", "void", "while", "with", "yield" - ] - } - - static func keywordLanguage(for context: LanguageFeatureRequestContext) -> KeywordLanguage? { - if let languageID = context.languageID?.lowercased() { - switch languageID { - case "go", "golang": return .go - case "swift": return .swift - case "rust": return .rust - case "python": return .python - case "javascript", "javascriptreact", "jsx": return .javaScript - case "typescript", "typescriptreact", "tsx": return .typeScript - default: break - } - } - - switch context.fileURL.pathExtension.lowercased() { - case "go": return .go - case "swift": return .swift - case "rs": return .rust - case "py", "pyw": return .python - case "js", "jsx", "mjs", "cjs": return .javaScript - case "ts", "tsx", "mts", "cts": return .typeScript - default: return nil - } - } - - static func keywordCompletions( - in context: LanguageFeatureRequestContext - ) -> [LanguageServerCompletionItem] { - guard let language = keywordLanguage(for: context) else { return [] } - let prefix = identifierPrefix(in: context.text, at: context.position) - let startColumn = max(0, context.position.utf16Column - prefix.utf16.count) - let editRange = LanguageServerRange( - start: LanguageServerPosition(line: context.position.line, utf16Column: startColumn), - end: context.position - ) - - return language.keywords - .filter { prefix.isEmpty || $0.hasPrefix(prefix) } - .sorted() - .map { keyword in - LanguageServerCompletionItem( - label: keyword, - detail: "\(language.displayName) keyword", - documentation: nil, - insertText: keyword, - sortText: "zz_keyword_\(keyword)", - filterText: keyword, - kind: 14, - textEdit: LanguageServerTextEdit(range: editRange, newText: keyword), - additionalTextEdits: [], - data: nil - ) - } - } - - static func identifierPrefix(in text: String, at position: LanguageServerPosition) -> String { - guard position.line >= 0, position.utf16Column >= 0 else { return "" } - let lines = text.split(separator: "\n", omittingEmptySubsequences: false) - guard position.line < lines.count else { return "" } - - let line = lines[position.line] - let utf16 = line.utf16 - guard position.utf16Column <= utf16.count else { return "" } - let utf16Index = utf16.index(utf16.startIndex, offsetBy: position.utf16Column) - guard let cursor = String.Index(utf16Index, within: line) else { return "" } - - var start = cursor - while start > line.startIndex { - let previous = line.index(before: start) - guard isIdentifierCharacter(line[previous]) else { break } - start = previous - } - return String(line[start.. Bool { - character == "_" || character == "$" || character.isLetter || character.isNumber - } -} diff --git a/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift b/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift index 23b0e203b1..906635e923 100644 --- a/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift +++ b/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift @@ -87,8 +87,12 @@ package final class LanguageToolingSessionManager: ObservableObject, /// resource release. A timed-out close must not admit an overlapping start. private var stoppingServers: [String: (id: UUID, session: any LanguageServerSession, task: Task)] = [:] private var languageServerRoots: [String: URL] = [:] - private static let maximumStartupAttempts = 3 - private var languageServerStartupAttempts: [String: (root: URL, count: Int)] = [:] + /// Core-owned startup failure history (`lsp.activationPolicy`) per provider + /// and workspace root; a different root starts without history. + private var languageServerActivation: [String: (root: URL, state: LanguageServerActivationState)] = [:] + private let policyCore: (any LanguageToolingPolicyCore)? + /// Monotonic milliseconds for the activation policy; tests inject a clock. + package var monotonicNowMs: () -> UInt64 = { DispatchTime.now().uptimeNanoseconds / 1_000_000 } /// Core can clear Maven settings only for a session launched with Maven /// inputs. Keep this with the session so a pure Gradle start stays nil. private var languageServerMavenContexts: [String: MavenLaunchContext] = [:] @@ -118,6 +122,7 @@ package final class LanguageToolingSessionManager: ObservableObject, runtimes: [any LanguageProviderRuntime] = [], runtimeFactory: (any LanguageProviderRuntimeFactory)? = nil, builtinCore: (any BuiltinLanguageFeatureCore)? = nil, + policyCore: (any LanguageToolingPolicyCore)? = nil, languageFeatureProviders: [any LanguageFeatureProvider] = [], extensionRequiredProviderIDs: Set = [], isLanguageServerEnabled: @escaping (String, URL) -> Bool = { _, _ in true }, @@ -126,6 +131,7 @@ package final class LanguageToolingSessionManager: ObservableObject, workspaceStateCleaner: ((LanguageProviderDescriptor, URL, String?) throws -> Int)? = nil ) { self.pluginOperations = pluginOperations + self.policyCore = policyCore self.logSink = logSink self.resolveLogSubject = resolveLogSubject self.catalog = catalog @@ -1220,7 +1226,7 @@ package final class LanguageToolingSessionManager: ObservableObject, } private func removeLanguageServer(providerID: String, stopSession: Bool) { - languageServerStartupAttempts[providerID] = nil + languageServerActivation[providerID] = nil if providerID == "java" { projectPreparation = nil } if providerID == "java" { javaDependencyChangeTask?.cancel() @@ -1282,7 +1288,7 @@ package final class LanguageToolingSessionManager: ObservableObject, languageServerSessionIdentities = [:] languageServerFeatureProviders = [:] languageServerStates = [:] - languageServerStartupAttempts = [:] + languageServerActivation = [:] languageServerOperationIDs = [:] } @@ -1458,9 +1464,7 @@ package final class LanguageToolingSessionManager: ObservableObject, providers: providers, index: 0, context: context, - items: [], - isIncomplete: false, - seenLabels: [], + lists: [], completion: completion ) } @@ -1820,12 +1824,18 @@ package final class LanguageToolingSessionManager: ObservableObject, return active } - let previousAttempt = languageServerStartupAttempts[descriptor.id] - let attempts = previousAttempt?.root == rootURL ? previousAttempt?.count ?? 0 : 0 + let previousActivation = languageServerActivation[descriptor.id] + let activationHistory = previousActivation?.root == rootURL ? previousActivation?.state : nil // Document synchronization must not turn a persistent startup failure - // into an unbounded process loop. Explicit stop/restart clears the budget. - if attempts >= Self.maximumStartupAttempts, - case .failed(let failure) = languageServerStates[descriptor.id] { + // into an unbounded process loop. Explicit stop/restart clears the history. + if let outcome = activationOutcome(.automaticStart, providerID: descriptor.id, history: activationHistory), + outcome.decision != .allow { + let failure: LanguageServerSessionFailure + if case .failed(let previous) = languageServerStates[descriptor.id] { + failure = previous + } else { + failure = LanguageServerSessionFailure(code: "start_failed", stage: "processStart", message: nil) + } throw LanguageServerSessionStartError(failure: failure) } @@ -1852,7 +1862,8 @@ package final class LanguageToolingSessionManager: ObservableObject, if languageServers[descriptor.id] != nil || languageServerRoots[descriptor.id] != nil { stopLanguageServer(providerID: descriptor.id) } - languageServerStartupAttempts[descriptor.id] = (rootURL, attempts + 1) + // Retiring the previous projection clears its history; this start keeps it. + languageServerActivation[descriptor.id] = activationHistory.map { (rootURL, $0) } let operationID = requestedOperationID ?? UUID() // Only a declarative launch has no plugin process to report its start; // a plugin API 1 server is one too, though it has no launch templates. @@ -1874,6 +1885,7 @@ package final class LanguageToolingSessionManager: ObservableObject, message: message )) languageServerStates[descriptor.id] = state + recordActivation(.startFailed, providerID: descriptor.id, rootURL: rootURL) recordLogIssue(descriptor.id, operationID.uuidString, false, "discovery", "tool_not_found", nil) onLanguageServerStateChange?(descriptor.id, state, operationID) throw LanguageToolingSessionError.toolingUnavailable(message) @@ -1893,6 +1905,7 @@ package final class LanguageToolingSessionManager: ObservableObject, message: error.localizedDescription )) languageServerStates[descriptor.id] = state + recordActivation(.startFailed, providerID: descriptor.id, rootURL: rootURL) recordLogIssue(descriptor.id, operationID.uuidString, false, "workspaceFingerprint", "workspace_fingerprint_failed", nil) onLanguageServerStateChange?(descriptor.id, state, operationID) throw error @@ -1972,6 +1985,7 @@ package final class LanguageToolingSessionManager: ObservableObject, ) let state = LanguageServerSessionState.failed(failure) languageServerStates[descriptor.id] = state + recordActivation(.startFailed, providerID: descriptor.id, rootURL: rootURL) recordLogIssue(descriptor.id, languageServerOperationIDs[descriptor.id]?.uuidString, false, failure.stage ?? "processStart", failure.code ?? "start_failed", failure.exitCode.map(Int.init)) onLanguageServerStateChange?(descriptor.id, state, operationID) } @@ -1982,6 +1996,31 @@ package final class LanguageToolingSessionManager: ObservableObject, return created } + /// Asks Core for the activation decision. A Core failure is logged and + /// leaves starts allowed so a policy fault never disables the language server. + private func activationOutcome( + _ event: LanguageServerActivationEvent, + providerID: String, + history: LanguageServerActivationState? + ) -> LanguageServerActivationOutcome? { + guard let policyCore else { return nil } + switch policyCore.languageServerActivation(state: history, event: event, nowMs: monotonicNowMs()) { + case .success(let outcome): + return outcome + case .failure(let failure): + logSink?.emit(AppCoreFallbackSelected(error: LogFailure(domain: "RustCore", code: failure.code), + stage: "lsp.activationPolicy")) + return nil + } + } + + private func recordActivation(_ event: LanguageServerActivationEvent, providerID: String, rootURL: URL) { + let previous = languageServerActivation[providerID] + let history = previous?.root == rootURL ? previous?.state : nil + guard let outcome = activationOutcome(event, providerID: providerID, history: history) else { return } + languageServerActivation[providerID] = outcome.state.map { (rootURL, $0) } + } + private func clearLanguageServerSession( providerID: String, sessionIdentity: ObjectIdentifier, @@ -2080,31 +2119,22 @@ package final class LanguageToolingSessionManager: ObservableObject, providers: [any LanguageFeatureProvider], index: Int, context: LanguageFeatureRequestContext, - items: [LanguageServerCompletionItem], - isIncomplete: Bool, - seenLabels: Set, + lists: [LanguageServerCompletionList], completion: @escaping (Result) -> Void ) { guard index < providers.count else { - completion(.success(LanguageServerCompletionList(items: items, isIncomplete: isIncomplete))) + completion(.success(mergeCompletionLists(lists))) return } do { try providers[index].completionList(in: context) { [self] result in switch result { case .success(let providerItems): - var merged = items - var labels = seenLabels - for item in providerItems.items where labels.insert(item.label).inserted { - merged.append(item) - } routeCompletions( providers: providers, index: index + 1, context: context, - items: merged, - isIncomplete: isIncomplete || providerItems.isIncomplete, - seenLabels: labels, + lists: lists + [providerItems], completion: completion ) case .failure(let error): @@ -2116,6 +2146,32 @@ package final class LanguageToolingSessionManager: ObservableObject, } } + /// Core decides which candidates survive across prioritized providers + /// (`lsp.mergeCompletions`); only labels cross the boundary. + private func mergeCompletionLists(_ lists: [LanguageServerCompletionList]) -> LanguageServerCompletionList { + let isIncomplete = lists.contains(where: \.isIncomplete) + // With at most one non-empty list nothing can be shadowed, and the shared + // rule keeps a provider's list whole, so the call is skipped. + guard lists.filter({ !$0.items.isEmpty }).count > 1 else { + return LanguageServerCompletionList(items: lists.flatMap(\.items), isIncomplete: isIncomplete) + } + guard let policyCore else { + return LanguageServerCompletionList(items: lists.flatMap(\.items), isIncomplete: isIncomplete) + } + switch policyCore.mergeCompletions(labels: lists.map { $0.items.map(\.label) }, + incomplete: lists.map(\.isIncomplete)) { + case .success(let selection): + let items = zip(lists, selection.keep).flatMap { list, keep in + keep.compactMap { list.items.indices.contains($0) ? list.items[$0] : nil } + } + return LanguageServerCompletionList(items: items, isIncomplete: selection.isIncomplete) + case .failure(let failure): + logSink?.emit(AppCoreFallbackSelected(error: LogFailure(domain: "RustCore", code: failure.code), + stage: "lsp.mergeCompletions")) + return LanguageServerCompletionList(items: lists.flatMap(\.items), isIncomplete: isIncomplete) + } + } + private func routeHover( providers: [any LanguageFeatureProvider], index: Int, @@ -2294,6 +2350,9 @@ package final class LanguageToolingSessionManager: ObservableObject, state: state, rootURL: languageServerRoots[providerID] ) + if case .failed = state, let rootURL = languageServerRoots[providerID] { + recordActivation(.startFailed, providerID: providerID, rootURL: rootURL) + } switch state { case .stopped, .failed: if providerID == "java" { @@ -2316,7 +2375,9 @@ package final class LanguageToolingSessionManager: ObservableObject, languageServerFeatures[providerID] = nil languageServerInfos[providerID] = nil case .ready: - languageServerStartupAttempts[providerID] = nil + if let rootURL = languageServerRoots[providerID] { + recordActivation(.ready, providerID: providerID, rootURL: rootURL) + } languageServerFeatures[providerID] = session.features languageServerInfos[providerID] = session.serverInfo if providerID == "java" { refreshJavaDependenciesWhenReady() } diff --git a/macos/Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift b/macos/Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift index 7a2ab84104..03c163e24c 100644 --- a/macos/Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift +++ b/macos/Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift @@ -1531,6 +1531,62 @@ struct LanguageIntelligenceModuleTests { #expect(manager.languageServerStates["java"] == .failed(timeoutFailure)) } + /// Regression for LSP-08: macOS used a private three-attempt counter while + /// Windows used a five-second cooldown. Both now apply Core's decision. + @Test + func automaticStartsApplyTheCoreActivationDecision() throws { + let root = URL(fileURLWithPath: "/workspace/java-activation", isDirectory: true) + let descriptor = try #require( + LanguageProviderCatalog.compatibilityFallback.provider(for: root.appendingPathComponent("Main.java")) + ) + let session = WorkspaceStateLanguageServerSession() + let failure = LanguageServerSessionFailure(code: "start_failed", stage: "processStart", message: "boom") + session.startError = LanguageServerSessionStartError(failure: failure) + let history = LanguageServerActivationState(consecutiveFailures: 1, lastFailureAtMs: 1000) + let policy = ScriptedActivationPolicy(outcomes: [ + .init(state: nil, decision: .allow, retryAfterMs: nil), + .init(state: history, decision: .wait, retryAfterMs: 5000), + .init(state: history, decision: .wait, retryAfterMs: 1), + .init(state: nil, decision: .allow, retryAfterMs: nil), + .init(state: nil, decision: .allow, retryAfterMs: nil), + ]) + let manager = LanguageToolingSessionManager( + catalog: .compatibilityFallback, + runtimes: [WorkspaceStateLanguageProviderRuntime(descriptor: descriptor, session: session)], + policyCore: policy + ) + defer { manager.stopAllLanguageServers() } + var now: UInt64 = 1000 + manager.monotonicNowMs = { now } + + #expect(throws: LanguageServerSessionStartError.self) { + try manager.startLanguageServer(providerID: "java", rootURL: root) + } + now = 5999 + // A waiting decision reports the previous failure without a new process. + let waiting = #expect(throws: LanguageServerSessionStartError.self) { + try manager.startLanguageServer(providerID: "java", rootURL: root) + } + #expect(waiting?.failure == failure) + #expect(session.startCallCount == 1) + + // An explicit stop forgets the history, so the next start sends none. + manager.stopLanguageServer(providerID: "java") + session.startError = nil + now = 6000 + try manager.startLanguageServer(providerID: "java", rootURL: root) + session.publish(.ready) + #expect(session.startCallCount == 2) + + #expect(policy.requests == [ + .init(state: nil, event: .automaticStart, nowMs: 1000), + .init(state: nil, event: .startFailed, nowMs: 1000), + .init(state: history, event: .automaticStart, nowMs: 5999), + .init(state: nil, event: .automaticStart, nowMs: 6000), + .init(state: nil, event: .ready, nowMs: 6000), + ]) + } + @Test func workspaceCacheCleanupLogsTheLanguageServerOperationID() throws { let root = URL(fileURLWithPath: "/workspace/java", isDirectory: true) @@ -1905,6 +1961,35 @@ private final class TestLanguageProviderRuntimeFactory: LanguageProviderRuntimeF } } +/// Records activation requests and answers with scripted Core outcomes; the +/// policy itself is covered by the shared fixture in Rust Core. +private final class ScriptedActivationPolicy: LanguageToolingPolicyCore, @unchecked Sendable { + struct Request: Equatable { + let state: LanguageServerActivationState? + let event: LanguageServerActivationEvent + let nowMs: UInt64 + } + + private(set) var requests: [Request] = [] + private var outcomes: [LanguageServerActivationOutcome] + + init(outcomes: [LanguageServerActivationOutcome]) { self.outcomes = outcomes } + + func languageServerActivation( + state: LanguageServerActivationState?, event: LanguageServerActivationEvent, nowMs: UInt64 + ) -> Result { + requests.append(Request(state: state, event: event, nowMs: nowMs)) + guard !outcomes.isEmpty else { return .failure(LanguageToolingPolicyFailure(code: "unscripted")) } + return .success(outcomes.removeFirst()) + } + + func mergeCompletions( + labels _: [[String]], incomplete _: [Bool] + ) -> Result { + .failure(LanguageToolingPolicyFailure(code: "unscripted")) + } +} + @MainActor private final class TestLanguageProviderRuntime: LanguageProviderRuntime { let descriptor: LanguageProviderDescriptor diff --git a/macos/Tests/LitheTests/EditorDocumentServiceTests.swift b/macos/Tests/LitheTests/EditorDocumentServiceTests.swift index 7a60a8d7cd..760c0608ec 100644 --- a/macos/Tests/LitheTests/EditorDocumentServiceTests.swift +++ b/macos/Tests/LitheTests/EditorDocumentServiceTests.swift @@ -117,10 +117,14 @@ struct EditorDocumentServiceTests { EditorSelection(anchor: at(1, 1), active: at(1, 4))] let selections = try await f.service.selections(scope: f.scope, documentID: f.document.id) let snapshot = try f.service.snapshot(scope: f.scope, documentID: f.document.id) - let index = EditorTextLineIndex(snapshot.text) - let replacements = selections.selections.map { selection -> String in - let start = index.offset(of: selection.range.start)!, end = index.offset(of: selection.range.end)! - return String(utf16CodeUnits: Array(index.units[start.. String in + String(utf16CodeUnits: Array(units[range.utf16Offset.. EditorTextPosition { EditorTextPo private func edit(_ start: EditorTextPosition, _ end: EditorTextPosition, _ text: String) -> EditorTextEdit { EditorTextEdit(range: EditorTextRange(start: start, end: end), text: text) } -private func applying(_ edits: [EditorTextEdit], to text: String) throws -> String { - EditorTextEditPlanner.apply(try EditorTextEditPlanner.plan(edits, in: EditorTextLineIndex(text)), to: text) -} +/// The shared fixture is the rule set; these tests pin the bridge mapping of +/// Core's plan and rejection codes onto the host's service errors. @Suite("Plugin text edit planning") struct EditorTextEditPlannerTests { - @Test func emojiColumnsCountUTF16CodeUnits() throws { - // "😀" occupies columns 2 and 3; "b" is column 4. - #expect(try applying([edit(at(1, 2), at(1, 4), "X")], to: "a😀b") == "aXb") - #expect(EditorTextLineIndex("a😀b").offset(of: at(1, 5)) == 4) - #expect(throws: EditorDocumentServiceError.invalidRange(editIndex: 0, reason: .splitsSurrogatePair)) { - try EditorTextEditPlanner.plan([edit(at(1, 3), at(1, 4), "")], in: EditorTextLineIndex("a😀b")) + private struct Fixture: Decodable { + struct Position: Decodable { let line: Int; let column: Int } + struct Range: Decodable { let start: Position; let end: Position } + struct Edit: Decodable { let range: Range; let text: String } + struct Planned: Decodable { let index: Int; let utf16Offset: Int; let utf16Length: Int; let text: String; let normalizedText: String } + struct Rejection: Decodable { let reason: String; let editIndex: Int?; let otherEditIndex: Int?; let positionIndex: Int? } + struct Expected: Decodable { let lineEnding: String; let lineSeparator: String; let edits: [Planned]; let rejection: Rejection? } + struct Case: Decodable { + let name: String + let text: String + let edits: [Edit] + let positions: [Position]? + let expected: Expected + let expectedText: String? } + let cases: [Case] } - @Test func combiningMarksAreSeparateCodeUnits() throws { - // "é" written as e + U+0301 spans columns 1...2; the mark alone is addressable. - #expect(try applying([edit(at(1, 2), at(1, 3), "")], to: "e\u{301}x") == "ex") - #expect(try applying([edit(at(1, 1), at(1, 3), "E")], to: "e\u{301}x") == "Ex") - } - - @Test func chineseCharactersAreOneColumnEach() throws { - #expect(try applying([edit(at(1, 1), at(1, 2), "英")], to: "中文") == "英文") - #expect(EditorTextLineIndex("中文").offset(of: at(1, 3)) == 2) - #expect(EditorTextLineIndex("中文").offset(of: at(1, 4)) == nil) - } - - @Test func crlfIsOneLineBreakAndInsertedBreaksFollowTheDocument() throws { - let index = EditorTextLineIndex("a\r\nb") - #expect(index.lineEnding == .crlf) - #expect(index.lineCount == 2) - #expect(index.offset(of: at(2, 1)) == 3) - // Column 3 of line 1 would sit between CR and LF. - #expect(index.offset(of: at(1, 3)) == nil) - let plan = try EditorTextEditPlanner.plan([edit(at(2, 1), at(2, 1), "x\ny\r\n")], in: index) - #expect(plan.first?.normalizedText == "x\ny\n") - #expect(EditorTextEditPlanner.apply(plan, to: "a\r\nb") == "a\r\nx\r\ny\r\nb") - #expect(index.position(at: 3) == at(2, 1)) - } + private static func position(_ value: Fixture.Position) -> EditorTextPosition { at(value.line, value.column) } - @Test func lineEndingDetection() { - #expect(EditorTextLineIndex("plain").lineEnding == .lf) - #expect(EditorTextLineIndex("a\rb").lineEnding == .cr) - let mixed = EditorTextLineIndex("a\nb\r\nc") - #expect(mixed.lineEnding == .mixed) - #expect(mixed.insertionSeparator == "\n") - #expect(mixed.lineCount == 3) - } - - @Test func endOfLineAndEndOfDocumentAreValid() throws { - #expect(try applying([edit(at(1, 3), at(1, 3), "!"), edit(at(2, 1), at(2, 1), "?")], to: "ab\n") == "ab!\n?") - } - - @Test func rejectsPositionsOutsideTheDocument() { - let index = EditorTextLineIndex("ab\ncd") - for position in [at(0, 1), at(3, 1), at(1, 0), at(1, 4)] { - #expect(throws: EditorDocumentServiceError.rangeOutOfBounds(editIndex: 1)) { - try EditorTextEditPlanner.plan([edit(at(1, 1), at(1, 1), ""), edit(position, position, "x")], in: index) + @Test func followsSharedFixture() throws { + let url = DeclarativePluginTestSupport.repositoryRoot + .appendingPathComponent("shared/fixtures/editor/text-edit-plan-v1.json") + let fixture = try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: url)) + for item in fixture.cases { + let edits = item.edits.map { edit(Self.position($0.range.start), Self.position($0.range.end), $0.text) } + let positions = (item.positions ?? []).map(Self.position) + if let rejection = item.expected.rejection { + let expected: EditorDocumentServiceError + switch (rejection.reason, rejection.editIndex) { + case ("rangeOutOfBounds", let index?): expected = .rangeOutOfBounds(editIndex: index) + case ("reversed", let index?): expected = .invalidRange(editIndex: index, reason: .reversed) + case ("splitsSurrogatePair", let index?): expected = .invalidRange(editIndex: index, reason: .splitsSurrogatePair) + case ("overlappingRanges", let index?): + expected = .overlappingRanges(editIndex: index, otherEditIndex: try #require(rejection.otherEditIndex)) + default: expected = .editorUnavailable + } + #expect(throws: expected, "\(item.name)") { + try EditorTextEditPlanner.plan(edits, in: item.text, positions: positions) + } + continue + } + let plan = try EditorTextEditPlanner.plan(edits, in: item.text, positions: positions) + #expect(plan.lineEnding.rawValue == item.expected.lineEnding, "\(item.name)") + #expect(plan.lineSeparator == item.expected.lineSeparator, "\(item.name)") + #expect(plan.edits.map(\.index) == item.expected.edits.map(\.index), "\(item.name)") + #expect(plan.edits.map(\.utf16Offset) == item.expected.edits.map(\.utf16Offset), "\(item.name)") + #expect(plan.edits.map(\.utf16Length) == item.expected.edits.map(\.utf16Length), "\(item.name)") + #expect(plan.edits.map(\.text) == item.expected.edits.map(\.text), "\(item.name)") + #expect(plan.edits.map(\.normalizedText) == item.expected.edits.map(\.normalizedText), "\(item.name)") + #expect(plan.edits.map(\.range) == plan.edits.map { edits[$0.index].range }, "\(item.name)") + if let expectedText = item.expectedText { + #expect(EditorTextEditPlanner.apply(plan.edits, to: item.text) == expectedText, "\(item.name)") } } } - - @Test func rejectsReversedRanges() { - #expect(throws: EditorDocumentServiceError.invalidRange(editIndex: 0, reason: .reversed)) { - try EditorTextEditPlanner.plan([edit(at(1, 3), at(1, 1), "")], in: EditorTextLineIndex("abc")) - } - } - - @Test func rejectsOverlapAndReportsCallerIndices() { - #expect(throws: EditorDocumentServiceError.overlappingRanges(editIndex: 0, otherEditIndex: 1)) { - try EditorTextEditPlanner.plan([edit(at(1, 3), at(1, 5), ""), edit(at(1, 1), at(1, 4), "")], - in: EditorTextLineIndex("abcdef")) - } - // An insertion strictly inside a replaced range also overlaps. - #expect(throws: EditorDocumentServiceError.overlappingRanges(editIndex: 0, otherEditIndex: 1)) { - try EditorTextEditPlanner.plan([edit(at(1, 1), at(1, 4), ""), edit(at(1, 2), at(1, 2), "x")], - in: EditorTextLineIndex("abcdef")) - } - } - - @Test func adjacentRangesAndSamePositionInsertsAreAllowed() throws { - #expect(try applying([edit(at(1, 2), at(1, 3), "B"), edit(at(1, 1), at(1, 2), "A")], to: "ab") == "AB") - #expect(try applying([edit(at(1, 1), at(1, 1), "x"), edit(at(1, 1), at(1, 1), "y")], to: "z") == "xyz") - } - - @Test func multiLineEditsInMultiSelectionBatch() throws { - let text = "one\ntwo\nthree" - let edits = [edit(at(3, 1), at(3, 6), "3"), edit(at(1, 1), at(1, 4), "1"), edit(at(1, 4), at(2, 4), "")] - #expect(try applying(edits, to: text) == "1\n3") - } } diff --git a/macos/Tests/LitheTests/LanguageFeatureProviderTests.swift b/macos/Tests/LitheTests/LanguageFeatureProviderTests.swift index 72b40209cb..caa1800183 100644 --- a/macos/Tests/LitheTests/LanguageFeatureProviderTests.swift +++ b/macos/Tests/LitheTests/LanguageFeatureProviderTests.swift @@ -1,4 +1,5 @@ import Foundation +import LitheCoreContracts import LitheLanguageIntelligenceModule import Testing @testable import Lithe @@ -6,8 +7,10 @@ import Testing @Suite("Language feature providers") @MainActor struct LanguageFeatureProviderTests { + /// LSP-09: keywords belong to the language server or plugin, so the + /// fallback provider no longer carries a private per-language keyword table. @Test - func builtinCompletionIncludesLanguageKeywords() throws { + func builtinFallbackDoesNotSupplyLanguageKeywords() { let provider = BuiltinLanguageFeatureProvider() let context = LanguageFeatureRequestContext( fileURL: URL(fileURLWithPath: "/tmp/main.go"), @@ -15,26 +18,25 @@ struct LanguageFeatureProviderTests { position: LanguageServerPosition(line: 0, utf16Column: 2), languageID: "go" ) - var result: Result<[LanguageServerCompletionItem], Error>? - - try provider.completions(in: context) { result = $0 } - - let resolved = try #require(result) - let items = try resolved.get() - let item = try #require(items.first { $0.label == "func" }) - #expect(item.detail == "Go keyword") - #expect(item.textEdit?.range.start.utf16Column == 0) - #expect(item.textEdit?.range.end.utf16Column == 2) + #expect(!provider.supports(.completion, in: context)) } + /// LSP-09: overloads that share a label survive, while a lower-priority + /// provider's duplicate label is shadowed by the server candidate. @Test - func managerMergesHigherPriorityProviderWithBuiltinFallback() throws { + func managerKeepsOverloadsAndShadowsLowerPriorityLabels() throws { let remote = CompletionFeatureProvider(items: [ Self.item(label: "format", detail: "LSP"), - Self.item(label: "func", detail: "LSP") + Self.item(label: "print", detail: "print(String)"), + Self.item(label: "print", detail: "print(int)") + ]) + let fallback = CompletionFeatureProvider(id: "test.fallback", priority: .builtin, items: [ + Self.item(label: "format", detail: "fallback"), + Self.item(label: "fmt", detail: "fallback") ]) let manager = LanguageToolingSessionManager( - languageFeatureProviders: [remote] + policyCore: RustCoreBridge(), + languageFeatureProviders: [fallback, remote] ) let fileURL = URL(fileURLWithPath: "/tmp/main.go") var result: Result<[LanguageServerCompletionItem], Error>? @@ -46,17 +48,18 @@ struct LanguageFeatureProviderTests { rootURL: fileURL.deletingLastPathComponent() ) { result = $0 } - let resolved = try #require(result) - let items = try resolved.get() - #expect(items.first?.label == "format") - #expect(items.filter { $0.label == "func" }.count == 1) - #expect(items.contains { $0.label == "for" && $0.detail == "Go keyword" }) + let items = try #require(result).get() + #expect(items.map(\.label) == ["format", "print", "print", "fmt"]) + #expect(items.map(\.detail) == ["LSP", "print(String)", "print(int)", "fallback"]) } @Test(arguments: [true, false]) - func emptyProviderListPreservesRefreshStateWhenMergedWithLocalKeywords(incomplete: Bool) throws { + func emptyProviderListPreservesRefreshStateWhenMergedWithFallback(incomplete: Bool) throws { let remote = CompletionFeatureProvider(items: [], isIncomplete: incomplete) - let manager = LanguageToolingSessionManager(languageFeatureProviders: [remote]) + let fallback = CompletionFeatureProvider(id: "test.fallback", priority: .builtin, + items: [Self.item(label: "func", detail: "fallback")]) + let manager = LanguageToolingSessionManager(policyCore: RustCoreBridge(), + languageFeatureProviders: [remote, fallback]) let fileURL = URL(fileURLWithPath: "/tmp/main.go") var result: Result? try manager.completionList(fileURL: fileURL, text: "f", @@ -64,10 +67,53 @@ struct LanguageFeatureProviderTests { completionContext: .init(triggerKind: 3)) { result = $0 } let list = try #require(result).get() #expect(list.isIncomplete == incomplete) - #expect(list.items.contains { $0.label == "func" }) + #expect(list.items.map(\.label) == ["func"]) #expect(remote.context?.triggerKind == 3) } + /// The bridge returns exactly the shared fixture responses on macOS. + @Test + func rustBridgeMatchesSharedActivationAndMergeFixtures() throws { + let fixtures = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + .appendingPathComponent("shared/fixtures/lsp") + struct ActivationCase: Decodable { + struct Request: Decodable { + let state: LanguageServerActivationState? + let event: LanguageServerActivationEvent + let nowMs: UInt64 + } + let name: String + let request: Request + let response: LanguageServerActivationOutcome + } + struct MergeCase: Decodable { + struct List: Decodable { let labels: [String]; let isIncomplete: Bool } + struct Request: Decodable { let lists: [List] } + struct Response: Decodable { let keep: [[Int]]; let isIncomplete: Bool } + let name: String + let request: Request + let response: Response + } + struct Fixture: Decodable { let cases: [Case] } + let bridge = RustCoreBridge() + let activation = try JSONDecoder().decode(Fixture.self, from: Data( + contentsOf: fixtures.appendingPathComponent("activation-policy-v1.json"))) + for entry in activation.cases { + let outcome = bridge.languageServerActivation( + state: entry.request.state, event: entry.request.event, nowMs: entry.request.nowMs) + #expect(try outcome.get() == entry.response, "\(entry.name)") + } + let merge = try JSONDecoder().decode(Fixture.self, from: Data( + contentsOf: fixtures.appendingPathComponent("completion-merge-v1.json"))) + for entry in merge.cases { + let selection = try bridge.mergeCompletions( + labels: entry.request.lists.map(\.labels), incomplete: entry.request.lists.map(\.isIncomplete)).get() + #expect(selection.keep == entry.response.keep, "\(entry.name)") + #expect(selection.isIncomplete == entry.response.isIncomplete, "\(entry.name)") + } + } + private static func item(label: String, detail: String) -> LanguageServerCompletionItem { LanguageServerCompletionItem( label: label, @@ -86,13 +132,16 @@ struct LanguageFeatureProviderTests { @MainActor private final class CompletionFeatureProvider: LanguageFeatureProvider { - let id = "test.remote" - let priority: LanguageFeatureProviderPriority = .languageServer + let id: String + let priority: LanguageFeatureProviderPriority private let items: [LanguageServerCompletionItem] private let isIncomplete: Bool private(set) var context: LanguageServerCompletionContext? - init(items: [LanguageServerCompletionItem], isIncomplete: Bool = false) { + init(id: String = "test.remote", priority: LanguageFeatureProviderPriority = .languageServer, + items: [LanguageServerCompletionItem], isIncomplete: Bool = false) { + self.id = id + self.priority = priority self.items = items self.isIncomplete = isIncomplete } diff --git a/macos/Tests/LitheTests/NavigationHistoryFeatureModelTests.swift b/macos/Tests/LitheTests/NavigationHistoryFeatureModelTests.swift index 1e5fe11886..92f434e684 100644 --- a/macos/Tests/LitheTests/NavigationHistoryFeatureModelTests.swift +++ b/macos/Tests/LitheTests/NavigationHistoryFeatureModelTests.swift @@ -7,7 +7,7 @@ import Testing struct NavigationHistoryFeatureModelTests { @Test func backAndForwardPreserveLiveCaretLocations() throws { - let model = NavigationHistoryFeatureModel() + let model = NavigationHistoryFeatureModel(logSink: nil) let first = location("First.java", line: 4, column: 2) let second = location("Second.java", line: 9, column: 7) let movedSecond = location("Second.java", line: 14, column: 3) @@ -22,7 +22,7 @@ struct NavigationHistoryFeatureModelTests { @Test func newJumpClearsForwardHistory() { - let model = NavigationHistoryFeatureModel() + let model = NavigationHistoryFeatureModel(logSink: nil) let first = location("First.java", line: 1) let second = location("Second.java", line: 2) let third = location("Third.java", line: 3) @@ -36,26 +36,54 @@ struct NavigationHistoryFeatureModelTests { } @Test - func historyIsDeduplicatedAndBounded() { - let model = NavigationHistoryFeatureModel(maximumEntryCount: 2) + func historyIsDeduplicatedAndBoundedByCore() { + let model = NavigationHistoryFeatureModel(logSink: nil) + let destination = location("Target.java", line: 0) + let repeated = location("First.java", line: 1) + model.recordJump(from: repeated, to: destination) + model.recordJump(from: repeated, to: destination) + #expect(model.backLocations == [repeated]) + + for line in 0...100 { + model.recordJump(from: location("Many.java", line: line + 2), to: destination) + } + + // Core keeps the newest 100 entries (shared/fixtures/editor/navigation-history-v1.json). + #expect(model.backLocations.count == 100) + #expect(model.backLocations.first == location("Many.java", line: 3)) + #expect(model.backLocations.last == location("Many.java", line: 102)) + } + + @Test + func backSkipsTheEntryAtTheLiveLocation() { + let model = NavigationHistoryFeatureModel(logSink: nil) let first = location("First.java", line: 1) let second = location("Second.java", line: 2) let third = location("Third.java", line: 3) - let fourth = location("Fourth.java", line: 4) - model.recordJump(from: first, to: second) model.recordJump(from: second, to: third) - model.recordJump(from: third, to: fourth) - #expect(model.backLocations == [second, third]) - #expect(model.navigateBack(from: fourth) == third) - #expect(model.navigateBack(from: third) == second) - #expect(!model.canNavigateBack) + // The caret was moved back onto the newest entry without a jump. + #expect(model.navigateBack(from: second) == first) + #expect(model.forwardLocations == [second]) + } + + @Test + func virtualAndReadOnlyContextSurvivesTheCoreRoundTrip() throws { + let model = NavigationHistoryFeatureModel(logSink: nil) + let url = try #require(URL(string: "jdt://contents/java.base/java/lang/String.class?=a%20b")) + let virtual = EditorNavigationLocation( + url: url, line: 8, utf16Column: 4, isReadOnly: true, + displayPath: "java.base/java/lang/String.class", virtualProviderID: "java" + ) + model.recordJump(from: virtual, to: location("Other.java", line: 1)) + + #expect(model.navigateBack(from: nil) == virtual) } @Test func resetClearsBothDirections() { - let model = NavigationHistoryFeatureModel() + let model = NavigationHistoryFeatureModel(logSink: nil) let first = location("First.java", line: 1) let second = location("Second.java", line: 2) model.recordJump(from: first, to: second) @@ -69,7 +97,7 @@ struct NavigationHistoryFeatureModelTests { @Test func failedNavigationCanRestoreBothStacks() { - let model = NavigationHistoryFeatureModel() + let model = NavigationHistoryFeatureModel(logSink: nil) let first = location("First.java", line: 1) let second = location("Second.java", line: 2) model.recordJump(from: first, to: second) diff --git a/macos/Tests/LitheTests/ProblemsPanelPresentationTests.swift b/macos/Tests/LitheTests/ProblemsPanelPresentationTests.swift index 3222a42297..8d1c733a04 100644 --- a/macos/Tests/LitheTests/ProblemsPanelPresentationTests.swift +++ b/macos/Tests/LitheTests/ProblemsPanelPresentationTests.swift @@ -58,6 +58,37 @@ struct ProblemsPanelPresentationTests { #expect(store.diagnostics(for: second) == [problem]) } + /// LSP-17: Core orders files naturally (`File2` before `File10`) and lists + /// errors before warnings inside a file, the same as Windows. + @Test + func problemsQueryUsesSharedFileAndSeverityOrder() { + let root = URL(fileURLWithPath: "/workspace/problems") + let ten = root.appendingPathComponent("src/File10.java") + let two = root.appendingPathComponent("src/File2.java") + func problem(_ id: String, _ url: URL, _ line: Int, _ severity: DiagnosticSeverity) -> EditorDiagnostic { + EditorDiagnostic(id: id, fileURL: url, line: line, utf16Column: 0, endLine: line, endUTF16Column: 1, + severity: severity, message: id, source: "java", code: nil, tags: [], relatedInformation: []) + } + let diagnostics = [ + problem("ten-warning", ten, 1, .warning), + problem("two-warning", two, 1, .warning), + problem("two-error", two, 9, .error), + problem("two-hint", two, 0, .hint), + ] + let paths = [ten: "src/File10.java", two: "src/File2.java"] + + let all = ProblemsQuery.run(diagnostics: diagnostics, severities: Set(DiagnosticSeverity.allCases), + displayPaths: paths, logSink: nil) + #expect(all.groups.map(\.fileURL) == [two, ten]) + #expect(all.groups.first?.diagnostics.map(\.id) == ["two-error", "two-warning", "two-hint"]) + #expect(all.errorCount == 1 && all.warningCount == 2) + + let errors = ProblemsQuery.run(diagnostics: diagnostics, severities: [.error], + displayPaths: paths, logSink: nil) + #expect(errors.diagnostics.map(\.id) == ["two-error"]) + #expect(errors.warningCount == 0) + } + #if DEBUG @Test func demoCoversEverySeverityTagsRelatedInformationAndLongMessages() { let root = URL(fileURLWithPath: "/demo") diff --git a/rust/lithe-core/src/diagnostics/request_trace.rs b/rust/lithe-core/src/diagnostics/request_trace.rs index 8862792e5b..a13cf3bf90 100644 --- a/rust/lithe-core/src/diagnostics/request_trace.rs +++ b/rust/lithe-core/src/diagnostics/request_trace.rs @@ -182,6 +182,8 @@ pub(crate) fn is_hot(method: &str) -> bool { | "lsp.builtinCompletions" | "lsp.builtinHover" | "lsp.builtinNavigation" + | "lsp.mergeCompletions" + | "lsp.problems.query" | "java.codeVision" | "java.navigationMarkers" | "java.runMarkers" diff --git a/rust/lithe-core/src/editor/mod.rs b/rust/lithe-core/src/editor/mod.rs index c51bba5344..ad42f4914a 100644 --- a/rust/lithe-core/src/editor/mod.rs +++ b/rust/lithe-core/src/editor/mod.rs @@ -7,7 +7,10 @@ //! are the canonical reference for that migration. pub(crate) mod line_edit; +pub(crate) mod navigation_history; pub(crate) mod navigation_input; +pub(crate) mod text_edit_plan; +pub(crate) mod workbench_state; pub(crate) use line_edit::{ line_comment_token, line_edit, LineCommentTokenRequest, LineEditRequest, diff --git a/rust/lithe-core/src/editor/navigation_history.rs b/rust/lithe-core/src/editor/navigation_history.rs new file mode 100644 index 0000000000..2342c71cf2 --- /dev/null +++ b/rust/lithe-core/src/editor/navigation_history.rs @@ -0,0 +1,189 @@ +//! Bounded editor navigation history shared by every platform editor. +//! +//! Platforms decide which gestures count as a jump and how a location is +//! opened. Core owns the back and forward stacks: departure recording, +//! deduplication, forward-branch truncation, the capacity limit, and skipping +//! entries that equal the current location. The reducer is stateless; the +//! platform keeps the returned state and restores its previous value when +//! opening the returned destination fails. + +use crate::protocol::{CoreError, ErrorCode}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Maximum number of locations retained by each history direction. +pub(crate) const MAX_HISTORY_ENTRIES: usize = 100; + +/// One editor location recorded in navigation history. +/// +/// Identity is `document`, `line` and `column`; `context` is platform-owned +/// presentation data (pane, scroll offsets, read-only state) that Core carries +/// through unchanged. Coordinates are opaque to Core, so each platform keeps +/// its own base and column unit consistently. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Location { + /// Stable document identity, such as a standardized URL or file path. + document: String, + line: u64, + column: u64, + #[serde(default, skip_serializing_if = "Value::is_null")] + context: Value, +} + +impl Location { + fn same_position(&self, other: &Self) -> bool { + self.document == other.document && self.line == other.line && self.column == other.column + } +} + +/// Back and forward stacks, each ordered oldest first so the next destination +/// is the last element. +#[derive(Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct History { + back: Vec, + forward: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +enum Event { + /// A platform-recognized jump left `departure`. `destination` is optional + /// because some jumps record before the target is resolved. + Record { + departure: Option, + destination: Option, + }, + /// Move one step back from the live `current` location. + Back { current: Option }, + /// Move one step forward from the live `current` location. + Forward { current: Option }, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Request { + state: History, + event: Event, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct Response { + state: History, + /// Location to open for `back` and `forward`; null when nothing moved. + destination: Option, +} + +/// Reduces one navigation history event (`editor.navigationHistory.reduce`). +/// +/// Returns `invalidRequest` for malformed payloads or empty document +/// identities. Oversized stacks keep their newest entries. +pub(crate) fn reduce_command(payload: Value) -> Result { + let request: Request = serde_json::from_value(payload).map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid navigation history request", + ) + .with_details(error.to_string()) + })?; + let response = reduce(request)?; + Ok(serde_json::to_value(response).expect("navigation history response should encode")) +} + +fn reduce(request: Request) -> Result { + let Request { mut state, event } = request; + validate(&state, &event)?; + trim(&mut state.back); + trim(&mut state.forward); + let destination = match event { + Event::Record { + departure, + destination, + } => { + if let Some(departure) = departure { + let stays = destination + .as_ref() + .is_some_and(|destination| destination.same_position(&departure)); + if !stays { + push(&mut state.back, departure); + // A new jump starts a new branch, as in browser history. + state.forward.clear(); + } + } + None + } + Event::Back { current } => step(&mut state.back, &mut state.forward, current), + Event::Forward { current } => step(&mut state.forward, &mut state.back, current), + }; + Ok(Response { state, destination }) +} + +/// Pops the next distinct destination from `source` and records the live +/// location on `opposite`, so caret movement since the last jump survives a +/// return trip. +fn step( + source: &mut Vec, + opposite: &mut Vec, + current: Option, +) -> Option { + while let Some(destination) = source.pop() { + // An entry equal to the live location would be a no-op step; drop it + // so one key press always moves when any distinct entry remains. + if current + .as_ref() + .is_some_and(|current| current.same_position(&destination)) + { + continue; + } + if let Some(current) = current { + push(opposite, current); + } + return Some(destination); + } + None +} + +fn push(stack: &mut Vec, location: Location) { + match stack.last_mut() { + // Replace rather than skip so the newest platform context is kept. + Some(last) if last.same_position(&location) => *last = location, + _ => stack.push(location), + } + trim(stack); +} + +fn trim(stack: &mut Vec) { + if stack.len() > MAX_HISTORY_ENTRIES { + stack.drain(..stack.len() - MAX_HISTORY_ENTRIES); + } +} + +fn validate(state: &History, event: &Event) -> Result<(), CoreError> { + let event_locations: Vec<&Location> = match event { + Event::Record { + departure, + destination, + } => departure.iter().chain(destination.iter()).collect(), + Event::Back { current } | Event::Forward { current } => current.iter().collect(), + }; + let empty = state + .back + .iter() + .chain(state.forward.iter()) + .chain(event_locations) + .any(|location| location.document.is_empty()); + if empty { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Navigation history locations require a document identity", + )); + } + Ok(()) +} diff --git a/rust/lithe-core/src/editor/tests.rs b/rust/lithe-core/src/editor/tests.rs index 9f93cc3abd..613a9078da 100644 --- a/rust/lithe-core/src/editor/tests.rs +++ b/rust/lithe-core/src/editor/tests.rs @@ -27,6 +27,167 @@ fn navigation_input_matches_shared_fixture() { } } +/// Plugin edit plans follow the shared fixture, and applying a plan's +/// document-separator text in reverse order yields the fixture's result. +#[test] +fn text_edit_plan_matches_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/editor/text-edit-plan-v1.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let mut payload = json!({"text": case["text"], "edits": case["edits"]}); + if let Some(positions) = case.get("positions") { + payload["positions"] = positions.clone(); + } + let response: Value = serde_json::from_str(&crate::execute_json( + &json!({"command":"editor.planTextEdits", "payload": payload}).to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], true, "{}: {response}", case["name"]); + assert_eq!(response["data"], case["expected"], "{}", case["name"]); + if let Some(expected) = case["expectedText"].as_str() { + let mut units: Vec = case["text"].as_str().unwrap().encode_utf16().collect(); + for edit in response["data"]["edits"].as_array().unwrap().iter().rev() { + let offset = edit["utf16Offset"].as_u64().unwrap() as usize; + let length = edit["utf16Length"].as_u64().unwrap() as usize; + let replacement: Vec = edit["text"].as_str().unwrap().encode_utf16().collect(); + units.splice(offset..offset + length, replacement); + } + assert_eq!(String::from_utf16(&units).unwrap(), expected, "{}", case["name"]); + } + } +} + +#[test] +fn text_edit_plan_rejects_malformed_requests() { + for payload in [ + json!({"edits": []}), + json!({"text": "a", "edits": [{"range": {"start": {"line": -1, "column": 1}, "end": {"line": 1, "column": 1}}, "text": ""}]}), + json!({"text": "a", "unknown": true}), + ] { + let response: Value = serde_json::from_str(&crate::execute_json( + &json!({"command":"editor.planTextEdits", "payload": payload}).to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], false, "{payload}"); + assert_eq!(response["error"]["code"], "invalid_request"); + } +} + +fn navigation_history(payload: Value) -> Value { + serde_json::from_str(&crate::execute_json( + &json!({"command":"editor.navigationHistory.reduce", "payload":payload}).to_string(), + )) + .unwrap() +} + +#[test] +fn navigation_history_matches_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/editor/navigation-history-v1.json" + )) + .unwrap(); + assert_eq!( + fixture["maxEntries"], + super::navigation_history::MAX_HISTORY_ENTRIES + ); + for case in fixture["cases"].as_array().unwrap() { + let response = navigation_history(case["request"].clone()); + if let Some(code) = case.get("expectedError") { + assert_eq!(response["ok"], false, "{}: {response}", case["name"]); + assert_eq!(response["error"]["code"], *code, "{}", case["name"]); + continue; + } + assert_eq!(response["ok"], true, "{}: {response}", case["name"]); + assert_eq!(response["data"], case["expected"], "{}", case["name"]); + } +} + +// Both stacks are bounded so a long editing session cannot grow history (or +// the IPC payload that carries it) without limit; the oldest entries go first. +#[test] +fn navigation_history_keeps_only_the_newest_entries() { + let limit = super::navigation_history::MAX_HISTORY_ENTRIES as u64; + let back: Vec = (0..limit) + .map(|line| json!({"document":"a", "line":line, "column":0})) + .collect(); + let response = navigation_history(json!({ + "state": {"back": back, "forward": []}, + "event": {"type":"record", "departure":{"document":"b", "line":0, "column":0}} + })); + let back = response["data"]["state"]["back"].as_array().unwrap(); + assert_eq!(back.len() as u64, limit); + assert_eq!(back[0]["line"], 1); + assert_eq!(back[back.len() - 1]["document"], "b"); + + let oversized: Vec = (0..limit + 5) + .map(|line| json!({"document":"a", "line":line, "column":0})) + .collect(); + let response = navigation_history(json!({ + "state": {"back": [], "forward": oversized}, + "event": {"type":"back", "current":null} + })); + let forward = response["data"]["state"]["forward"].as_array().unwrap(); + assert_eq!(forward.len() as u64, limit); + assert_eq!(forward[0]["line"], 5); +} + +/// Locks the shared tab group rules both hosts consume; each case runs through +/// the public JSON command so routing and encoding are covered as well. +#[test] +fn workbench_state_matches_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/editor/workbench-state-v1.json" + )) + .unwrap(); + let run = |state: &Value, action: &Value| -> Value { + serde_json::from_str(&crate::execute_json( + &json!({"command":"editor.workbenchState.reduce", + "payload":{"state":state, "action":action}}) + .to_string(), + )) + .unwrap() + }; + for case in fixture["cases"].as_array().unwrap() { + let response = run(&case["state"], &case["action"]); + assert_eq!(response["ok"], true, "{}: {response}", case["name"]); + assert_eq!( + response["data"]["applied"], case["applied"], + "{}", + case["name"] + ); + assert_eq!( + response["data"]["state"], case["expected"], + "{}", + case["name"] + ); + // A returned state is already normalized: reducing it again with a + // no-op sync must not change it. + let tabs: Vec = case["expected"]["tabs"] + .as_array() + .unwrap() + .iter() + .map(|tab| tab["id"].clone()) + .collect(); + let again = run(&case["expected"], &json!({"type":"sync", "tabs":tabs})); + assert_eq!( + again["data"]["applied"], false, + "{} idempotence", + case["name"] + ); + } + for case in fixture["invalid"].as_array().unwrap() { + let response = run(&case["state"], &case["action"]); + assert_eq!(response["ok"], false, "{}: {response}", case["name"]); + assert_eq!( + response["error"]["code"], "invalid_request", + "{}", + case["name"] + ); + } +} + fn edit( operation: &str, source: &str, diff --git a/rust/lithe-core/src/editor/text_edit_plan.rs b/rust/lithe-core/src/editor/text_edit_plan.rs new file mode 100644 index 0000000000..6797718844 --- /dev/null +++ b/rust/lithe-core/src/editor/text_edit_plan.rs @@ -0,0 +1,161 @@ +//! Plugin document edit planning (`editor.planTextEdits`). +//! +//! The host reads the live buffer and owns versions, undo grouping and the +//! editor surface; Core validates one-based plugin positions with the same +//! line index, ordering and overlap rules as `lsp.applyTextEdits`, and returns +//! UTF-16 offsets plus replacement text in the document's line ending. + +use crate::lsp::{ + normalize_line_breaks, plan_replacements, EditRejection, EditRejectionReason, PositionPolicy, + RawTextEdit, TextLineIndex, +}; +use crate::protocol::{CoreError, ErrorCode}; +use serde::Deserialize; +use serde_json::{json, Value}; + +/// One-based line and UTF-16 column, as the plugin editor API defines them. +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(deny_unknown_fields)] +struct Position { + line: u64, + column: u64, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Range { + start: Position, + end: Position, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Edit { + range: Range, + text: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Request { + text: String, + #[serde(default)] + edits: Vec, + /// Extra positions that must exist in `text`, such as reported selections. + #[serde(default)] + positions: Vec, +} + +/// Converts a one-based plugin position to the zero-based pair the shared +/// planner resolves. Zero and values beyond `i64` are out of bounds. +fn zero_based(position: Position) -> (i64, i64) { + let convert = |value: u64| { + value + .checked_sub(1) + .and_then(|value| i64::try_from(value).ok()) + .unwrap_or(-1) + }; + (convert(position.line), convert(position.column)) +} + +fn reason_name(reason: EditRejectionReason) -> &'static str { + match reason { + EditRejectionReason::RangeOutOfBounds => "rangeOutOfBounds", + EditRejectionReason::Reversed => "reversed", + EditRejectionReason::SplitsSurrogatePair => "splitsSurrogatePair", + EditRejectionReason::OverlappingRanges => "overlappingRanges", + } +} + +/// Plans a plugin edit batch against the exact text it was based on. +/// +/// Returns `{ lineEnding, lineSeparator, edits, rejection }`. `edits` is the +/// stable application order with `index`, `utf16Offset`, `utf16Length`, +/// `text` (line breaks in `lineSeparator`) and `normalizedText` (LF). When the +/// batch is invalid, `edits` is empty and `rejection` names the first fault +/// with `reason`, the caller-order `editIndex`/`otherEditIndex`, or +/// `positionIndex` for a failed extra position. An empty batch still reports +/// the document's line ending. +pub(crate) fn plan_command(payload: Value) -> Result { + let request: Request = serde_json::from_value(payload).map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid editor text edit plan request") + .with_details(error.to_string()) + })?; + let index = TextLineIndex::new(&request.text); + let line_ending = index.line_ending(); + let separator = index.insertion_separator(); + let respond = |edits: Vec, rejection: Value| { + Ok(json!({ + "lineEnding": line_ending, + "lineSeparator": separator, + "edits": edits, + "rejection": rejection, + })) + }; + + for (position_index, position) in request.positions.iter().enumerate() { + if let Err(reason) = index.resolve(zero_based(*position), PositionPolicy::Strict) { + return respond( + Vec::new(), + json!({"reason": reason_name(reason), "positionIndex": position_index}), + ); + } + } + + let edits = request + .edits + .into_iter() + .map(|edit| RawTextEdit { + start: zero_based(edit.range.start), + end: zero_based(edit.range.end), + text: edit.text, + }) + .collect(); + let planned = match plan_replacements(&index, edits, PositionPolicy::Strict) { + Ok(planned) => planned, + Err(EditRejection { + reason, + edit_index, + other_edit_index, + }) => { + return respond( + Vec::new(), + json!({ + "reason": reason_name(reason), + "editIndex": edit_index, + "otherEditIndex": other_edit_index, + }), + ); + } + }; + + // Offsets are ascending, so one forward walk converts every byte offset. + let mut walked_bytes = 0; + let mut walked_units = 0; + let mut utf16_at = |byte: usize| { + walked_units += request.text[walked_bytes..byte].encode_utf16().count(); + walked_bytes = byte; + walked_units + }; + let edits = planned + .into_iter() + .map(|edit| { + let offset = utf16_at(edit.start); + let end = utf16_at(edit.end); + let normalized = normalize_line_breaks(&edit.text, "\n"); + let text = if separator == "\n" { + normalized.clone() + } else { + normalized.replace('\n', separator) + }; + json!({ + "index": edit.index, + "utf16Offset": offset, + "utf16Length": end - offset, + "text": text, + "normalizedText": normalized, + }) + }) + .collect(); + respond(edits, Value::Null) +} diff --git a/rust/lithe-core/src/editor/workbench_state.rs b/rust/lithe-core/src/editor/workbench_state.rs new file mode 100644 index 0000000000..994391859f --- /dev/null +++ b/rust/lithe-core/src/editor/workbench_state.rs @@ -0,0 +1,626 @@ +//! Editor tab group membership rules owned by Rust Core (`editor.workbenchState.reduce`). +//! +//! The reducer owns which group presents each tab, how the binary split tree +//! grows and collapses, which tab each group selects, and which group has the +//! focus after opening, closing, moving, dropping and focusing tabs. Hosts own +//! everything visual: split ratios, pixel geometry, drag hit testing and the +//! per-frame drag target. Tab and group identifiers are opaque host strings; +//! new group and split identities are supplied by the host so the reducer +//! stays deterministic. + +use std::collections::{BTreeMap, BTreeSet, HashSet}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::protocol::{CoreError, ErrorCode}; + +/// Upper bound on tabs in one request; real workbenches hold far fewer and the +/// bound keeps the quadratic order operations cheap. +const MAX_TABS: usize = 10_000; + +/// Complete tab group state exchanged with a host. Hosts treat a returned state +/// as the only source for placement, selection and focus. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct WorkbenchState { + layout: GroupLayout, + /// Tabs in the host's presentation order. A group presents its tabs in + /// this relative order. + tabs: Vec, + /// One entry per group, in layout order. `tab` is null only for an empty group. + #[serde(default)] + selections: Vec, + /// Group that receives tabs opened without an explicit target. Null until + /// the host first focuses a tab. + focused_group: Option, +} + +/// Binary split tree of editor groups. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +enum GroupLayout { + /// A leaf group identified by its opaque host ID. + Group(String), + Split(Box), +} + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SplitLayout { + /// Opaque host ID; hosts key split ratios by it. + id: String, + orientation: Orientation, + /// Left child for horizontal splits, top child for vertical splits. + first: GroupLayout, + second: GroupLayout, +} + +/// `horizontal` places the children side by side; `vertical` stacks them. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +enum Orientation { + Horizontal, + Vertical, +} + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct TabPlacement { + id: String, + /// Owning group. Null or an unknown group places the tab in the focused group. + group: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct GroupSelection { + group: String, + tab: Option, +} + +/// Drop or insert position relative to a target group. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +enum DropSide { + /// Join the target group's tab strip. + Center, + Left, + Right, + Top, + Bottom, +} + +impl DropSide { + fn orientation(self) -> Orientation { + match self { + Self::Top | Self::Bottom => Orientation::Vertical, + _ => Orientation::Horizontal, + } + } + + fn leading(self) -> bool { + matches!(self, Self::Left | Self::Top) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +enum MovePosition { + Before, + After, +} + +/// One user-level tab action. Every action is followed by the same +/// normalization, so membership, selection and focus rules have one owner. +#[derive(Debug, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase", deny_unknown_fields)] +enum WorkbenchAction { + /// Replaces the set and order of open tabs; existing placements are kept + /// and new tabs join the focused group. + Sync { tabs: Vec }, + /// Moves an open tab into a group, or into a new group split off it. + #[serde(rename_all = "camelCase")] + Drop { + tab: String, + group: String, + side: DropSide, + new_group: Option, + new_split: Option, + }, + /// Reports whether the matching `drop` would apply, without changing state. + CanDrop { + tab: String, + group: String, + side: DropSide, + }, + /// Opens a tab that is not yet presented. A null group targets the + /// focused group and ignores `side`. + #[serde(rename_all = "camelCase")] + Insert { + tab: String, + group: Option, + side: DropSide, + new_group: Option, + new_split: Option, + }, + /// Reorders a tab next to another tab and adopts that tab's group. + Move { + tab: String, + target: String, + position: MovePosition, + }, + /// Selects a tab and focuses its group. + Focus { tab: String }, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Request { + state: WorkbenchState, + action: WorkbenchAction, +} + +/// Reduction outcome. `applied` is false for rejected or no-op actions, which +/// return the normalized input state. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct Response { + applied: bool, + state: WorkbenchState, +} + +/// Entry point for `editor.workbenchState.reduce`. +/// +/// Returns `invalid_request` for malformed payloads, structurally invalid +/// layouts (duplicate group or split IDs), oversized tab lists, and edge +/// drops whose new group or split identity is missing or already in use. +pub(crate) fn reduce_command(payload: Value) -> Result { + let request: Request = serde_json::from_value(payload).map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid workbench state request") + .with_details(error.to_string()) + })?; + let response = reduce(request.state, request.action)?; + Ok(serde_json::to_value(response).expect("workbench state response should encode")) +} + +fn invalid(message: &str) -> CoreError { + CoreError::new(ErrorCode::InvalidRequest, message) +} + +fn reduce(state: WorkbenchState, action: WorkbenchAction) -> Result { + validate_layout(&state.layout)?; + if state.tabs.len() > MAX_TABS { + return Err(invalid("Too many workbench tabs")); + } + let current = normalize(&state, state.clone()); + let unchanged = |current: WorkbenchState| Response { + applied: false, + state: current, + }; + let next = match action { + WorkbenchAction::Sync { tabs } => { + if tabs.len() > MAX_TABS { + return Err(invalid("Too many workbench tabs")); + } + let mut draft = current.clone(); + draft.tabs = tabs + .into_iter() + .map(|id| { + let group = group_of(¤t, &id).map(str::to_owned); + TabPlacement { id, group } + }) + .collect(); + Some(normalize(¤t, draft)) + } + WorkbenchAction::CanDrop { tab, group, side } => { + let allowed = can_drop(¤t, &tab, &group, side); + return Ok(Response { + applied: allowed, + state: current, + }); + } + WorkbenchAction::Drop { + tab, + group, + side, + new_group, + new_split, + } => { + if !can_drop(¤t, &tab, &group, side) { + return Ok(unchanged(current)); + } + let mut draft = current.clone(); + if side == DropSide::Center { + place_after_group(&mut draft.tabs, &tab, &group); + } else { + let created = split_group(&mut draft, &group, side, new_group, new_split)?; + set_group(&mut draft.tabs, &tab, created); + } + let mut next = normalize(¤t, draft); + focus(&mut next, &tab); + Some(next) + } + WorkbenchAction::Insert { + tab, + group, + side, + new_group, + new_split, + } => { + if group_of(¤t, &tab).is_some() { + return Ok(unchanged(current)); + } + let mut draft = current.clone(); + match group { + None => draft.tabs.push(TabPlacement { + id: tab.clone(), + group: Some(destination(¤t).to_owned()), + }), + Some(group) if !current.layout.groups().contains(&group.as_str()) => { + return Ok(unchanged(current)); + } + Some(group) if side == DropSide::Center => { + draft.tabs.push(TabPlacement { + id: tab.clone(), + group: None, + }); + place_after_group(&mut draft.tabs, &tab, &group); + } + Some(group) => { + let created = split_group(&mut draft, &group, side, new_group, new_split)?; + draft.tabs.push(TabPlacement { + id: tab.clone(), + group: Some(created), + }); + } + } + let mut next = normalize(¤t, draft); + focus(&mut next, &tab); + Some(next) + } + WorkbenchAction::Move { + tab, + target, + position, + } => { + let target_group = group_of(¤t, &target).map(str::to_owned); + match target_group { + Some(target_group) if tab != target => { + let mut draft = current.clone(); + draft.tabs.retain(|placement| placement.id != tab); + let index = draft + .tabs + .iter() + .position(|placement| placement.id == target) + .expect("target remains after removing a different tab"); + let index = index + usize::from(position == MovePosition::After); + draft.tabs.insert( + index, + TabPlacement { + id: tab.clone(), + group: Some(target_group.clone()), + }, + ); + let same_order = draft + .tabs + .iter() + .map(|placement| &placement.id) + .eq(current.tabs.iter().map(|placement| &placement.id)); + if same_order && group_of(¤t, &tab) == Some(target_group.as_str()) { + None + } else { + Some(normalize(¤t, draft)) + } + } + _ => None, + } + } + WorkbenchAction::Focus { tab } => { + let mut next = current.clone(); + focus(&mut next, &tab); + Some(next) + } + }; + Ok(match next { + Some(next) if next != current => Response { + applied: true, + state: next, + }, + _ => unchanged(current), + }) +} + +impl GroupLayout { + fn groups(&self) -> Vec<&str> { + let mut groups = Vec::new(); + self.collect_groups(&mut groups); + groups + } + + fn collect_groups<'a>(&'a self, groups: &mut Vec<&'a str>) { + match self { + Self::Group(id) => groups.push(id), + Self::Split(split) => { + split.first.collect_groups(groups); + split.second.collect_groups(groups); + } + } + } + + fn collect_ids<'a>(&'a self, ids: &mut Vec<&'a str>) { + match self { + Self::Group(id) => ids.push(id), + Self::Split(split) => { + ids.push(&split.id); + split.first.collect_ids(ids); + split.second.collect_ids(ids); + } + } + } + + /// Replaces the leaf `group` with a split holding it and `created`. + fn splitting(self, group: &str, side: DropSide, created: &str, split_id: &str) -> Self { + match self { + Self::Group(id) if id == group => { + let old = Self::Group(id); + let new = Self::Group(created.to_owned()); + let (first, second) = if side.leading() { + (new, old) + } else { + (old, new) + }; + Self::Split(Box::new(SplitLayout { + id: split_id.to_owned(), + orientation: side.orientation(), + first, + second, + })) + } + Self::Group(id) => Self::Group(id), + Self::Split(split) => { + let SplitLayout { + id, + orientation, + first, + second, + } = *split; + Self::Split(Box::new(SplitLayout { + id, + orientation, + first: first.splitting(group, side, created, split_id), + second: second.splitting(group, side, created, split_id), + })) + } + } + } + + /// Removes groups outside `occupied`; a split with one surviving child is + /// replaced by that child, keeping the surviving subtree's identities. + fn keeping(self, occupied: &BTreeSet<&str>) -> Option { + match self { + Self::Group(id) => occupied.contains(id.as_str()).then_some(Self::Group(id)), + Self::Split(split) => { + let SplitLayout { + id, + orientation, + first, + second, + } = *split; + match (first.keeping(occupied), second.keeping(occupied)) { + (Some(first), Some(second)) => Some(Self::Split(Box::new(SplitLayout { + id, + orientation, + first, + second, + }))), + (first, second) => first.or(second), + } + } + } + } +} + +fn validate_layout(layout: &GroupLayout) -> Result<(), CoreError> { + let mut ids = Vec::new(); + layout.collect_ids(&mut ids); + let mut seen = HashSet::new(); + if ids.iter().all(|id| seen.insert(*id)) { + Ok(()) + } else { + Err(invalid("Workbench layout repeats a group or split ID")) + } +} + +fn group_of<'a>(state: &'a WorkbenchState, tab: &str) -> Option<&'a str> { + state + .tabs + .iter() + .find(|placement| placement.id == tab) + .and_then(|placement| placement.group.as_deref()) +} + +/// Group receiving tabs without a valid placement: the focused group while it +/// exists, otherwise the first group in layout order. +fn destination(state: &WorkbenchState) -> &str { + let groups = state.layout.groups(); + state + .focused_group + .as_deref() + .filter(|focused| groups.contains(focused)) + .unwrap_or(groups[0]) +} + +fn can_drop(state: &WorkbenchState, tab: &str, group: &str, side: DropSide) -> bool { + let Some(source) = group_of(state, tab) else { + return false; + }; + if !state.layout.groups().contains(&group) { + return false; + } + // Moving into another group always applies; within the owning group only + // an edge split of a group that keeps another tab changes anything. + source != group + || (side != DropSide::Center + && state + .tabs + .iter() + .filter(|placement| placement.group.as_deref() == Some(group)) + .count() + > 1) +} + +/// Moves `tab` into `group`, right after the group's last tab in +/// presentation order (or to the end when the group has none). +fn place_after_group(tabs: &mut Vec, tab: &str, group: &str) { + tabs.retain(|placement| placement.id != tab); + let index = tabs + .iter() + .rposition(|placement| placement.group.as_deref() == Some(group)) + .map_or(tabs.len(), |last| last + 1); + tabs.insert( + index, + TabPlacement { + id: tab.to_owned(), + group: Some(group.to_owned()), + }, + ); +} + +fn set_group(tabs: &mut [TabPlacement], tab: &str, group: String) { + if let Some(placement) = tabs.iter_mut().find(|placement| placement.id == tab) { + placement.group = Some(group); + } +} + +fn split_group( + draft: &mut WorkbenchState, + group: &str, + side: DropSide, + new_group: Option, + new_split: Option, +) -> Result { + let (Some(created), Some(split_id)) = (new_group, new_split) else { + return Err(invalid( + "Workbench edge drops require new group and split IDs", + )); + }; + let mut ids = Vec::new(); + draft.layout.collect_ids(&mut ids); + if created == split_id || ids.contains(&created.as_str()) || ids.contains(&split_id.as_str()) { + return Err(invalid("Workbench split identities must be new")); + } + let layout = std::mem::replace(&mut draft.layout, GroupLayout::Group(String::new())); + draft.layout = layout.splitting(group, side, &created, &split_id); + Ok(created) +} + +fn focus(state: &mut WorkbenchState, tab: &str) { + let Some(group) = group_of(state, tab).map(str::to_owned) else { + return; + }; + if let Some(selection) = state + .selections + .iter_mut() + .find(|selection| selection.group == group) + { + selection.tab = Some(tab.to_owned()); + } + state.focused_group = Some(group); +} + +/// Applies the shared invariants to `draft`, using `previous` to keep each +/// group's selection near its former position. +/// +/// - Tab IDs are unique; the first occurrence wins. +/// - Tabs without a valid group join the focused group (else the first group). +/// - Empty groups are removed and their splits collapse; when every group is +/// empty, the first group remains as the single reusable group. +/// - A focused group that disappeared falls back to the first remaining group. +/// - A group keeps its selected tab while the tab stays in the group; +/// otherwise it selects the tab now at the former selection's index, or the +/// last tab when the index passed the end, or its first tab when it had no +/// selection. +fn normalize(previous: &WorkbenchState, mut draft: WorkbenchState) -> WorkbenchState { + let groups: Vec = draft + .layout + .groups() + .into_iter() + .map(str::to_owned) + .collect(); + let fallback = destination(&draft).to_owned(); + let mut seen = HashSet::new(); + draft + .tabs + .retain(|placement| seen.insert(placement.id.clone())); + for placement in &mut draft.tabs { + if !placement + .group + .as_ref() + .is_some_and(|group| groups.contains(group)) + { + placement.group = Some(fallback.clone()); + } + } + + let occupied: BTreeSet<&str> = draft + .tabs + .iter() + .filter_map(|placement| placement.group.as_deref()) + .collect(); + let layout = draft + .layout + .clone() + .keeping(&occupied) + .unwrap_or_else(|| GroupLayout::Group(groups[0].clone())); + let remaining: Vec = layout.groups().into_iter().map(str::to_owned).collect(); + draft.layout = layout; + if draft + .focused_group + .as_ref() + .is_some_and(|focused| !remaining.contains(focused)) + { + draft.focused_group = Some(remaining[0].clone()); + } + + let members = members_by_group(&draft); + let previous_members = members_by_group(previous); + let previous_selection: BTreeMap<&str, &str> = previous + .selections + .iter() + .filter_map(|selection| Some((selection.group.as_str(), selection.tab.as_deref()?))) + .collect(); + let selections = remaining + .iter() + .map(|group| { + let tabs = members.get(group.as_str()).cloned().unwrap_or_default(); + let selected = previous_selection.get(group.as_str()).copied(); + let tab = match selected { + Some(selected) if tabs.contains(&selected) => Some(selected), + Some(selected) => previous_members + .get(group.as_str()) + .and_then(|old| old.iter().position(|tab| *tab == selected)) + .and_then(|index| tabs.get(index.min(tabs.len().saturating_sub(1)))) + .or(tabs.first()) + .copied(), + None => tabs.first().copied(), + }; + GroupSelection { + group: group.clone(), + tab: tab.map(str::to_owned), + } + }) + .collect(); + draft.selections = selections; + draft +} + +fn members_by_group(state: &WorkbenchState) -> BTreeMap<&str, Vec<&str>> { + let mut members: BTreeMap<&str, Vec<&str>> = BTreeMap::new(); + for placement in &state.tabs { + if let Some(group) = placement.group.as_deref() { + members.entry(group).or_default().push(&placement.id); + } + } + members +} diff --git a/rust/lithe-core/src/lsp/host_policy/activation.rs b/rust/lithe-core/src/lsp/host_policy/activation.rs new file mode 100644 index 0000000000..3908b302d1 --- /dev/null +++ b/rust/lithe-core/src/lsp/host_policy/activation.rs @@ -0,0 +1,149 @@ +//! Language-server activation retry policy (`lsp.activationPolicy`). +//! +//! The platform owns processes and stores the returned state per server +//! identity (provider or language plus workspace root). Core decides whether +//! an automatic start may run now, must wait for the failure cooldown, or +//! needs an explicit user retry, so a persistent startup failure can neither +//! loop unboundedly nor retry at different rates on the two platforms. +//! +//! Time is a caller-supplied monotonic millisecond reading. Core never reads a +//! clock, which keeps the policy deterministic and unaffected by wall-clock +//! adjustments. + +use crate::protocol::{CoreError, ErrorCode}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Consecutive failures after which automatic starts stop until the user retries. +pub(crate) const MAX_CONSECUTIVE_FAILURES: u32 = 3; +/// Minimum delay between a failure and the next automatic start. +pub(crate) const FAILURE_COOLDOWN_MS: u64 = 5_000; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Request { + /// State returned by the previous call for this server; `null` when none. + #[serde(default)] + state: Option, + event: ActivationEvent, + /// Monotonic clock reading in milliseconds from the platform. + now_ms: u64, +} + +/// Failure history the platform stores verbatim between calls. +#[derive(Deserialize, Serialize, Clone, Copy, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ActivationState { + /// Failures since the last ready session or explicit start. + consecutive_failures: u32, + /// Monotonic time of the latest failure. + last_failure_at_ms: u64, +} + +#[derive(Deserialize, Clone, Copy)] +#[serde(rename_all = "camelCase")] +enum ActivationEvent { + /// A start triggered by opening or synchronizing a document. + AutomaticStart, + /// A start the user requested (restart, retry, or repair). + ExplicitStart, + /// A start or a running session failed. + StartFailed, + /// The session reached its ready state. + Ready, +} + +#[derive(Serialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +enum Decision { + /// A start may run now. + Allow, + /// Automatic starts must wait `retryAfterMs`. + Wait, + /// Automatic starts are exhausted; only an explicit start may run. + ExplicitRetryRequired, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Response { + /// State to store for the next call; `null` clears the history. + state: Option, + decision: Decision, + /// Remaining cooldown when `decision` is `wait`. + retry_after_ms: Option, +} + +/// Executes `lsp.activationPolicy`. +pub(crate) fn policy_command(payload: Value) -> Result { + let request: Request = serde_json::from_value(payload).map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid language server activation request", + ) + .with_details(error.to_string()) + })?; + Ok(serde_json::to_value(evaluate(request)).expect("activation response should encode")) +} + +fn evaluate(request: Request) -> Response { + match request.event { + ActivationEvent::ExplicitStart | ActivationEvent::Ready => Response { + state: None, + decision: Decision::Allow, + retry_after_ms: None, + }, + ActivationEvent::StartFailed => { + let state = ActivationState { + consecutive_failures: request + .state + .map_or(0, |state| state.consecutive_failures) + .saturating_add(1), + last_failure_at_ms: request.now_ms, + }; + automatic(state, request.now_ms) + } + ActivationEvent::AutomaticStart => match request.state { + None => Response { + state: None, + decision: Decision::Allow, + retry_after_ms: None, + }, + Some(state) => automatic(state, request.now_ms), + }, + } +} + +fn automatic(state: ActivationState, now_ms: u64) -> Response { + if state.consecutive_failures >= MAX_CONSECUTIVE_FAILURES { + return Response { + state: Some(state), + decision: Decision::ExplicitRetryRequired, + retry_after_ms: None, + }; + } + // A reading earlier than the stored failure means the platform switched + // clocks; restart the cooldown from now instead of trusting the gap. + let state = if now_ms < state.last_failure_at_ms { + ActivationState { + last_failure_at_ms: now_ms, + ..state + } + } else { + state + }; + let elapsed = now_ms - state.last_failure_at_ms; + if elapsed < FAILURE_COOLDOWN_MS { + Response { + state: Some(state), + decision: Decision::Wait, + retry_after_ms: Some(FAILURE_COOLDOWN_MS - elapsed), + } + } else { + Response { + state: Some(state), + decision: Decision::Allow, + retry_after_ms: None, + } + } +} diff --git a/rust/lithe-core/src/lsp/host_policy/completion_merge.rs b/rust/lithe-core/src/lsp/host_policy/completion_merge.rs new file mode 100644 index 0000000000..2b10d6f31e --- /dev/null +++ b/rust/lithe-core/src/lsp/host_policy/completion_merge.rs @@ -0,0 +1,76 @@ +//! Merging completion lists from several feature providers (`lsp.mergeCompletions`). +//! +//! A host may ask a language server, plugin providers, and the lightweight +//! current-file fallback for completions at the same position. Core decides +//! which candidates survive: +//! +//! - Every candidate a single provider returns is kept. Overloads such as +//! `print(String)` and `print(int)` often share a label and differ only in +//! detail, signature, or resolve data, so a provider's own list is never +//! collapsed. +//! - Lists arrive in provider priority order. A lower-priority candidate is +//! shadowed when a higher-priority provider already offers the same label, +//! so fallback suggestions never duplicate what a real provider returned. +//! +//! Only labels cross the boundary. The platform keeps the full items and +//! applies the returned indices, which keeps the per-keystroke payload small. + +use crate::protocol::{CoreError, ErrorCode}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashSet; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Request { + /// Provider results ordered from highest to lowest priority. + lists: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct CompletionLabels { + labels: Vec, + #[serde(default)] + is_incomplete: bool, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Response { + /// For each submitted list, the indices of the candidates to keep, in order. + keep: Vec>, + /// True when any provider reported an incomplete list. + is_incomplete: bool, +} + +/// Executes `lsp.mergeCompletions`. +pub(crate) fn merge_command(payload: Value) -> Result { + let request: Request = serde_json::from_value(payload).map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid completion merge request") + .with_details(error.to_string()) + })?; + Ok(serde_json::to_value(merge(&request)).expect("completion merge response should encode")) +} + +fn merge(request: &Request) -> Response { + let mut offered = HashSet::<&str>::new(); + let mut keep = Vec::with_capacity(request.lists.len()); + for list in &request.lists { + let kept: Vec = list + .labels + .iter() + .enumerate() + .filter(|(_, label)| !offered.contains(label.as_str())) + .map(|(index, _)| index) + .collect(); + // Labels join the shadow set only after the whole list is evaluated, + // so duplicates inside one provider's list are all kept. + offered.extend(kept.iter().map(|index| list.labels[*index].as_str())); + keep.push(kept); + } + Response { + keep, + is_incomplete: request.lists.iter().any(|list| list.is_incomplete), + } +} diff --git a/rust/lithe-core/src/lsp/host_policy/mod.rs b/rust/lithe-core/src/lsp/host_policy/mod.rs new file mode 100644 index 0000000000..f30bd06f2d --- /dev/null +++ b/rust/lithe-core/src/lsp/host_policy/mod.rs @@ -0,0 +1,13 @@ +//! Deterministic language-tooling rules that both platform hosts apply around +//! their native language-server sessions and editor presentation. +//! +//! Every command here is a pure function of the facts the platform submits: +//! no process, document, or clock is owned by Core. Platforms keep the native +//! I/O, the per-session storage of returned state, and localization. + +pub(crate) mod activation; +pub(crate) mod completion_merge; +pub(crate) mod problems; + +#[cfg(test)] +mod tests; diff --git a/rust/lithe-core/src/lsp/host_policy/problems.rs b/rust/lithe-core/src/lsp/host_policy/problems.rs new file mode 100644 index 0000000000..b45de7f1ac --- /dev/null +++ b/rust/lithe-core/src/lsp/host_policy/problems.rs @@ -0,0 +1,397 @@ +//! Problems tool-window filtering, ordering, and grouping (`lsp.problems.query`). +//! +//! Platforms submit the diagnostics they display together with the active +//! view options. Core returns indices into the submitted list, so the large +//! message payload never crosses the boundary a second time. File paths are +//! compared with the IDEA natural file-name order that the Project view uses, +//! which keeps `File2` before `File10` on both platforms. + +use crate::project::file_name_order::FileNameKey; +use crate::protocol::{CoreError, ErrorCode}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::cmp::Ordering; +use std::collections::BTreeMap; + +/// LSP severities in display order; unknown (`0`) ranks after hints. +const SEVERITY_RANKS: [u8; 5] = [1, 2, 3, 4, 0]; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Request { + diagnostics: Vec, + #[serde(default)] + group_by: GroupBy, + #[serde(default)] + sort_by: SortBy, + #[serde(default)] + filter: Filter, +} + +/// One displayed diagnostic. Only the fields that affect filtering and order +/// are submitted; code-action payloads stay with the platform. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct DiagnosticFacts { + /// Opaque file identity chosen by the platform, using its native path + /// equality (for example a case-folded Windows path). + file_key: String, + /// Workspace-relative path shown to the user; it decides file order. + display_path: String, + /// Zero-based start line. + line: u64, + /// Zero-based UTF-16 start column. + column: u64, + /// LSP `DiagnosticSeverity` (1–4); missing or out-of-range values are unknown. + #[serde(default)] + severity: Option, + #[serde(default)] + message: String, + #[serde(default)] + source: Option, + #[serde(default)] + code: Option, +} + +#[derive(Deserialize, Default, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +enum GroupBy { + #[default] + File, + Severity, + None, +} + +#[derive(Deserialize, Default, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +enum SortBy { + /// Severity first, then file, line, and column (IDEA's default). + #[default] + Severity, + /// File, then line and column. + File, + /// Line and column across files; file order breaks ties. + Position, +} + +#[derive(Deserialize, Default)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Filter { + /// Severities to keep; `None` keeps all. `0` selects unknown severities. + #[serde(default)] + severities: Option>, + /// Restricts results to one `fileKey` (the File scope or "current file only"). + #[serde(default)] + file_key: Option, + /// Case-insensitive exact match on the diagnostic source. + #[serde(default)] + source: Option, + /// Case-insensitive substring over message, source, code, path, and + /// the one-based `line:column` label. + #[serde(default)] + query: Option, +} + +/// Per-severity totals keyed by LSP severity name. +#[derive(Serialize, Default, Clone, Copy)] +#[serde(rename_all = "camelCase")] +struct SeverityCounts { + error: usize, + warning: usize, + information: usize, + hint: usize, + unknown: usize, +} + +impl SeverityCounts { + fn add(&mut self, severity: u8) { + match severity { + 1 => self.error += 1, + 2 => self.warning += 1, + 3 => self.information += 1, + 4 => self.hint += 1, + _ => self.unknown += 1, + } + } + + fn highest(&self) -> u8 { + SEVERITY_RANKS + .into_iter() + .find(|severity| self.get(*severity) > 0) + .unwrap_or(0) + } + + fn get(&self, severity: u8) -> usize { + match severity { + 1 => self.error, + 2 => self.warning, + 3 => self.information, + 4 => self.hint, + _ => self.unknown, + } + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Group { + /// Stable key: `file:`, `severity:`, or `all`. + key: String, + kind: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + file_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + display_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + severity: Option, + /// Indices into the submitted `diagnostics`, in display order. + indices: Vec, + counts: SeverityCounts, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct FileSummary { + file_key: String, + display_path: String, + count: usize, + highest_severity: u8, + counts: SeverityCounts, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Response { + groups: Vec, + /// Files with visible diagnostics in file order, independent of `groupBy`. + files: Vec, + visible_counts: SeverityCounts, + total_counts: SeverityCounts, +} + +struct Entry<'a> { + index: usize, + facts: &'a DiagnosticFacts, + severity: u8, +} + +/// Executes `lsp.problems.query`. +pub(crate) fn query_command(payload: Value) -> Result { + let request: Request = serde_json::from_value(payload).map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid Problems query request") + .with_details(error.to_string()) + })?; + Ok(serde_json::to_value(query(&request)).expect("Problems query response should encode")) +} + +fn query(request: &Request) -> Response { + let mut total_counts = SeverityCounts::default(); + let query = request + .filter + .query + .as_deref() + .map(str::trim) + .filter(|query| !query.is_empty()) + .map(str::to_lowercase); + let source = request.filter.source.as_deref().map(str::to_lowercase); + let mut visible = Vec::new(); + for (index, facts) in request.diagnostics.iter().enumerate() { + let severity = normalized_severity(facts.severity); + total_counts.add(severity); + if request + .filter + .severities + .as_ref() + .is_some_and(|allowed| !allowed.contains(&severity)) + { + continue; + } + if request + .filter + .file_key + .as_ref() + .is_some_and(|key| key != &facts.file_key) + { + continue; + } + if let Some(source) = &source { + if facts.source.as_deref().map(str::to_lowercase).as_ref() != Some(source) { + continue; + } + } + if let Some(query) = &query { + if !haystack(facts).contains(query.as_str()) { + continue; + } + } + visible.push(Entry { + index, + facts, + severity, + }); + } + + // Each file's display key is converted to UTF-16 once, not per comparison. + let path_keys: BTreeMap<&str, FileNameKey> = visible + .iter() + .map(|entry| { + ( + entry.facts.display_path.as_str(), + FileNameKey::new(&entry.facts.display_path), + ) + }) + .collect(); + let compare_files = |left: &DiagnosticFacts, right: &DiagnosticFacts| { + path_keys[left.display_path.as_str()] + .cmp(&path_keys[right.display_path.as_str()]) + .then_with(|| left.display_path.cmp(&right.display_path)) + .then_with(|| left.file_key.cmp(&right.file_key)) + }; + let position = |entry: &Entry| (entry.facts.line, entry.facts.column); + visible.sort_by(|left, right| { + let order = match request.sort_by { + SortBy::Severity => severity_rank(left.severity) + .cmp(&severity_rank(right.severity)) + .then_with(|| compare_files(left.facts, right.facts)) + .then_with(|| position(left).cmp(&position(right))), + SortBy::File => compare_files(left.facts, right.facts) + .then_with(|| position(left).cmp(&position(right))), + SortBy::Position => position(left) + .cmp(&position(right)) + .then_with(|| compare_files(left.facts, right.facts)), + }; + // Submission order is the final tie-breaker, so equal facts stay stable. + order.then_with(|| left.index.cmp(&right.index)) + }); + + let mut visible_counts = SeverityCounts::default(); + for entry in &visible { + visible_counts.add(entry.severity); + } + + let files = file_groups(&visible, &compare_files); + let groups = match request.group_by { + GroupBy::File => files + .iter() + .map(|file| Group { + key: format!("file:{}", file.facts.file_key), + kind: "file", + file_key: Some(file.facts.file_key.clone()), + display_path: Some(file.facts.display_path.clone()), + severity: None, + indices: file.indices.clone(), + counts: file.counts, + }) + .collect(), + GroupBy::Severity => SEVERITY_RANKS + .into_iter() + .filter_map(|severity| { + let indices: Vec = visible + .iter() + .filter(|entry| entry.severity == severity) + .map(|entry| entry.index) + .collect(); + (!indices.is_empty()).then(|| { + let mut counts = SeverityCounts::default(); + indices.iter().for_each(|_| counts.add(severity)); + Group { + key: format!("severity:{severity}"), + kind: "severity", + file_key: None, + display_path: None, + severity: Some(severity), + indices, + counts, + } + }) + }) + .collect(), + GroupBy::None if visible.is_empty() => Vec::new(), + GroupBy::None => vec![Group { + key: "all".into(), + kind: "all", + file_key: None, + display_path: None, + severity: None, + indices: visible.iter().map(|entry| entry.index).collect(), + counts: visible_counts, + }], + }; + let files = files + .into_iter() + .map(|file| FileSummary { + file_key: file.facts.file_key.clone(), + display_path: file.facts.display_path.clone(), + count: file.indices.len(), + highest_severity: file.counts.highest(), + counts: file.counts, + }) + .collect(); + Response { + groups, + files, + visible_counts, + total_counts, + } +} + +struct FileGroup<'a> { + /// The first visible diagnostic of the file names the group. + facts: &'a DiagnosticFacts, + indices: Vec, + counts: SeverityCounts, +} + +fn file_groups<'a>( + visible: &[Entry<'a>], + compare_files: &dyn Fn(&DiagnosticFacts, &DiagnosticFacts) -> Ordering, +) -> Vec> { + let mut positions = BTreeMap::<&str, usize>::new(); + let mut groups: Vec> = Vec::new(); + for entry in visible { + let slot = *positions + .entry(entry.facts.file_key.as_str()) + .or_insert_with(|| { + groups.push(FileGroup { + facts: entry.facts, + indices: Vec::new(), + counts: SeverityCounts::default(), + }); + groups.len() - 1 + }); + groups[slot].indices.push(entry.index); + groups[slot].counts.add(entry.severity); + } + groups.sort_by(|left, right| compare_files(left.facts, right.facts)); + groups +} + +fn normalized_severity(severity: Option) -> u8 { + match severity { + Some(value @ 1..=4) => value as u8, + _ => 0, + } +} + +fn severity_rank(severity: u8) -> usize { + SEVERITY_RANKS + .iter() + .position(|candidate| *candidate == severity) + .unwrap_or(SEVERITY_RANKS.len()) +} + +fn haystack(facts: &DiagnosticFacts) -> String { + [ + facts.message.as_str(), + facts.source.as_deref().unwrap_or(""), + facts.code.as_deref().unwrap_or(""), + facts.display_path.as_str(), + &format!( + "{}:{}", + facts.line.saturating_add(1), + facts.column.saturating_add(1) + ), + ] + .join(" ") + .to_lowercase() +} diff --git a/rust/lithe-core/src/lsp/host_policy/tests.rs b/rust/lithe-core/src/lsp/host_policy/tests.rs new file mode 100644 index 0000000000..edf84f64d6 --- /dev/null +++ b/rust/lithe-core/src/lsp/host_policy/tests.rs @@ -0,0 +1,73 @@ +//! Shared-fixture coverage for the host policy commands. Both platforms +//! consume these fixtures, so a divergence here is a cross-platform bug. + +use serde_json::{json, Value}; + +fn run_fixture(command: &str, fixture: &str) { + let fixture: Value = serde_json::from_str(fixture).expect("fixture should be JSON"); + let cases = fixture["cases"].as_array().expect("fixture should list cases"); + assert!(!cases.is_empty(), "{command} fixture has no cases"); + for case in cases { + let response: Value = serde_json::from_str(&crate::execute_json( + &json!({"command": command, "payload": case["request"]}).to_string(), + )) + .expect("Core should return JSON"); + assert_eq!(response["ok"], true, "{}: {response}", case["name"]); + assert_eq!(response["data"], case["response"], "{}", case["name"]); + } +} + +#[test] +fn problems_query_matches_shared_fixture() { + run_fixture( + "lsp.problems.query", + include_str!("../../../../../shared/fixtures/lsp/problems-query-v1.json"), + ); +} + +#[test] +fn activation_policy_matches_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../../shared/fixtures/lsp/activation-policy-v1.json" + )) + .unwrap(); + // The fixture documents the budget; keep it pinned to the constants. + assert_eq!( + fixture["maxConsecutiveFailures"], + super::activation::MAX_CONSECUTIVE_FAILURES + ); + assert_eq!( + fixture["failureCooldownMs"], + super::activation::FAILURE_COOLDOWN_MS + ); + run_fixture( + "lsp.activationPolicy", + include_str!("../../../../../shared/fixtures/lsp/activation-policy-v1.json"), + ); +} + +#[test] +fn completion_merge_matches_shared_fixture() { + run_fixture( + "lsp.mergeCompletions", + include_str!("../../../../../shared/fixtures/lsp/completion-merge-v1.json"), + ); +} + +#[test] +fn malformed_requests_fail_with_invalid_request() { + for (command, payload) in [ + ("lsp.problems.query", json!({"diagnostics": [{"fileKey": "a"}]})), + ("lsp.problems.query", json!({"diagnostics": [], "groupBy": "type"})), + ("lsp.activationPolicy", json!({"event": "automaticStart"})), + ("lsp.activationPolicy", json!({"event": "crashed", "nowMs": 1})), + ("lsp.mergeCompletions", json!({"lists": [{"items": []}]})), + ] { + let response: Value = serde_json::from_str(&crate::execute_json( + &json!({"command": command, "payload": payload}).to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], false, "{command}: {response}"); + assert_eq!(response["error"]["code"], "invalid_request", "{command}"); + } +} diff --git a/rust/lithe-core/src/lsp/interface/document_uri.rs b/rust/lithe-core/src/lsp/interface/document_uri.rs index dabd14213b..e5214ffe47 100644 --- a/rust/lithe-core/src/lsp/interface/document_uri.rs +++ b/rust/lithe-core/src/lsp/interface/document_uri.rs @@ -16,9 +16,10 @@ struct Request { platform: Platform, } -#[derive(Clone, Copy, Deserialize)] +/// Path syntax of a native absolute path; `auto` recognizes drive and UNC paths. +#[derive(Clone, Copy, Debug, Deserialize)] #[serde(rename_all = "lowercase")] -enum Platform { +pub(crate) enum Platform { Posix, Windows, Auto, @@ -29,6 +30,29 @@ pub(crate) fn document_uri_command(payload: Value) -> Result { Ok(json!({"uri": document_uri(&request.path, request.platform)?})) } +/// Public alias for planners that compare native paths through their URIs. +pub(crate) type DocumentPathPlatform = Platform; + +impl Platform { + /// Canonical file URI of an absolute native path; see `lsp.documentUri`. + pub(crate) fn document_uri(self, path: &str) -> Result { + document_uri(path, self) + } + + /// True when `path` uses Windows syntax: drive or UNC, or `windows` explicitly. + pub(crate) fn is_windows_path(self, path: &str) -> bool { + match self { + Platform::Windows => true, + Platform::Posix => false, + Platform::Auto => { + path.starts_with("\\\\") + || path.starts_with("//") + || path.as_bytes().get(1) == Some(&b':') + } + } + } +} + fn document_uri(path: &str, platform: Platform) -> Result { if path.len() > MAX_PATH_BYTES || path.contains('\0') { return Err(invalid()); diff --git a/rust/lithe-core/src/lsp/interface/mod.rs b/rust/lithe-core/src/lsp/interface/mod.rs index b60881ff78..60c798ddf2 100644 --- a/rust/lithe-core/src/lsp/interface/mod.rs +++ b/rust/lithe-core/src/lsp/interface/mod.rs @@ -24,6 +24,7 @@ mod vcs_provider; pub(crate) use client::*; pub(crate) use document_uri::{ document_uri_command, resolve_field as resolve_uri_field, resolve_request_paths, + DocumentPathPlatform, }; pub(crate) use engine::*; pub(crate) use engine_logging::{resolve_engine_log_subject, ResolveEngineLogSubjectRequest}; diff --git a/rust/lithe-core/src/lsp/lightweight/edits.rs b/rust/lithe-core/src/lsp/lightweight/edits.rs index 22f9f07789..5329c5f2e9 100644 --- a/rust/lithe-core/src/lsp/lightweight/edits.rs +++ b/rust/lithe-core/src/lsp/lightweight/edits.rs @@ -31,36 +31,251 @@ pub struct TextResponse { } /// Applies non-overlapping edits from the end of the document toward the start. +/// +/// Validation and ordering are shared with `editor.planTextEdits` through +/// [`plan_replacements`]; only the position policy differs (LSP wire +/// semantics clamp a column past the line end and resolve a position inside a +/// surrogate pair to the character start). pub fn apply_text_edits(request: ApplyTextEditsRequest) -> Result { - let mut replacements = Vec::new(); - for edit in request.edits { - let start = utf16_position_to_byte_offset(&request.text, edit.range.start)?; - let end = utf16_position_to_byte_offset(&request.text, edit.range.end)?; + let index = TextLineIndex::new(&request.text); + let edits = request + .edits + .into_iter() + .map(|edit| RawTextEdit { + start: (edit.range.start.line, edit.range.start.utf16_column), + end: (edit.range.end.line, edit.range.end.utf16_column), + text: edit.new_text, + }) + .collect(); + let replacements = + plan_replacements(&index, edits, PositionPolicy::LspLenient).map_err(|rejection| { + match rejection.reason { + EditRejectionReason::OverlappingRanges => CoreError::new( + ErrorCode::InvalidRequest, + "Language server returned overlapping text edits.", + ) + .with_details("overlappingEdits"), + _ => invalid_range_error(), + } + })?; + + let mut text = request.text; + for replacement in replacements.into_iter().rev() { + text.replace_range(replacement.start..replacement.end, &replacement.text); + } + Ok(TextResponse { text }) +} + +/// How a position that does not name an exact code-unit boundary is treated. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PositionPolicy { + /// LSP wire semantics: a column past the line end clamps before the line + /// terminator and a column inside a surrogate pair resolves to the start of + /// that character. Missing and negative lines or columns are rejected. + LspLenient, + /// Plugin editor API semantics: every position must name an existing line, + /// a column within that line's content and a code-unit boundary. + Strict, +} + +/// Zero-based line and UTF-16 column pair with the edit's replacement text. +pub(crate) struct RawTextEdit { + pub start: (i64, i64), + pub end: (i64, i64), + pub text: String, +} + +/// One validated replacement in byte offsets of the planned text. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PlannedReplacement { + /// Position of the edit in the caller's batch. + pub index: usize, + pub start: usize, + pub end: usize, + pub text: String, +} + +/// Stable rejection classes for one edit batch. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum EditRejectionReason { + /// A position names a line or column that does not exist. + RangeOutOfBounds, + /// `start` is after `end`. + Reversed, + /// A position falls between the two code units of a surrogate pair. + SplitsSurrogatePair, + /// Two edits overlap; zero-length insertions at a shared boundary do not. + OverlappingRanges, +} + +/// First rejection found in a batch, with indices in the caller's order. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct EditRejection { + pub reason: EditRejectionReason, + pub edit_index: usize, + /// The lower-priority partner of an overlap; `None` for single-edit faults. + pub other_edit_index: Option, +} + +/// Line table of a text in byte offsets. `\r\n`, `\n` and a lone `\r` each +/// terminate one line, as the LSP specification and Monaco define them. +pub(crate) struct TextLineIndex<'a> { + text: &'a str, + line_starts: Vec, + /// Byte offset just before each line's terminator (or the text end). + content_ends: Vec, + /// Distinct terminators in first-seen order. + separators: Vec<&'static str>, +} + +impl<'a> TextLineIndex<'a> { + pub(crate) fn new(text: &'a str) -> Self { + let bytes = text.as_bytes(); + let mut line_starts = vec![0]; + let mut content_ends = Vec::new(); + let mut separators: Vec<&'static str> = Vec::new(); + let mut index = 0; + while index < bytes.len() { + let separator = match bytes[index] { + b'\r' if bytes.get(index + 1) == Some(&b'\n') => "\r\n", + b'\r' => "\r", + b'\n' => "\n", + _ => { + index += 1; + continue; + } + }; + content_ends.push(index); + index += separator.len(); + line_starts.push(index); + if !separators.contains(&separator) { + separators.push(separator); + } + } + content_ends.push(bytes.len()); + Self { + text, + line_starts, + content_ends, + separators, + } + } + + /// Wire name of the document's line ending: `lf`, `crlf`, `cr` or `mixed`. + /// A text without terminators reports `lf`. + pub(crate) fn line_ending(&self) -> &'static str { + match self.separators.as_slice() { + [] | ["\n"] => "lf", + ["\r\n"] => "crlf", + ["\r"] => "cr", + _ => "mixed", + } + } + + /// Separator written for inserted line breaks: the first one in the text, + /// LF when there is none. + pub(crate) fn insertion_separator(&self) -> &'static str { + self.separators.first().copied().unwrap_or("\n") + } + + /// Resolves a zero-based line and UTF-16 column to a byte offset. + pub(crate) fn resolve( + &self, + (line, column): (i64, i64), + policy: PositionPolicy, + ) -> Result { + let (Ok(line), Ok(column)) = (usize::try_from(line), usize::try_from(column)) else { + return Err(EditRejectionReason::RangeOutOfBounds); + }; + let (Some(&start), Some(&contents_end)) = + (self.line_starts.get(line), self.content_ends.get(line)) + else { + return Err(EditRejectionReason::RangeOutOfBounds); + }; + let mut units = 0; + for (relative, character) in self.text[start..contents_end].char_indices() { + if units == column { + return Ok(start + relative); + } + let next_units = units + character.len_utf16(); + if next_units > column { + return match policy { + PositionPolicy::LspLenient => Ok(start + relative), + PositionPolicy::Strict => Err(EditRejectionReason::SplitsSurrogatePair), + }; + } + units = next_units; + } + if units == column || policy == PositionPolicy::LspLenient { + Ok(contents_end) + } else { + Err(EditRejectionReason::RangeOutOfBounds) + } + } +} + +/// Validates a whole batch before anything is applied and returns it in a +/// stable application order: by start, then end, then the caller's order. +/// Zero-length insertions at the same position keep the caller's order, and an +/// insertion at a replaced range's boundary is adjacent rather than +/// overlapping, independent of where it appears in the batch. +pub(crate) fn plan_replacements( + index: &TextLineIndex<'_>, + edits: Vec, + policy: PositionPolicy, +) -> Result, EditRejection> { + let mut planned = Vec::with_capacity(edits.len()); + for (position, edit) in edits.into_iter().enumerate() { + let reject = |reason| EditRejection { + reason, + edit_index: position, + other_edit_index: None, + }; + let start = index.resolve(edit.start, policy).map_err(reject)?; + let end = index.resolve(edit.end, policy).map_err(reject)?; if end < start { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Language server returned an invalid text range.", - ) - .with_details("invalidRange")); + return Err(reject(EditRejectionReason::Reversed)); } - replacements.push((start, end, edit.new_text)); + planned.push(PlannedReplacement { + index: position, + start, + end, + text: edit.text, + }); } - replacements.sort_by_key(|(start, _, _)| *start); - for pair in replacements.windows(2) { - if pair[0].1 > pair[1].0 { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Language server returned overlapping text edits.", - ) - .with_details("overlappingEdits")); + planned.sort_by_key(|edit| (edit.start, edit.end, edit.index)); + for pair in planned.windows(2) { + if pair[1].start < pair[0].end { + return Err(EditRejection { + reason: EditRejectionReason::OverlappingRanges, + edit_index: pair[0].index.min(pair[1].index), + other_edit_index: Some(pair[0].index.max(pair[1].index)), + }); } } + Ok(planned) +} - let mut text = request.text; - for (start, end, replacement) in replacements.into_iter().rev() { - text.replace_range(start..end, &replacement); +/// Rewrites every `\r\n`, `\r` and `\n` in `text` as `separator`. +pub(crate) fn normalize_line_breaks(text: &str, separator: &str) -> String { + if !text.contains(['\r', '\n']) { + return text.to_string(); } - Ok(TextResponse { text }) + let mut result = String::with_capacity(text.len()); + let mut characters = text.chars().peekable(); + while let Some(character) = characters.next() { + match character { + '\r' => { + if characters.peek() == Some(&'\n') { + characters.next(); + } + result.push_str(separator); + } + '\n' => result.push_str(separator), + other => result.push(other), + } + } + result } /// Applies ranged content changes sequentially in the given order. @@ -116,23 +331,28 @@ fn invalid_range_error() -> CoreError { .with_details("invalidRange") } +// Shares the terminator rule of `TextLineIndex` (`\r\n`, `\n`, lone `\r`) +// so document sync, lightweight symbols and edit planning count lines alike. fn line_bounds(text: &str, target_line: usize) -> Option<(usize, usize)> { let bytes = text.as_bytes(); let mut line = 0; let mut start = 0; - for (index, byte) in bytes.iter().enumerate() { - if *byte == b'\n' { - if line == target_line { - let contents_end = if index > start && bytes[index - 1] == b'\r' { - index - 1 - } else { - index - }; - return Some((start, contents_end)); + let mut index = 0; + while index < bytes.len() { + let terminator = match bytes[index] { + b'\r' if bytes.get(index + 1) == Some(&b'\n') => 2, + b'\r' | b'\n' => 1, + _ => { + index += 1; + continue; } - line += 1; - start = index + 1; + }; + if line == target_line { + return Some((start, index)); } + line += 1; + index += terminator; + start = index; } if line == target_line { Some((start, text.len())) @@ -165,14 +385,16 @@ fn byte_offset_to_lsp_position(text: &str, offset: usize) -> LspPositionResponse let offset = offset.min(text.len()); let mut line = 0_i64; let mut column = 0_i64; - for (index, character) in text.char_indices() { + let mut characters = text.char_indices().peekable(); + while let Some((index, character)) = characters.next() { if index >= offset { break; } - if character == '\n' { + let crlf = character == '\r' && characters.peek().is_some_and(|(_, next)| *next == '\n'); + if character == '\n' || (character == '\r' && !crlf) { line += 1; column = 0; - } else { + } else if !crlf { column += character.len_utf16() as i64; } } diff --git a/rust/lithe-core/src/lsp/mod.rs b/rust/lithe-core/src/lsp/mod.rs index c39f987a3f..5bea638b61 100644 --- a/rust/lithe-core/src/lsp/mod.rs +++ b/rust/lithe-core/src/lsp/mod.rs @@ -1,8 +1,12 @@ //! Language tooling is split by responsibility while this facade keeps the Core command API stable. +pub(crate) mod host_policy; pub(crate) mod interface; mod languages; pub(crate) mod lightweight; +pub(crate) mod navigation_target; +pub(crate) mod workspace_edit_plan; +mod workspace_scope; pub(crate) use interface::*; pub(crate) use languages::*; diff --git a/rust/lithe-core/src/lsp/navigation_target.rs b/rust/lithe-core/src/lsp/navigation_target.rs new file mode 100644 index 0000000000..d5ad00241e --- /dev/null +++ b/rust/lithe-core/src/lsp/navigation_target.rs @@ -0,0 +1,122 @@ +//! Permission and read-only policy for language-server jump targets +//! (`lsp.classifyNavigationTarget`). +//! +//! A jump may open a workspace file normally. It may leave the workspace only +//! into a source file of the language that produced it, and such a file opens +//! read-only; configuration, credentials and other files a server might point +//! at stay closed. Hosts own symlink resolution and the path-to-language +//! lookup and pass both as facts; Core owns the decision. + +use crate::lsp::{workspace_scope::WorkspaceScope, DocumentPathPlatform}; +use crate::protocol::{CoreError, ErrorCode}; +use serde::Deserialize; +use serde_json::{json, Value}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Request { + platform: DocumentPathPlatform, + /// Workspace root as opened; `None` when no workspace is open. + #[serde(default)] + workspace_root: Option, + /// Real path of the root, when the host resolved symlinks. + #[serde(default)] + resolved_workspace_root: Option, + /// Absolute target path, as the editor would open it. + path: String, + /// Real path of the target, when the host resolved symlinks. + #[serde(default)] + resolved_path: Option, + /// Language that produced the jump; `None` permits no external target. + #[serde(default, rename = "sourceLanguageID")] + source_language_id: Option, + /// Language the host assigns to `path`. + #[serde(default, rename = "languageID")] + language_id: Option, + /// Language the host assigns to `resolvedPath`. + #[serde(default, rename = "resolvedLanguageID")] + resolved_language_id: Option, + /// Read-only flag the server or Core projection put on the location. + #[serde(default)] + is_read_only: bool, +} + +/// Classifies a physical jump target as `workspace`, `externalReadOnly` or +/// `rejected`, and returns the effective `isReadOnly` for opening it. +/// +/// A target is a workspace file only when both its opened path and its real +/// path are inside the workspace (lexically, through either the opened or the +/// real root): an in-workspace symlink to the outside must not skip the +/// source-file check, and an outside path to a workspace file still opens +/// under its outside path. Missing real paths fall back to the opened ones. +pub(crate) fn classify_command(payload: Value) -> Result { + let request: Request = serde_json::from_value(payload).map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid navigation target classification request", + ) + .with_details(error.to_string()) + })?; + let classification = classify(&request)?; + Ok(json!({ + "classification": classification, + "isReadOnly": request.is_read_only || classification == "externalReadOnly", + })) +} + +fn classify(request: &Request) -> Result<&'static str, CoreError> { + let Some(root) = request.workspace_root.as_deref() else { + return Ok("workspace"); + }; + let resolved_root = request.resolved_workspace_root.as_deref().unwrap_or(root); + let resolved_path = request.resolved_path.as_deref().unwrap_or(&request.path); + let opened_scope = WorkspaceScope::new(root, request.platform)?; + let real_scope = WorkspaceScope::new(resolved_root, request.platform)?; + let opens_inside = + opened_scope.contains(&request.path)? || real_scope.contains(&request.path)?; + let resolves_inside = real_scope.contains(resolved_path)?; + if opens_inside && resolves_inside { + return Ok("workspace"); + } + let resolved_language = if request.resolved_path.is_some() { + request.resolved_language_id.as_deref() + } else { + request.language_id.as_deref() + }; + let same_language = request.source_language_id.as_deref().is_some_and(|source| { + request.language_id.as_deref() == Some(source) && resolved_language == Some(source) + }); + Ok(if same_language { + "externalReadOnly" + } else { + "rejected" + }) +} + +#[cfg(test)] +mod tests { + use serde_json::{json, Value}; + + /// Every shared-fixture case yields its expected data or error code + /// through the public JSON command. + #[test] + fn matches_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/lsp/navigation-target-v1.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let response: Value = serde_json::from_str(&crate::execute_json( + &json!({"command": "lsp.classifyNavigationTarget", "payload": case["request"]}).to_string(), + )) + .unwrap(); + if let Some(code) = case.get("expectedError") { + assert_eq!(response["ok"], false, "{}: {response}", case["name"]); + assert_eq!(&response["error"]["code"], code, "{}", case["name"]); + } else { + assert_eq!(response["ok"], true, "{}: {response}", case["name"]); + assert_eq!(response["data"], case["expected"], "{}", case["name"]); + } + } + } +} diff --git a/rust/lithe-core/src/lsp/workspace_edit_plan.rs b/rust/lithe-core/src/lsp/workspace_edit_plan.rs new file mode 100644 index 0000000000..3f6abfd961 --- /dev/null +++ b/rust/lithe-core/src/lsp/workspace_edit_plan.rs @@ -0,0 +1,186 @@ +//! Target validation and commit order for language-server workspace edits +//! (`lsp.planWorkspaceEdit`). +//! +//! A rename or code action may touch many files. Core decides, before anything +//! is read or written, whether every target is a workspace file that may be +//! edited, which targets are open editor documents and which are closed files, +//! and the order in which the host commits them. The host owns reading, +//! encoding-preserving writes, editor buffers, stale-input checks and the +//! rollback the plan prescribes. + +use crate::lsp::{ + percent_decode, + workspace_scope::{comparable, WorkspaceScope}, + DocumentPathPlatform, +}; +use crate::protocol::{CoreError, ErrorCode}; +use serde::Deserialize; +use serde_json::{json, Map, Value}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct OpenDocument { + path: String, + #[serde(default)] + is_read_only: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Request { + platform: DocumentPathPlatform, + workspace_root: String, + /// Text edits per target, keyed by a file URI or an absolute native path, + /// as `lsp.request` projects `WorkspaceEdit.changes`/`documentChanges`. + changes: Map, + /// Open, non-virtual editor documents of this workspace. + #[serde(default)] + open_documents: Vec, +} + +struct Target { + /// Comparable URI identity used to merge keys naming one file. + identity: String, + path: String, + relative_path: String, + open_document: bool, + edits: Vec, +} + +/// Native path named by a workspace-edit key, or `None` for a non-file URI. +/// Core's URI projection keeps the leading slash of `file:///C:/...`; strip it +/// so drive paths parse as Windows paths. +fn native_path(key: &str) -> Option { + let path = match key.split_once(':') { + // A one-letter "scheme" is a drive letter, not a URI scheme. + Some((scheme, rest)) + if scheme.len() > 1 + && scheme.starts_with(|c: char| c.is_ascii_alphabetic()) + && scheme + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) => + { + if !scheme.eq_ignore_ascii_case("file") { + return None; + } + let rest = rest.strip_prefix("//").unwrap_or(rest); + // `file:///p` leaves `/p`; `file://host/share` leaves `host/share`. + if rest.starts_with('/') { + percent_decode(rest) + } else { + format!("//{}", percent_decode(rest)) + } + } + _ => key.to_string(), + }; + let bytes = path.as_bytes(); + if bytes.len() >= 3 && bytes[0] == b'/' && bytes[1].is_ascii_alphabetic() && bytes[2] == b':' + { + return Some(path[1..].to_string()); + } + Some(path) +} + +/// Plans one workspace edit. +/// +/// Returns `{ rejection, targets }`. On success `rejection` is null and +/// `targets` lists every target with edits in commit order: first the closed +/// files (`effect: "writeFile"`), then the open documents +/// (`effect: "updateDocument"`), each group ordered by `relativePath`. The host +/// computes every new text before committing, writes the files in order, and +/// when a write fails restores the files already written in reverse order and +/// updates no document. Open targets report the host's own document `path`. +/// Keys that name the same file are merged in key order. A batch with any +/// target outside the workspace (including non-file URIs) or any read-only +/// open document returns `rejection: { code: "outsideWorkspace" | +/// "readOnlyDocument", path }` and no targets. Unparseable native paths return +/// `invalid_request`. +pub(crate) fn plan_command(payload: Value) -> Result { + let request: Request = serde_json::from_value(payload).map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid workspace edit plan request", + ) + .with_details(error.to_string()) + })?; + let scope = WorkspaceScope::new(&request.workspace_root, request.platform)?; + let reject = |code: &str, path: &str| Ok(json!({"rejection": {"code": code, "path": path}, "targets": []})); + let mut targets: Vec = Vec::new(); + // serde_json maps iterate in key order, which keeps rejection and merge + // order deterministic for the same edit. + for (key, edits) in &request.changes { + let Some(path) = native_path(key) else { + return reject("outsideWorkspace", key); + }; + let Some(relative_path) = scope.relative_path(&path)? else { + return reject("outsideWorkspace", &path); + }; + let identity = comparable(&path, request.platform)?; + let mut open = None; + for document in &request.open_documents { + if comparable(&document.path, request.platform)? == identity { + open = Some(document); + break; + } + } + if open.is_some_and(|document| document.is_read_only) { + return reject("readOnlyDocument", &path); + } + let edits = edits.as_array().cloned().unwrap_or_default(); + if let Some(existing) = targets + .iter_mut() + .find(|target| target.identity == identity) + { + existing.edits.extend(edits); + continue; + } + targets.push(Target { + identity, + path: open.map_or(path, |document| document.path.clone()), + relative_path, + open_document: open.is_some(), + edits, + }); + } + targets.retain(|target| !target.edits.is_empty()); + targets.sort_by(|left, right| { + (left.open_document, &left.relative_path).cmp(&(right.open_document, &right.relative_path)) + }); + Ok(json!({ + "rejection": null, + "targets": targets.into_iter().map(|target| json!({ + "path": target.path, + "relativePath": target.relative_path, + "effect": if target.open_document { "updateDocument" } else { "writeFile" }, + "edits": target.edits, + })).collect::>(), + })) +} + +#[cfg(test)] +mod tests { + use serde_json::{json, Value}; + + /// Every shared-fixture case yields its expected data or error code + /// through the public JSON command. + #[test] + fn matches_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/lsp/workspace-edit-plan-v1.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let response: Value = serde_json::from_str(&crate::execute_json( + &json!({"command": "lsp.planWorkspaceEdit", "payload": case["request"]}).to_string(), + )) + .unwrap(); + if let Some(code) = case.get("expectedError") { + assert_eq!(response["ok"], false, "{}: {response}", case["name"]); + assert_eq!(&response["error"]["code"], code, "{}", case["name"]); + } else { + assert_eq!(response["ok"], true, "{}: {response}", case["name"]); + assert_eq!(response["data"], case["expected"], "{}", case["name"]); + } + } + } +} diff --git a/rust/lithe-core/src/lsp/workspace_scope.rs b/rust/lithe-core/src/lsp/workspace_scope.rs new file mode 100644 index 0000000000..680eae12ea --- /dev/null +++ b/rust/lithe-core/src/lsp/workspace_scope.rs @@ -0,0 +1,57 @@ +//! Lexical workspace containment for native paths named by language servers. +//! +//! Hosts pass absolute paths (and, where they can resolve symlinks, the real +//! paths); Core compares them through their canonical file URIs so dot +//! segments, separators, percent-encoding and Windows drive/UNC syntax follow +//! the `lsp.documentUri` rules. Windows comparisons ignore ASCII case, as the +//! Windows filesystem does. No filesystem access happens here. + +use crate::lsp::DocumentPathPlatform; +use crate::protocol::CoreError; + +/// One workspace root in canonical URI form. +pub(crate) struct WorkspaceScope { + /// Root URI with a trailing `/`, case-folded for Windows paths. + prefix: String, + platform: DocumentPathPlatform, +} + +impl WorkspaceScope { + pub(crate) fn new(root: &str, platform: DocumentPathPlatform) -> Result { + let mut prefix = comparable(root, platform)?; + if !prefix.ends_with('/') { + prefix.push('/'); + } + Ok(Self { prefix, platform }) + } + + /// True when `path` lies strictly below the root; the root itself is not a + /// file inside it. + pub(crate) fn contains(&self, path: &str) -> Result { + let target = comparable(path, self.platform)?; + Ok(target.len() > self.prefix.len() && target.starts_with(&self.prefix)) + } + + /// `/`-separated path of `path` relative to the root, when inside it. + pub(crate) fn relative_path(&self, path: &str) -> Result, CoreError> { + if !self.contains(path)? { + return Ok(None); + } + let uri = self.platform.document_uri(path)?; + // The case-folded prefix has the same byte length as the original URI + // prefix because only ASCII letters are folded. + let relative = &uri[self.prefix.len()..]; + Ok(Some(crate::lsp::percent_decode(relative))) + } +} + +/// Canonical URI used for comparison. Distinct spellings of one Windows path +/// (drive letter case, separators, `\\?\` prefixes) compare equal. +pub(crate) fn comparable(path: &str, platform: DocumentPathPlatform) -> Result { + let uri = platform.document_uri(path)?; + Ok(if platform.is_windows_path(path) { + uri.to_ascii_lowercase() + } else { + uri + }) +} diff --git a/rust/lithe-core/src/project/document_save_batch.rs b/rust/lithe-core/src/project/document_save_batch.rs new file mode 100644 index 0000000000..c60a2cdf41 --- /dev/null +++ b/rust/lithe-core/src/project/document_save_batch.rs @@ -0,0 +1,214 @@ +//! Membership and result rules for saving a batch of open documents. +//! +//! "Save All" and the save that precedes Run, Debug, Test or a project +//! transition share one rule set: which open documents take part, and when the +//! batch counts as complete. Platforms own the writes themselves (sequential or +//! concurrent), dialogs, and failure notifications; they report each member's +//! outcome together with the live documents observed after the writes, so +//! edits or documents that appeared while saving keep the batch incomplete. + +use std::collections::{HashMap, HashSet}; + +use crate::protocol::{CoreError, ErrorCode}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Maximum number of open documents accepted by one batch request. +pub(crate) const MAX_SAVE_BATCH_DOCUMENTS: usize = 10_000; + +/// Persistence facts a platform reports for one open document. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct BatchDocument { + /// Platform document identity, unique within the request. + id: String, + /// The platform model holds text that differs from the last saved text. + dirty: bool, + /// The document cannot be written by the editor. + read_only: bool, + /// False when the editor view may still hold input that the platform + /// model has not received, so a clean model does not prove a clean editor. + content_synchronized: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PlanRequest { + documents: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct PlanResponse { + /// Documents to save, in request order. + members: Vec, +} + +/// Outcome of saving one batch member. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +enum MemberOutcome { + Saved, + Failed, + /// The write did not run because the user or the caller cancelled it. + Cancelled, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct MemberResult { + id: String, + outcome: MemberOutcome, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ReduceRequest { + /// Members returned by `document.planSaveBatch`. + members: Vec, + /// Exactly one result per member. + results: Vec, + /// Live open documents observed after every member finished. + documents: Vec, +} + +/// Aggregate batch status, in decreasing precedence. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +enum BatchStatus { + /// At least one member was cancelled; callers abort without an error. + Cancelled, + /// At least one member failed to save. + Failed, + /// Every member saved, but a live document is still dirty. + Incomplete, + /// Every member saved and no live document is dirty. + Saved, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ReduceResponse { + status: BatchStatus, + /// Members that saved and are not dirty again. + saved_count: usize, + failed: Vec, + cancelled: Vec, + /// Live dirty documents, in live document order, whether or not they were + /// members: edits made during the batch and newly opened documents count. + remaining_dirty: Vec, +} + +/// Selects the documents a save batch writes (`document.planSaveBatch`). +/// +/// A writable document takes part when it is dirty or when its editor view may +/// hold unsynchronized input. Returns `invalidRequest` for malformed payloads, +/// empty or duplicate identities, or more than [`MAX_SAVE_BATCH_DOCUMENTS`]. +pub(crate) fn plan_command(payload: Value) -> Result { + let request: PlanRequest = decode(payload)?; + validate_documents(&request.documents)?; + let members = request + .documents + .into_iter() + .filter(|document| { + !document.read_only && (document.dirty || !document.content_synchronized) + }) + .map(|document| document.id) + .collect(); + Ok(serde_json::to_value(PlanResponse { members }).expect("save batch plan should encode")) +} + +/// Reduces member outcomes and live documents into one batch result +/// (`document.reduceSaveBatch`). +/// +/// Returns `invalidRequest` when results do not name each member exactly once, +/// in addition to the document validation of [`plan_command`]. +pub(crate) fn reduce_command(payload: Value) -> Result { + let request: ReduceRequest = decode(payload)?; + validate_documents(&request.documents)?; + if request.members.len() > MAX_SAVE_BATCH_DOCUMENTS { + return Err(invalid("Save batch has too many members")); + } + let mut outcomes: HashMap<&str, MemberOutcome> = HashMap::new(); + for result in &request.results { + if outcomes.insert(&result.id, result.outcome).is_some() { + return Err(invalid("Save batch results repeat a member")); + } + } + let mut members = HashSet::new(); + for member in &request.members { + if !members.insert(member.as_str()) || !outcomes.contains_key(member.as_str()) { + return Err(invalid("Save batch results must name each member once")); + } + } + if outcomes.len() != members.len() { + return Err(invalid("Save batch results name a non-member")); + } + + // Read-only documents cannot be saved, but unsaved text in one still means + // the batch did not persist everything the user sees. + let remaining_dirty: Vec = request + .documents + .iter() + .filter(|document| document.dirty) + .map(|document| document.id.clone()) + .collect(); + let dirty: HashSet<&str> = remaining_dirty.iter().map(String::as_str).collect(); + let with_outcome = |wanted: MemberOutcome| -> Vec { + request + .members + .iter() + .filter(|member| outcomes[member.as_str()] == wanted) + .cloned() + .collect() + }; + let failed = with_outcome(MemberOutcome::Failed); + let cancelled = with_outcome(MemberOutcome::Cancelled); + let saved_count = request + .members + .iter() + .filter(|member| { + outcomes[member.as_str()] == MemberOutcome::Saved && !dirty.contains(member.as_str()) + }) + .count(); + let status = if !cancelled.is_empty() { + BatchStatus::Cancelled + } else if !failed.is_empty() { + BatchStatus::Failed + } else if !remaining_dirty.is_empty() { + BatchStatus::Incomplete + } else { + BatchStatus::Saved + }; + Ok(serde_json::to_value(ReduceResponse { + status, + saved_count, + failed, + cancelled, + remaining_dirty, + }) + .expect("save batch result should encode")) +} + +fn decode(payload: Value) -> Result { + serde_json::from_value(payload).map_err(|error| { + invalid("Invalid document save batch request").with_details(error.to_string()) + }) +} + +fn validate_documents(documents: &[BatchDocument]) -> Result<(), CoreError> { + if documents.len() > MAX_SAVE_BATCH_DOCUMENTS { + return Err(invalid("Save batch has too many documents")); + } + let mut seen = HashSet::new(); + for document in documents { + if document.id.is_empty() || !seen.insert(document.id.as_str()) { + return Err(invalid("Save batch documents need unique identities")); + } + } + Ok(()) +} + +fn invalid(message: &str) -> CoreError { + CoreError::new(ErrorCode::InvalidRequest, message) +} diff --git a/rust/lithe-core/src/project/mod.rs b/rust/lithe-core/src/project/mod.rs index 72ae4add53..3f8dc15bc2 100644 --- a/rust/lithe-core/src/project/mod.rs +++ b/rust/lithe-core/src/project/mod.rs @@ -1,6 +1,7 @@ //! Project files, search, local history, and document rendering services. mod document_lifecycle; +pub(crate) mod document_save_batch; pub(crate) mod file_name_order; pub(crate) mod files; mod history; diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index 4b8d9e2740..f11c964e7d 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -310,6 +310,10 @@ pub enum CoreCommand { DocumentLifecycle, /// Classifies decoded Unicode text independently of its filename (`document.classifyText`). DocumentClassifyText, + /// Selects the open documents a save batch writes (`document.planSaveBatch`). + DocumentPlanSaveBatch, + /// Reduces save batch member outcomes and live documents (`document.reduceSaveBatch`). + DocumentReduceSaveBatch, /// Records one local-history snapshot (`history.record`). HistoryRecord, /// Lists retained local-history metadata (`history.entries`). @@ -408,14 +412,29 @@ pub enum CoreCommand { DebugDestroySession, /// Applies validated UTF-16 LSP text edits (`lsp.applyTextEdits`). LspApplyTextEdits, + /// Validates workspace-edit targets and orders their commit + /// (`lsp.planWorkspaceEdit`). + LspPlanWorkspaceEdit, + /// Decides whether a language-server jump target opens, opens read-only + /// or is refused (`lsp.classifyNavigationTarget`). + LspClassifyNavigationTarget, /// Applies a deterministic line-level editing transform /// (`editor.lineEdit`). EditorLineEdit, + /// Validates and orders a plugin document edit batch + /// (`editor.planTextEdits`). + EditorPlanTextEdits, /// Parses a line or line:column navigation input (`editor.parseNavigationInput`). EditorParseNavigationInput, + /// Reduces one bounded back/forward navigation history event + /// (`editor.navigationHistory.reduce`). + EditorNavigationHistoryReduce, /// Resolves the line comment token for a file extension /// (`editor.lineCommentToken`). EditorLineCommentToken, + /// Reduces editor tab group membership, selection and focus + /// (`editor.workbenchState.reduce`). + EditorWorkbenchStateReduce, /// Normalizes the stored proxy settings into the policy a network stack /// must apply (`network.proxyPolicy`). NetworkProxyPolicy, @@ -427,6 +446,12 @@ pub enum CoreCommand { LspBuiltinHover, /// Provides same-document fallback navigation (`lsp.builtinNavigation`). LspBuiltinNavigation, + /// Merges completion lists from prioritized providers (`lsp.mergeCompletions`). + LspMergeCompletions, + /// Decides whether a language server may start automatically (`lsp.activationPolicy`). + LspActivationPolicy, + /// Filters, orders, and groups Problems tool-window diagnostics (`lsp.problems.query`). + LspProblemsQuery, /// Starts and initializes a managed language server (`lsp.startServer`). LspStartServer, /// Derives the durable JDT LS workspace directory key (`lsp.jdtWorkspaceKey`). @@ -908,6 +933,8 @@ impl CoreCommand { "file.write" => Some(Self::FileWrite), "document.lifecycle" => Some(Self::DocumentLifecycle), "document.classifyText" => Some(Self::DocumentClassifyText), + "document.planSaveBatch" => Some(Self::DocumentPlanSaveBatch), + "document.reduceSaveBatch" => Some(Self::DocumentReduceSaveBatch), "history.record" => Some(Self::HistoryRecord), "history.entries" => Some(Self::HistoryEntries), "history.content" => Some(Self::HistoryContent), @@ -957,14 +984,22 @@ impl CoreCommand { "debug.disconnect" => Some(Self::DebugDisconnect), "debug.destroySession" => Some(Self::DebugDestroySession), "lsp.applyTextEdits" => Some(Self::LspApplyTextEdits), + "lsp.planWorkspaceEdit" => Some(Self::LspPlanWorkspaceEdit), + "lsp.classifyNavigationTarget" => Some(Self::LspClassifyNavigationTarget), "editor.lineEdit" => Some(Self::EditorLineEdit), + "editor.planTextEdits" => Some(Self::EditorPlanTextEdits), "editor.parseNavigationInput" => Some(Self::EditorParseNavigationInput), + "editor.navigationHistory.reduce" => Some(Self::EditorNavigationHistoryReduce), "editor.lineCommentToken" => Some(Self::EditorLineCommentToken), + "editor.workbenchState.reduce" => Some(Self::EditorWorkbenchStateReduce), "network.proxyPolicy" => Some(Self::NetworkProxyPolicy), "lsp.plainSnippet" => Some(Self::LspPlainSnippet), "lsp.builtinCompletions" => Some(Self::LspBuiltinCompletions), "lsp.builtinHover" => Some(Self::LspBuiltinHover), "lsp.builtinNavigation" => Some(Self::LspBuiltinNavigation), + "lsp.mergeCompletions" => Some(Self::LspMergeCompletions), + "lsp.activationPolicy" => Some(Self::LspActivationPolicy), + "lsp.problems.query" => Some(Self::LspProblemsQuery), "lsp.startServer" => Some(Self::LspStartServer), "lsp.jdtWorkspaceKey" => Some(Self::LspJdtWorkspaceKey), "java.workspacePolicy" => Some(Self::JavaWorkspacePolicy), diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index 780fb2967b..f097bc4f77 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -1041,6 +1041,16 @@ fn execute_parsed(mut parsed: CoreRequest) -> CoreResponse { ), } } + CoreCommand::DocumentPlanSaveBatch => plugin_response( + id, + parsed.payload, + project::document_save_batch::plan_command, + ), + CoreCommand::DocumentReduceSaveBatch => plugin_response( + id, + parsed.payload, + project::document_save_batch::reduce_command, + ), CoreCommand::DocumentLifecycle => { match serde_json::from_value::(parsed.payload) .map_err(|error| { @@ -1848,11 +1858,31 @@ fn execute_parsed(mut parsed: CoreRequest) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::LspPlanWorkspaceEdit => plugin_response( + id, + parsed.payload, + crate::lsp::workspace_edit_plan::plan_command, + ), + CoreCommand::LspClassifyNavigationTarget => plugin_response( + id, + parsed.payload, + crate::lsp::navigation_target::classify_command, + ), + CoreCommand::EditorPlanTextEdits => plugin_response( + id, + parsed.payload, + crate::editor::text_edit_plan::plan_command, + ), CoreCommand::EditorParseNavigationInput => plugin_response( id, parsed.payload, crate::editor::navigation_input::parse_command, ), + CoreCommand::EditorNavigationHistoryReduce => plugin_response( + id, + parsed.payload, + crate::editor::navigation_history::reduce_command, + ), CoreCommand::EditorLineEdit => { match serde_json::from_value::(parsed.payload) .map_err(|error| { @@ -1889,6 +1919,11 @@ fn execute_parsed(mut parsed: CoreRequest) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::EditorWorkbenchStateReduce => plugin_response( + id, + parsed.payload, + crate::editor::workbench_state::reduce_command, + ), CoreCommand::NetworkProxyPolicy => { match serde_json::from_value::( parsed.payload, @@ -1969,6 +2004,21 @@ fn execute_parsed(mut parsed: CoreRequest) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::LspMergeCompletions => plugin_response( + id, + parsed.payload, + crate::lsp::host_policy::completion_merge::merge_command, + ), + CoreCommand::LspActivationPolicy => plugin_response( + id, + parsed.payload, + crate::lsp::host_policy::activation::policy_command, + ), + CoreCommand::LspProblemsQuery => plugin_response( + id, + parsed.payload, + crate::lsp::host_policy::problems::query_command, + ), CoreCommand::LspStartServer => { match serde_json::from_value::(parsed.payload) .map_err(|error| { diff --git a/rust/lithe-core/src/tests/project.rs b/rust/lithe-core/src/tests/project.rs index e959ef53e1..b54f8c317b 100644 --- a/rust/lithe-core/src/tests/project.rs +++ b/rust/lithe-core/src/tests/project.rs @@ -1672,6 +1672,41 @@ fn document_lifecycle_matches_the_cross_platform_fixture() { } } +// Save All and save-before-run share these membership and completion rules on +// both platforms; the fixture pins the cases where macOS and Windows diverged. +#[test] +fn document_save_batch_matches_the_cross_platform_fixture() { + let fixture_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../shared/fixtures/documents/save-batch-v1.json"); + let fixture: Value = serde_json::from_str( + &fs::read_to_string(fixture_path).expect("save batch fixture should be readable"), + ) + .expect("save batch fixture should be valid JSON"); + assert_eq!( + fixture["maxDocuments"], + crate::project::document_save_batch::MAX_SAVE_BATCH_DOCUMENTS + ); + for (command, group) in [ + ("document.planSaveBatch", "plan"), + ("document.reduceSaveBatch", "reduce"), + ] { + for case in fixture[group].as_array().expect("cases should be an array") { + let request = serde_json::json!({"command": command, "payload": case["request"]}); + let response: Value = serde_json::from_str(&execute_json(&request.to_string())) + .expect("save batch response should be valid JSON"); + if let Some(code) = case.get("expectedError") { + assert_eq!(response["error"]["code"], *code, "{group} {}", case["name"]); + } else { + assert_eq!( + response["data"], case["expected"], + "{group} {}", + case["name"] + ); + } + } + } +} + #[test] fn search_everywhere_streams_names_before_content_index_and_preserves_the_final_response() { use std::sync::{Arc, Mutex}; diff --git a/scripts/architecture/language-specifics-baseline.json b/scripts/architecture/language-specifics-baseline.json index 900ae02613..18e8072719 100644 --- a/scripts/architecture/language-specifics-baseline.json +++ b/scripts/architecture/language-specifics-baseline.json @@ -413,7 +413,6 @@ "windows/tauri/src/features/editor/lsp/java-workspace-change-scheduler.ts": {"words":17}, "windows/tauri/src/features/editor/lsp/java-workspace-language-server.test.ts": {"words":27}, "windows/tauri/src/features/editor/lsp/java-workspace-language-server.ts": {"words":30}, - "windows/tauri/src/features/editor/lsp/language-server-failure-cooldown.test.ts": {"words":3}, "windows/tauri/src/features/editor/lsp/language-server-feedback-options.test.ts": {"words":7}, "windows/tauri/src/features/editor/lsp/language-server-navigation.test.ts": {"words":9}, "windows/tauri/src/features/editor/lsp/lsp-client-code-actions.test.ts": {"words":5}, diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index ae467d88b6..d78e6c7856 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -1150,8 +1150,13 @@ package manager owns the download and Lithe does not infer bytes from logs. | `community.discourse.categories` | List normalized visible categories | | `community.discourse.search` | Search normalized topics and sanitized posts | | `editor.lineEdit` | Apply a deterministic line-level text transform and return the replacement plus selection to restore | +| `lsp.planWorkspaceEdit` | Validate language-server workspace edit targets against the workspace and open documents, and order the commit | +| `lsp.classifyNavigationTarget` | Classify a language-server jump target as workspace, external read-only or rejected | +| `editor.planTextEdits` | Validate and order a plugin document edit batch into UTF-16 offsets with the document's line ending | | `editor.parseNavigationInput` | Parse a bounded line or line:column input into zero-based editor coordinates | +| `editor.navigationHistory.reduce` | Reduce one bounded back/forward navigation history event and return the next state plus the location to open | | `editor.lineCommentToken` | Resolve the line comment token for a file extension or language id | +| `editor.workbenchState.reduce` | Reduce editor tab group membership, split/collapse, selection and focus for one tab action | | `workspace.releaseIndexing` | Retire a policy and clear index/cache data at project close; accepts `root`, optional `ownerId`, and that owner's last observed `revision`, returns `released`; an old close must not retire a new generation | | `workspace.refreshIndexing` | Refresh external Git decisions for a live `root`, `ownerId`, `revision` lease without changing preferences or release tokens; returns lazy `IndexingStatus` | | `workspace.configureIndexing` | Install a process-local, canonical-root indexing policy before workspace consumers run; returns a monotonic revision, effective directory reasons and Git failure state; see `shared/fixtures/workspace/indexing-policy-v1.json` | @@ -1168,6 +1173,8 @@ package manager owns the download and Lithe does not infer bytes from logs. | `file.read` | Read a UTF-8 file using a workspace-relative path | | `file.write` | Write a UTF-8 file using a workspace-relative path | | `document.lifecycle` | Reduce a shared document save, external-change, or conflict event without reading text or disk | +| `document.planSaveBatch` | Select the open documents a Save All or save-before-launch batch writes | +| `document.reduceSaveBatch` | Reduce save batch member outcomes and live documents into one batch status | | `history.record` | Store a versioned text snapshot and metadata | | `history.entries` | List valid history entries for one file or a workspace | | `history.content` | Read a stored history snapshot by relative storage path | @@ -1221,6 +1228,9 @@ package manager owns the download and Lithe does not infer bytes from logs. | `lsp.builtinCompletions` | Return lightweight current-file identifier completions | | `lsp.builtinHover` | Return lightweight current-symbol hover text | | `lsp.builtinNavigation` | Return lightweight current-file definition/reference locations | +| `lsp.mergeCompletions` | Choose which candidates survive when several prioritized providers complete at one position | +| `lsp.activationPolicy` | Decide whether a language server may start automatically after failures, using a platform monotonic clock | +| `lsp.problems.query` | Filter, order, and group Problems tool-window diagnostics and count them by severity | | `lsp.documentUri` | Convert a native absolute path into a canonical file URI | | `lsp.startServer` | Start one Rust-owned process/session and begin initialization | | `lsp.jdtWorkspaceKey` | Derive the deterministic JDT LS workspace-state directory key | @@ -1601,6 +1611,22 @@ authorization. Conflict resolution acknowledges only the disk snapshot observed by the user, and subsequent saves must validate that snapshot again. The portable examples are in `shared/fixtures/documents/lifecycle-v1.json`. +`document.planSaveBatch` accepts `{ documents }`, where each open document is +`{ id, dirty, readOnly, contentSynchronized }`, and returns `{ members }` in +request order. A writable document takes part when it is dirty, or when +`contentSynchronized` is false because its editor view may still hold input +that the platform model has not received. `document.reduceSaveBatch` accepts +`{ members, results, documents }`: one `{ id, outcome }` result per member +(`saved`, `failed` or `cancelled`) and the live documents observed after every +write finished. It returns `{ status, savedCount, failed, cancelled, +remainingDirty }`. `status` is `cancelled`, `failed`, `incomplete` or `saved`, +in that precedence; any live dirty document, including one dirtied or opened +while the batch ran, keeps the batch `incomplete`. Platforms attempt every +member (sequentially or concurrently) instead of stopping at the first failure, +and own dialogs and failure notifications. Duplicate or empty identities, +missing or extra results, and more than 10,000 documents are `invalid_request`. +See `shared/fixtures/documents/save-batch-v1.json`. + GitHub command shapes, authorization behavior, and supported pull-request operations are documented in [`github.md`](github.md). Rust Core performs no network or credential I/O for these commands. @@ -2650,11 +2676,58 @@ line clamp before its line ending, and positions inside a UTF-16 surrogate pair resolve to the character start. Missing/negative lines, negative columns, reversed ranges and overlapping replacements are rejected. +The line index and batch rules are shared with `editor.planTextEdits`: `\r\n`, +`\n` and a lone `\r` each end one line (as in the LSP specification), and a +batch is applied in a stable order by start, end and then caller order. +Insertions at the same position keep the caller's order, and an insertion at +the boundary of a replaced range is adjacent regardless of where it appears in +the batch. + `lsp.plainSnippet` accepts `{ "value": string }` and returns `{ "text": string }` after removing LSP tab stops and replacing simple placeholder defaults such as `${1:name}` with `name`. +`editor.planTextEdits` accepts `{ text, edits, positions? }`. Each edit is +`{ range: { start, end }, text }` with one-based `line` and one-based UTF-16 +`column`; `positions` lists extra one-based positions that must exist (for +example reported selection endpoints). It returns `{ lineEnding, +lineSeparator, edits, rejection }`. `lineEnding` is `lf`, `crlf`, `cr` or +`mixed`; `lineSeparator` is the text's first terminator, LF when there is +none. On success `edits` is the stable application order with `index` (caller +order), `utf16Offset`, `utf16Length`, `text` (line breaks rewritten to +`lineSeparator`) and `normalizedText` (LF), and `rejection` is null. Unlike +`lsp.applyTextEdits`, positions are strict: a missing line, column zero, a +column past the line content or a position inside a surrogate pair rejects the +batch. A rejected batch returns empty `edits` and `rejection: { reason, +editIndex, otherEditIndex }` with `reason` `rangeOutOfBounds`, `reversed`, +`splitsSurrogatePair` or `overlappingRanges`, or `{ reason, positionIndex }` +for a failed extra position. An empty batch still reports the line ending. +Malformed payloads return `invalid_request`. The host keeps document versions, +undo grouping and the editor surface. See +`shared/fixtures/editor/text-edit-plan-v1.json`. + +`lsp.planWorkspaceEdit` accepts `{ platform, workspaceRoot, changes, +openDocuments? }`, where `changes` maps a file URI or absolute native path to +its text edits and `openDocuments` lists `{ path, isReadOnly? }` for open +editor documents. It returns `{ rejection, targets }`. Every target must be a +workspace file; otherwise `rejection` is `{ code: "outsideWorkspace" | +"readOnlyDocument", path }` and `targets` is empty. On success `rejection` is +null and each target is `{ path, relativePath, effect, edits }` with `effect` +`writeFile` (closed files first) or `updateDocument` (open documents), each +group ordered by `relativePath`; keys naming one file merge. The host reads and +writes files, owns buffers, stale-input checks and rollback, and applies each +target's edits with `lsp.applyTextEdits`. See +`shared/fixtures/lsp/workspace-edit-plan-v1.json`. + +`lsp.classifyNavigationTarget` accepts `{ platform, workspaceRoot?, +resolvedWorkspaceRoot?, path, resolvedPath?, sourceLanguageID?, languageID?, +resolvedLanguageID?, isReadOnly? }`; the host supplies symlink resolution and +the path-to-language lookup as facts. It returns `{ classification, +isReadOnly }` where `classification` is `workspace`, `externalReadOnly` (a +source file of the language that produced the jump, opened read-only) or +`rejected`. See `shared/fixtures/lsp/navigation-target-v1.json`. + `editor.parseNavigationInput` accepts `{ text }` and returns `{ target }`. `target` is null for invalid input, otherwise `{ line, column, selectsWholeLine }` contains zero-based line and UTF-16 column. One-based positive decimal line and @@ -2665,6 +2738,22 @@ selects its content without the terminator; an explicit column places the caret. Hosts clamp against the current document, not an old snapshot. See `shared/fixtures/editor/navigation-input-v1.json`. +`editor.navigationHistory.reduce` accepts `{ state, event }` and returns +`{ state, destination }`. `state` is `{ back, forward }`, each ordered oldest +first, and each location is `{ document, line, column, context? }`. Identity is +`document`, `line` and `column`; coordinates are opaque to Core and `context` is +platform data (pane, scroll, read-only presentation) carried unchanged. Events +are `record { departure, destination? }`, `back { current }` and +`forward { current }`. Recording pushes the departure unless it equals the +destination and clears the forward branch; a repeated position replaces the top +entry so the newest context wins. Back and forward discard entries equal to the +live `current` location, return the next distinct one as `destination`, and +push `current` onto the opposite stack so caret movement since the last jump +survives a return trip. Each stack keeps the newest 100 entries. Platforms +decide which gestures record history and restore their previous state when the +destination cannot be opened. See +`shared/fixtures/editor/navigation-history-v1.json`. + `editor.lineEdit` applies one deterministic line-level transform. All offsets are UTF-16 code units so both hosts can feed the result straight into their text engines. The payload is `{ "operation": "toggleLineComment" | @@ -2692,6 +2781,38 @@ to adopt the same contract in a follow-up change; the fixture pins the canonical behavior for that migration. Deterministic cases are pinned by `shared/fixtures/editor/line-edit-v1.json`. +`editor.workbenchState.reduce` accepts `{ "state": WorkbenchState, "action": +Action }` and returns `{ "applied": boolean, "state": WorkbenchState }`. +`WorkbenchState` is `{ "layout": Layout, "tabs": [{ "id", "group" }], +"selections": [{ "group", "tab" }], "focusedGroup": string | null }` where +`Layout` is `{ "group": id }` or `{ "split": { "id", "orientation": +"horizontal" | "vertical", "first": Layout, "second": Layout } }` +(`horizontal` places children side by side, `vertical` stacks them). `tabs` +is the host's presentation order; a group presents its tabs in that relative +order. All IDs are opaque host strings. Actions are `sync { tabs }` (the new +set and order of open tabs), `drop { tab, group, side, newGroup?, newSplit? }`, +`canDrop { tab, group, side }`, `insert { tab, group | null, side, newGroup?, +newSplit? }`, `move { tab, target, position: "before" | "after" }` and +`focus { tab }`, with `side` one of `center`, `left`, `right`, `top` and +`bottom`. Edge sides require host-generated `newGroup` and `newSplit` IDs that +are not in the layout. After every action Core removes duplicate tabs (first +wins), places tabs without a valid group in the focused group (else the first +group), removes empty groups and collapses their splits (keeping one empty +group when nothing is open), moves a vanished focus to the first remaining +group, and keeps each group's selected tab while it stays in the group, +otherwise selecting the tab that now occupies the former selection's index +(or the new last tab). `drop` and `insert` then focus the tab. Rejected or +no-op actions, and `canDrop`, return `applied: false` (`canDrop` returns +`true` when the matching drop would apply) with the normalized input state. +Malformed payloads, layouts that repeat a group or split ID, more than 10,000 +tabs, and edge sides without fresh identities return `invalid_request`. Split +ratios, pixel geometry and drag hit testing stay in the host. macOS consumes +the command; Windows still keeps its pane tree in TypeScript +(`windows/tauri/src/features/panes/utils/pane-tree.ts`), whose synchronous +store, buffer sharing across panes, preview, pinned and locked panes and the +bottom pane tree must move together in a follow-up. Cases are pinned by +`shared/fixtures/editor/workbench-state-v1.json`. + `network.proxyPolicy` normalizes the stored proxy settings into the policy a network stack must apply. It accepts `{ "mode": "system" | "direct" | "custom", "proxyType"?: "http" | "socks5", "host"?: string, "port"?: number, @@ -3088,6 +3209,41 @@ all matching identifier occurrences. These commands are deliberately text-level fallbacks; precise type-aware behavior belongs to a started language server. +`lsp.mergeCompletions` accepts `{ lists: [{ labels, isIncomplete }] }` ordered +from the highest-priority provider to the lowest and returns +`{ keep: [[index]], isIncomplete }`. A provider's own candidates are never +collapsed, so overloads that share a label stay visible; a lower-priority +candidate is dropped when a higher-priority provider already offered the same +label. Only labels cross the boundary and the platform applies the indices to +its full items. A host with a single completion provider (Windows today) keeps +that provider's list unchanged and does not call the command. Cases: +`shared/fixtures/lsp/completion-merge-v1.json`. + +`lsp.activationPolicy` accepts `{ state, event, nowMs }` and returns +`{ state, decision, retryAfterMs }`. `event` is `automaticStart`, +`explicitStart`, `startFailed`, or `ready`; `decision` is `allow`, `wait`, or +`explicitRetryRequired`. The platform stores the returned `state` per server +identity (language or provider plus workspace root) and passes it back +verbatim; `null` means no failure history. After each failure automatic starts +wait 5000 ms, and after 3 consecutive failures only an explicit start (restart, +retry, or repair) may run. A ready session or an explicit start clears the +history. `nowMs` must come from a monotonic clock; a reading earlier than the +stored failure restarts the cooldown. Cases: +`shared/fixtures/lsp/activation-policy-v1.json`. + +`lsp.problems.query` accepts the displayed diagnostics as +`{ fileKey, displayPath, line, column, severity, message, source, code }` +together with `groupBy` (`file` default, `severity`, `none`), `sortBy` +(`severity` default, `file`, `position`), and an optional `filter` +(`severities`, `fileKey`, `source`, `query`). It returns `groups` (each with a +stable `key`, `indices` into the submitted list, and per-severity `counts`), +`files` (file summaries in file order regardless of `groupBy`), +`visibleCounts`, and `totalCounts`. `fileKey` is the platform's native file +identity; `displayPath` is the workspace-relative path and orders files with +the Project view's natural file-name order. Severity is the LSP number; +missing or out-of-range values are unknown (`0`) and rank last. Cases: +`shared/fixtures/lsp/problems-query-v1.json`. + The LSP provider catalog is returned by `lithe_core_lsp_provider_catalog_json`. Each provider descriptor may include `languageServerLaunch` with ordered `executableNames` and `arguments`; Swift adapters use this metadata when they diff --git a/shared/fixtures/documents/save-batch-v1.json b/shared/fixtures/documents/save-batch-v1.json new file mode 100644 index 0000000000..3a21eca8ed --- /dev/null +++ b/shared/fixtures/documents/save-batch-v1.json @@ -0,0 +1,410 @@ +{ + "version": 1, + "maxDocuments": 10000, + "plan": [ + { + "name": "writable-dirty-documents-in-open-order", + "request": { + "documents": [ + { + "id": "b", + "dirty": true, + "readOnly": false, + "contentSynchronized": true + }, + { + "id": "a", + "dirty": false, + "readOnly": false, + "contentSynchronized": true + }, + { + "id": "c", + "dirty": true, + "readOnly": false, + "contentSynchronized": true + } + ] + }, + "expected": { + "members": [ + "b", + "c" + ] + } + }, + { + "name": "read-only-documents-never-take-part", + "request": { + "documents": [ + { + "id": "a", + "dirty": true, + "readOnly": true, + "contentSynchronized": true + }, + { + "id": "b", + "dirty": false, + "readOnly": true, + "contentSynchronized": false + } + ] + }, + "expected": { + "members": [] + } + }, + { + "name": "clean-model-with-unsynchronized-editor-input-takes-part", + "request": { + "documents": [ + { + "id": "a", + "dirty": false, + "readOnly": false, + "contentSynchronized": false + }, + { + "id": "b", + "dirty": false, + "readOnly": false, + "contentSynchronized": true + } + ] + }, + "expected": { + "members": [ + "a" + ] + } + }, + { + "name": "duplicate-identity-is-rejected", + "request": { + "documents": [ + { + "id": "a", + "dirty": true, + "readOnly": false, + "contentSynchronized": true + }, + { + "id": "a", + "dirty": false, + "readOnly": false, + "contentSynchronized": true + } + ] + }, + "expectedError": "invalid_request" + }, + { + "name": "empty-identity-is-rejected", + "request": { + "documents": [ + { + "id": "", + "dirty": true, + "readOnly": false, + "contentSynchronized": true + } + ] + }, + "expectedError": "invalid_request" + } + ], + "reduce": [ + { + "name": "every-member-saved", + "request": { + "members": [ + "a", + "b" + ], + "results": [ + { + "id": "b", + "outcome": "saved" + }, + { + "id": "a", + "outcome": "saved" + } + ], + "documents": [ + { + "id": "a", + "dirty": false, + "readOnly": false, + "contentSynchronized": true + }, + { + "id": "b", + "dirty": false, + "readOnly": false, + "contentSynchronized": true + } + ] + }, + "expected": { + "status": "saved", + "savedCount": 2, + "failed": [], + "cancelled": [], + "remainingDirty": [] + } + }, + { + "name": "a-failure-does-not-hide-later-successes", + "request": { + "members": [ + "a", + "b", + "c" + ], + "results": [ + { + "id": "a", + "outcome": "saved" + }, + { + "id": "b", + "outcome": "failed" + }, + { + "id": "c", + "outcome": "saved" + } + ], + "documents": [ + { + "id": "a", + "dirty": false, + "readOnly": false, + "contentSynchronized": true + }, + { + "id": "b", + "dirty": true, + "readOnly": false, + "contentSynchronized": true + }, + { + "id": "c", + "dirty": false, + "readOnly": false, + "contentSynchronized": true + } + ] + }, + "expected": { + "status": "failed", + "savedCount": 2, + "failed": [ + "b" + ], + "cancelled": [], + "remainingDirty": [ + "b" + ] + } + }, + { + "name": "member-edited-while-saving-stays-incomplete", + "request": { + "members": [ + "a" + ], + "results": [ + { + "id": "a", + "outcome": "saved" + } + ], + "documents": [ + { + "id": "a", + "dirty": true, + "readOnly": false, + "contentSynchronized": true + } + ] + }, + "expected": { + "status": "incomplete", + "savedCount": 0, + "failed": [], + "cancelled": [], + "remainingDirty": [ + "a" + ] + } + }, + { + "name": "document-dirtied-during-the-batch-stays-incomplete", + "request": { + "members": [ + "a" + ], + "results": [ + { + "id": "a", + "outcome": "saved" + } + ], + "documents": [ + { + "id": "a", + "dirty": false, + "readOnly": false, + "contentSynchronized": true + }, + { + "id": "new", + "dirty": true, + "readOnly": false, + "contentSynchronized": true + } + ] + }, + "expected": { + "status": "incomplete", + "savedCount": 1, + "failed": [], + "cancelled": [], + "remainingDirty": [ + "new" + ] + } + }, + { + "name": "dirty-read-only-document-keeps-the-batch-incomplete", + "request": { + "members": [], + "results": [], + "documents": [ + { + "id": "a", + "dirty": true, + "readOnly": true, + "contentSynchronized": true + } + ] + }, + "expected": { + "status": "incomplete", + "savedCount": 0, + "failed": [], + "cancelled": [], + "remainingDirty": [ + "a" + ] + } + }, + { + "name": "cancellation-takes-precedence-over-failure", + "request": { + "members": [ + "a", + "b" + ], + "results": [ + { + "id": "a", + "outcome": "failed" + }, + { + "id": "b", + "outcome": "cancelled" + } + ], + "documents": [ + { + "id": "a", + "dirty": true, + "readOnly": false, + "contentSynchronized": true + }, + { + "id": "b", + "dirty": true, + "readOnly": false, + "contentSynchronized": true + } + ] + }, + "expected": { + "status": "cancelled", + "savedCount": 0, + "failed": [ + "a" + ], + "cancelled": [ + "b" + ], + "remainingDirty": [ + "a", + "b" + ] + } + }, + { + "name": "missing-member-result-is-rejected", + "request": { + "members": [ + "a", + "b" + ], + "results": [ + { + "id": "a", + "outcome": "saved" + } + ], + "documents": [] + }, + "expectedError": "invalid_request" + }, + { + "name": "result-for-a-non-member-is-rejected", + "request": { + "members": [ + "a" + ], + "results": [ + { + "id": "a", + "outcome": "saved" + }, + { + "id": "x", + "outcome": "saved" + } + ], + "documents": [] + }, + "expectedError": "invalid_request" + }, + { + "name": "repeated-member-result-is-rejected", + "request": { + "members": [ + "a" + ], + "results": [ + { + "id": "a", + "outcome": "saved" + }, + { + "id": "a", + "outcome": "failed" + } + ], + "documents": [] + }, + "expectedError": "invalid_request" + } + ] +} diff --git a/shared/fixtures/editor/navigation-history-v1.json b/shared/fixtures/editor/navigation-history-v1.json new file mode 100644 index 0000000000..74065a0863 --- /dev/null +++ b/shared/fixtures/editor/navigation-history-v1.json @@ -0,0 +1,531 @@ +{ + "version": 1, + "maxEntries": 100, + "cases": [ + { + "name": "record-pushes-departure-and-clears-forward", + "request": { + "state": { + "back": [], + "forward": [ + { + "document": "file:///w/C.java", + "line": 1, + "column": 0 + } + ] + }, + "event": { + "type": "record", + "departure": { + "document": "file:///w/A.java", + "line": 4, + "column": 2 + }, + "destination": { + "document": "file:///w/B.java", + "line": 9, + "column": 7 + } + } + }, + "expected": { + "state": { + "back": [ + { + "document": "file:///w/A.java", + "line": 4, + "column": 2 + } + ], + "forward": [] + }, + "destination": null + } + }, + { + "name": "record-to-the-same-position-keeps-history", + "request": { + "state": { + "back": [], + "forward": [ + { + "document": "file:///w/C.java", + "line": 1, + "column": 0 + } + ] + }, + "event": { + "type": "record", + "departure": { + "document": "file:///w/A.java", + "line": 4, + "column": 2 + }, + "destination": { + "document": "file:///w/A.java", + "line": 4, + "column": 2, + "context": { + "pane": "right" + } + } + } + }, + "expected": { + "state": { + "back": [], + "forward": [ + { + "document": "file:///w/C.java", + "line": 1, + "column": 0 + } + ] + }, + "destination": null + } + }, + { + "name": "record-without-destination-still-records", + "request": { + "state": { + "back": [ + { + "document": "file:///w/A.java", + "line": 4, + "column": 2 + } + ], + "forward": [] + }, + "event": { + "type": "record", + "departure": { + "document": "file:///w/B.java", + "line": 9, + "column": 7 + }, + "destination": null + } + }, + "expected": { + "state": { + "back": [ + { + "document": "file:///w/A.java", + "line": 4, + "column": 2 + }, + { + "document": "file:///w/B.java", + "line": 9, + "column": 7 + } + ], + "forward": [] + }, + "destination": null + } + }, + { + "name": "record-without-departure-is-ignored", + "request": { + "state": { + "back": [ + { + "document": "file:///w/A.java", + "line": 4, + "column": 2 + } + ], + "forward": [ + { + "document": "file:///w/C.java", + "line": 1, + "column": 0 + } + ] + }, + "event": { + "type": "record", + "departure": null, + "destination": { + "document": "file:///w/B.java", + "line": 9, + "column": 7 + } + } + }, + "expected": { + "state": { + "back": [ + { + "document": "file:///w/A.java", + "line": 4, + "column": 2 + } + ], + "forward": [ + { + "document": "file:///w/C.java", + "line": 1, + "column": 0 + } + ] + }, + "destination": null + } + }, + { + "name": "record-deduplicates-by-position-and-keeps-newest-context", + "request": { + "state": { + "back": [ + { + "document": "file:///w/A.java", + "line": 4, + "column": 2, + "context": { + "scrollTop": 10 + } + } + ], + "forward": [] + }, + "event": { + "type": "record", + "departure": { + "document": "file:///w/A.java", + "line": 4, + "column": 2, + "context": { + "scrollTop": 80 + } + }, + "destination": { + "document": "file:///w/B.java", + "line": 9, + "column": 7 + } + } + }, + "expected": { + "state": { + "back": [ + { + "document": "file:///w/A.java", + "line": 4, + "column": 2, + "context": { + "scrollTop": 80 + } + } + ], + "forward": [] + }, + "destination": null + } + }, + { + "name": "back-records-the-live-caret-for-forward", + "request": { + "state": { + "back": [ + { + "document": "file:///w/A.java", + "line": 4, + "column": 2 + } + ], + "forward": [] + }, + "event": { + "type": "back", + "current": { + "document": "file:///w/B.java", + "line": 14, + "column": 3 + } + } + }, + "expected": { + "state": { + "back": [], + "forward": [ + { + "document": "file:///w/B.java", + "line": 14, + "column": 3 + } + ] + }, + "destination": { + "document": "file:///w/A.java", + "line": 4, + "column": 2 + } + } + }, + { + "name": "forward-records-the-live-caret-for-back", + "request": { + "state": { + "back": [], + "forward": [ + { + "document": "file:///w/B.java", + "line": 14, + "column": 3 + } + ] + }, + "event": { + "type": "forward", + "current": { + "document": "file:///w/A.java", + "line": 6, + "column": 0 + } + } + }, + "expected": { + "state": { + "back": [ + { + "document": "file:///w/A.java", + "line": 6, + "column": 0 + } + ], + "forward": [] + }, + "destination": { + "document": "file:///w/B.java", + "line": 14, + "column": 3 + } + } + }, + { + "name": "back-skips-entries-equal-to-the-current-location", + "request": { + "state": { + "back": [ + { + "document": "file:///w/C.java", + "line": 1, + "column": 0 + }, + { + "document": "file:///w/A.java", + "line": 4, + "column": 2 + } + ], + "forward": [] + }, + "event": { + "type": "back", + "current": { + "document": "file:///w/A.java", + "line": 4, + "column": 2 + } + } + }, + "expected": { + "state": { + "back": [], + "forward": [ + { + "document": "file:///w/A.java", + "line": 4, + "column": 2 + } + ] + }, + "destination": { + "document": "file:///w/C.java", + "line": 1, + "column": 0 + } + } + }, + { + "name": "back-only-at-current-location-drops-the-no-op-entry", + "request": { + "state": { + "back": [ + { + "document": "file:///w/A.java", + "line": 4, + "column": 2 + } + ], + "forward": [ + { + "document": "file:///w/C.java", + "line": 1, + "column": 0 + } + ] + }, + "event": { + "type": "back", + "current": { + "document": "file:///w/A.java", + "line": 4, + "column": 2 + } + } + }, + "expected": { + "state": { + "back": [], + "forward": [ + { + "document": "file:///w/C.java", + "line": 1, + "column": 0 + } + ] + }, + "destination": null + } + }, + { + "name": "back-with-empty-history-does-not-move", + "request": { + "state": { + "back": [], + "forward": [ + { + "document": "file:///w/C.java", + "line": 1, + "column": 0 + } + ] + }, + "event": { + "type": "back", + "current": { + "document": "file:///w/A.java", + "line": 4, + "column": 2 + } + } + }, + "expected": { + "state": { + "back": [], + "forward": [ + { + "document": "file:///w/C.java", + "line": 1, + "column": 0 + } + ] + }, + "destination": null + } + }, + { + "name": "back-without-a-current-editor-location", + "request": { + "state": { + "back": [ + { + "document": "file:///w/A.java", + "line": 4, + "column": 2 + } + ], + "forward": [] + }, + "event": { + "type": "back", + "current": null + } + }, + "expected": { + "state": { + "back": [], + "forward": [] + }, + "destination": { + "document": "file:///w/A.java", + "line": 4, + "column": 2 + } + } + }, + { + "name": "forward-pushes-current-without-duplicating-the-back-top", + "request": { + "state": { + "back": [ + { + "document": "file:///w/A.java", + "line": 4, + "column": 2 + } + ], + "forward": [ + { + "document": "file:///w/C.java", + "line": 1, + "column": 0 + } + ] + }, + "event": { + "type": "forward", + "current": { + "document": "file:///w/A.java", + "line": 4, + "column": 2 + } + } + }, + "expected": { + "state": { + "back": [ + { + "document": "file:///w/A.java", + "line": 4, + "column": 2 + } + ], + "forward": [] + }, + "destination": { + "document": "file:///w/C.java", + "line": 1, + "column": 0 + } + } + }, + { + "name": "empty-document-identity-is-rejected", + "request": { + "state": { + "back": [ + { + "document": "", + "line": 1, + "column": 0 + } + ], + "forward": [] + }, + "event": { + "type": "back", + "current": null + } + }, + "expectedError": "invalid_request" + } + ] +} diff --git a/shared/fixtures/editor/text-edit-plan-v1.json b/shared/fixtures/editor/text-edit-plan-v1.json new file mode 100644 index 0000000000..dbe9d29181 --- /dev/null +++ b/shared/fixtures/editor/text-edit-plan-v1.json @@ -0,0 +1,841 @@ +{ + "version": 1, + "command": "editor.planTextEdits", + "cases": [ + { + "name": "emoji columns count UTF-16 code units", + "text": "a😀b", + "edits": [ + { + "range": { + "start": { + "line": 1, + "column": 2 + }, + "end": { + "line": 1, + "column": 4 + } + }, + "text": "X" + } + ], + "expected": { + "lineEnding": "lf", + "lineSeparator": "\n", + "edits": [ + { + "index": 0, + "utf16Offset": 1, + "utf16Length": 2, + "text": "X", + "normalizedText": "X" + } + ], + "rejection": null + }, + "expectedText": "aXb" + }, + { + "name": "position inside a surrogate pair is rejected", + "text": "a😀b", + "edits": [ + { + "range": { + "start": { + "line": 1, + "column": 3 + }, + "end": { + "line": 1, + "column": 4 + } + }, + "text": "" + } + ], + "expected": { + "lineEnding": "lf", + "lineSeparator": "\n", + "edits": [], + "rejection": { + "reason": "splitsSurrogatePair", + "editIndex": 0, + "otherEditIndex": null + } + } + }, + { + "name": "combining mark is its own code unit", + "text": "éx", + "edits": [ + { + "range": { + "start": { + "line": 1, + "column": 2 + }, + "end": { + "line": 1, + "column": 3 + } + }, + "text": "" + } + ], + "expected": { + "lineEnding": "lf", + "lineSeparator": "\n", + "edits": [ + { + "index": 0, + "utf16Offset": 1, + "utf16Length": 1, + "text": "", + "normalizedText": "" + } + ], + "rejection": null + }, + "expectedText": "ex" + }, + { + "name": "CRLF document writes inserted breaks as CRLF", + "text": "a\r\nb", + "edits": [ + { + "range": { + "start": { + "line": 2, + "column": 1 + }, + "end": { + "line": 2, + "column": 1 + } + }, + "text": "x\ny\r\n" + } + ], + "expected": { + "lineEnding": "crlf", + "lineSeparator": "\r\n", + "edits": [ + { + "index": 0, + "utf16Offset": 3, + "utf16Length": 0, + "text": "x\r\ny\r\n", + "normalizedText": "x\ny\n" + } + ], + "rejection": null + }, + "expectedText": "a\r\nx\r\ny\r\nb" + }, + { + "name": "lone CR terminates a line", + "text": "a\rb", + "edits": [ + { + "range": { + "start": { + "line": 2, + "column": 1 + }, + "end": { + "line": 2, + "column": 2 + } + }, + "text": "B" + } + ], + "expected": { + "lineEnding": "cr", + "lineSeparator": "\r", + "edits": [ + { + "index": 0, + "utf16Offset": 2, + "utf16Length": 1, + "text": "B", + "normalizedText": "B" + } + ], + "rejection": null + }, + "expectedText": "a\rB" + }, + { + "name": "mixed endings insert the first separator", + "text": "a\nb\r\nc", + "edits": [ + { + "range": { + "start": { + "line": 3, + "column": 1 + }, + "end": { + "line": 3, + "column": 1 + } + }, + "text": "1\r2" + } + ], + "expected": { + "lineEnding": "mixed", + "lineSeparator": "\n", + "edits": [ + { + "index": 0, + "utf16Offset": 5, + "utf16Length": 0, + "text": "1\n2", + "normalizedText": "1\n2" + } + ], + "rejection": null + }, + "expectedText": "a\nb\r\n1\n2c" + }, + { + "name": "column between CR and LF is out of bounds", + "text": "a\r\nb", + "edits": [ + { + "range": { + "start": { + "line": 1, + "column": 3 + }, + "end": { + "line": 1, + "column": 3 + } + }, + "text": "x" + } + ], + "expected": { + "lineEnding": "crlf", + "lineSeparator": "\r\n", + "edits": [], + "rejection": { + "reason": "rangeOutOfBounds", + "editIndex": 0, + "otherEditIndex": null + } + } + }, + { + "name": "end of line and end of document are valid", + "text": "ab\n", + "edits": [ + { + "range": { + "start": { + "line": 1, + "column": 3 + }, + "end": { + "line": 1, + "column": 3 + } + }, + "text": "!" + }, + { + "range": { + "start": { + "line": 2, + "column": 1 + }, + "end": { + "line": 2, + "column": 1 + } + }, + "text": "?" + } + ], + "expected": { + "lineEnding": "lf", + "lineSeparator": "\n", + "edits": [ + { + "index": 0, + "utf16Offset": 2, + "utf16Length": 0, + "text": "!", + "normalizedText": "!" + }, + { + "index": 1, + "utf16Offset": 3, + "utf16Length": 0, + "text": "?", + "normalizedText": "?" + } + ], + "rejection": null + }, + "expectedText": "ab!\n?" + }, + { + "name": "line zero is out of bounds", + "text": "ab\ncd", + "edits": [ + { + "range": { + "start": { + "line": 1, + "column": 1 + }, + "end": { + "line": 1, + "column": 1 + } + }, + "text": "" + }, + { + "range": { + "start": { + "line": 0, + "column": 1 + }, + "end": { + "line": 0, + "column": 1 + } + }, + "text": "x" + } + ], + "expected": { + "lineEnding": "lf", + "lineSeparator": "\n", + "edits": [], + "rejection": { + "reason": "rangeOutOfBounds", + "editIndex": 1, + "otherEditIndex": null + } + } + }, + { + "name": "missing line is out of bounds", + "text": "ab\ncd", + "edits": [ + { + "range": { + "start": { + "line": 1, + "column": 1 + }, + "end": { + "line": 1, + "column": 1 + } + }, + "text": "" + }, + { + "range": { + "start": { + "line": 3, + "column": 1 + }, + "end": { + "line": 3, + "column": 1 + } + }, + "text": "x" + } + ], + "expected": { + "lineEnding": "lf", + "lineSeparator": "\n", + "edits": [], + "rejection": { + "reason": "rangeOutOfBounds", + "editIndex": 1, + "otherEditIndex": null + } + } + }, + { + "name": "column zero is out of bounds", + "text": "ab\ncd", + "edits": [ + { + "range": { + "start": { + "line": 1, + "column": 1 + }, + "end": { + "line": 1, + "column": 1 + } + }, + "text": "" + }, + { + "range": { + "start": { + "line": 1, + "column": 0 + }, + "end": { + "line": 1, + "column": 0 + } + }, + "text": "x" + } + ], + "expected": { + "lineEnding": "lf", + "lineSeparator": "\n", + "edits": [], + "rejection": { + "reason": "rangeOutOfBounds", + "editIndex": 1, + "otherEditIndex": null + } + } + }, + { + "name": "column past line end is out of bounds", + "text": "ab\ncd", + "edits": [ + { + "range": { + "start": { + "line": 1, + "column": 1 + }, + "end": { + "line": 1, + "column": 1 + } + }, + "text": "" + }, + { + "range": { + "start": { + "line": 1, + "column": 4 + }, + "end": { + "line": 1, + "column": 4 + } + }, + "text": "x" + } + ], + "expected": { + "lineEnding": "lf", + "lineSeparator": "\n", + "edits": [], + "rejection": { + "reason": "rangeOutOfBounds", + "editIndex": 1, + "otherEditIndex": null + } + } + }, + { + "name": "reversed range", + "text": "abc", + "edits": [ + { + "range": { + "start": { + "line": 1, + "column": 3 + }, + "end": { + "line": 1, + "column": 1 + } + }, + "text": "" + } + ], + "expected": { + "lineEnding": "lf", + "lineSeparator": "\n", + "edits": [], + "rejection": { + "reason": "reversed", + "editIndex": 0, + "otherEditIndex": null + } + } + }, + { + "name": "overlap reports caller indices", + "text": "abcdef", + "edits": [ + { + "range": { + "start": { + "line": 1, + "column": 3 + }, + "end": { + "line": 1, + "column": 5 + } + }, + "text": "" + }, + { + "range": { + "start": { + "line": 1, + "column": 1 + }, + "end": { + "line": 1, + "column": 4 + } + }, + "text": "" + } + ], + "expected": { + "lineEnding": "lf", + "lineSeparator": "\n", + "edits": [], + "rejection": { + "reason": "overlappingRanges", + "editIndex": 0, + "otherEditIndex": 1 + } + } + }, + { + "name": "insertion strictly inside a replacement overlaps", + "text": "abcdef", + "edits": [ + { + "range": { + "start": { + "line": 1, + "column": 1 + }, + "end": { + "line": 1, + "column": 4 + } + }, + "text": "" + }, + { + "range": { + "start": { + "line": 1, + "column": 2 + }, + "end": { + "line": 1, + "column": 2 + } + }, + "text": "x" + } + ], + "expected": { + "lineEnding": "lf", + "lineSeparator": "\n", + "edits": [], + "rejection": { + "reason": "overlappingRanges", + "editIndex": 0, + "otherEditIndex": 1 + } + } + }, + { + "name": "adjacent ranges are applied in stable order", + "text": "ab", + "edits": [ + { + "range": { + "start": { + "line": 1, + "column": 2 + }, + "end": { + "line": 1, + "column": 3 + } + }, + "text": "B" + }, + { + "range": { + "start": { + "line": 1, + "column": 1 + }, + "end": { + "line": 1, + "column": 2 + } + }, + "text": "A" + } + ], + "expected": { + "lineEnding": "lf", + "lineSeparator": "\n", + "edits": [ + { + "index": 1, + "utf16Offset": 0, + "utf16Length": 1, + "text": "A", + "normalizedText": "A" + }, + { + "index": 0, + "utf16Offset": 1, + "utf16Length": 1, + "text": "B", + "normalizedText": "B" + } + ], + "rejection": null + }, + "expectedText": "AB" + }, + { + "name": "same-position inserts keep caller order", + "text": "z", + "edits": [ + { + "range": { + "start": { + "line": 1, + "column": 1 + }, + "end": { + "line": 1, + "column": 1 + } + }, + "text": "x" + }, + { + "range": { + "start": { + "line": 1, + "column": 1 + }, + "end": { + "line": 1, + "column": 1 + } + }, + "text": "y" + } + ], + "expected": { + "lineEnding": "lf", + "lineSeparator": "\n", + "edits": [ + { + "index": 0, + "utf16Offset": 0, + "utf16Length": 0, + "text": "x", + "normalizedText": "x" + }, + { + "index": 1, + "utf16Offset": 0, + "utf16Length": 0, + "text": "y", + "normalizedText": "y" + } + ], + "rejection": null + }, + "expectedText": "xyz" + }, + { + "name": "insert at a replacement start is adjacent regardless of batch order", + "text": "abcd", + "edits": [ + { + "range": { + "start": { + "line": 1, + "column": 2 + }, + "end": { + "line": 1, + "column": 4 + } + }, + "text": "R" + }, + { + "range": { + "start": { + "line": 1, + "column": 2 + }, + "end": { + "line": 1, + "column": 2 + } + }, + "text": "I" + } + ], + "expected": { + "lineEnding": "lf", + "lineSeparator": "\n", + "edits": [ + { + "index": 1, + "utf16Offset": 1, + "utf16Length": 0, + "text": "I", + "normalizedText": "I" + }, + { + "index": 0, + "utf16Offset": 1, + "utf16Length": 2, + "text": "R", + "normalizedText": "R" + } + ], + "rejection": null + }, + "expectedText": "aIRd" + }, + { + "name": "multi-line edits in a multi-selection batch", + "text": "one\ntwo\nthree", + "edits": [ + { + "range": { + "start": { + "line": 3, + "column": 1 + }, + "end": { + "line": 3, + "column": 6 + } + }, + "text": "3" + }, + { + "range": { + "start": { + "line": 1, + "column": 1 + }, + "end": { + "line": 1, + "column": 4 + } + }, + "text": "1" + }, + { + "range": { + "start": { + "line": 1, + "column": 4 + }, + "end": { + "line": 2, + "column": 4 + } + }, + "text": "" + } + ], + "expected": { + "lineEnding": "lf", + "lineSeparator": "\n", + "edits": [ + { + "index": 1, + "utf16Offset": 0, + "utf16Length": 3, + "text": "1", + "normalizedText": "1" + }, + { + "index": 2, + "utf16Offset": 3, + "utf16Length": 4, + "text": "", + "normalizedText": "" + }, + { + "index": 0, + "utf16Offset": 8, + "utf16Length": 5, + "text": "3", + "normalizedText": "3" + } + ], + "rejection": null + }, + "expectedText": "1\n3" + }, + { + "name": "empty batch reports the line ending", + "text": "x\r\ny", + "edits": [], + "expected": { + "lineEnding": "crlf", + "lineSeparator": "\r\n", + "edits": [], + "rejection": null + }, + "expectedText": "x\r\ny" + }, + { + "name": "extra positions must exist", + "text": "ab\ncd", + "edits": [], + "positions": [ + { + "line": 2, + "column": 3 + }, + { + "line": 2, + "column": 4 + } + ], + "expected": { + "lineEnding": "lf", + "lineSeparator": "\n", + "edits": [], + "rejection": { + "reason": "rangeOutOfBounds", + "positionIndex": 1 + } + } + } + ] +} diff --git a/shared/fixtures/editor/workbench-state-v1.json b/shared/fixtures/editor/workbench-state-v1.json new file mode 100644 index 0000000000..f807498c6f --- /dev/null +++ b/shared/fixtures/editor/workbench-state-v1.json @@ -0,0 +1,2429 @@ +{ + "description": "Editor tab group membership, selection, split and collapse rules owned by Rust Core (editor.workbenchState.reduce). Orientation horizontal places children side by side; vertical stacks them. Sizes and drag geometry stay in the platform.", + "cases": [ + { + "name": "sync removes duplicate members and keeps the first occurrence", + "state": { + "layout": { + "group": "g1" + }, + "tabs": [ + { + "id": "a", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + } + ], + "focusedGroup": "g1" + }, + "action": { + "type": "sync", + "tabs": [ + "a", + "b", + "a" + ] + }, + "applied": true, + "expected": { + "layout": { + "group": "g1" + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + } + ], + "focusedGroup": "g1" + } + }, + { + "name": "sync places new and orphaned members in the focused group", + "state": { + "layout": { + "split": { + "id": "s1", + "orientation": "horizontal", + "first": { + "group": "g1" + }, + "second": { + "group": "g2" + } + } + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g2" + }, + { + "id": "x", + "group": "gone" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + }, + { + "group": "g2", + "tab": "b" + } + ], + "focusedGroup": "g2" + }, + "action": { + "type": "sync", + "tabs": [ + "a", + "b", + "x", + "n" + ] + }, + "applied": true, + "expected": { + "layout": { + "split": { + "id": "s1", + "orientation": "horizontal", + "first": { + "group": "g1" + }, + "second": { + "group": "g2" + } + } + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g2" + }, + { + "id": "x", + "group": "g2" + }, + { + "id": "n", + "group": "g2" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + }, + { + "group": "g2", + "tab": "b" + } + ], + "focusedGroup": "g2" + } + }, + { + "name": "sync without a focused group places new members in the first group", + "state": { + "layout": { + "group": "g1" + }, + "tabs": [], + "selections": [ + { + "group": "g1", + "tab": null + } + ], + "focusedGroup": null + }, + "action": { + "type": "sync", + "tabs": [ + "a", + "b" + ] + }, + "applied": true, + "expected": { + "layout": { + "group": "g1" + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + } + ], + "focusedGroup": null + } + }, + { + "name": "closing the selected tab selects the tab that takes its place", + "state": { + "layout": { + "group": "g1" + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g1" + }, + { + "id": "c", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "b" + } + ], + "focusedGroup": "g1" + }, + "action": { + "type": "sync", + "tabs": [ + "a", + "c" + ] + }, + "applied": true, + "expected": { + "layout": { + "group": "g1" + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "c", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "c" + } + ], + "focusedGroup": "g1" + } + }, + { + "name": "closing the selected last tab selects the new last tab", + "state": { + "layout": { + "group": "g1" + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g1" + }, + { + "id": "c", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "c" + } + ], + "focusedGroup": "g1" + }, + "action": { + "type": "sync", + "tabs": [ + "a", + "b" + ] + }, + "applied": true, + "expected": { + "layout": { + "group": "g1" + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "b" + } + ], + "focusedGroup": "g1" + } + }, + { + "name": "closing the last tab of a focused split group collapses the split and focuses the first remaining group", + "state": { + "layout": { + "split": { + "id": "s1", + "orientation": "horizontal", + "first": { + "group": "g1" + }, + "second": { + "group": "g2" + } + } + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g2" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + }, + { + "group": "g2", + "tab": "b" + } + ], + "focusedGroup": "g2" + }, + "action": { + "type": "sync", + "tabs": [ + "a" + ] + }, + "applied": true, + "expected": { + "layout": { + "group": "g1" + }, + "tabs": [ + { + "id": "a", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + } + ], + "focusedGroup": "g1" + } + }, + { + "name": "nested split collapse keeps the surviving split identity", + "state": { + "layout": { + "split": { + "id": "s1", + "orientation": "horizontal", + "first": { + "group": "g1" + }, + "second": { + "split": { + "id": "s2", + "orientation": "vertical", + "first": { + "group": "g2" + }, + "second": { + "group": "g3" + } + } + } + } + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g2" + }, + { + "id": "c", + "group": "g3" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + }, + { + "group": "g2", + "tab": "b" + }, + { + "group": "g3", + "tab": "c" + } + ], + "focusedGroup": "g1" + }, + "action": { + "type": "sync", + "tabs": [ + "a", + "c" + ] + }, + "applied": true, + "expected": { + "layout": { + "split": { + "id": "s1", + "orientation": "horizontal", + "first": { + "group": "g1" + }, + "second": { + "group": "g3" + } + } + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "c", + "group": "g3" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + }, + { + "group": "g3", + "tab": "c" + } + ], + "focusedGroup": "g1" + } + }, + { + "name": "closing every tab leaves one reusable empty group", + "state": { + "layout": { + "split": { + "id": "s1", + "orientation": "horizontal", + "first": { + "group": "g1" + }, + "second": { + "group": "g2" + } + } + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g2" + }, + { + "id": "c", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + }, + { + "group": "g2", + "tab": "b" + } + ], + "focusedGroup": "g1" + }, + "action": { + "type": "sync", + "tabs": [] + }, + "applied": true, + "expected": { + "layout": { + "group": "g1" + }, + "tabs": [], + "selections": [ + { + "group": "g1", + "tab": null + } + ], + "focusedGroup": "g1" + } + }, + { + "name": "unchanged sync is not applied", + "state": { + "layout": { + "split": { + "id": "s1", + "orientation": "horizontal", + "first": { + "group": "g1" + }, + "second": { + "group": "g2" + } + } + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g2" + }, + { + "id": "c", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + }, + { + "group": "g2", + "tab": "b" + } + ], + "focusedGroup": "g1" + }, + "action": { + "type": "sync", + "tabs": [ + "a", + "b", + "c" + ] + }, + "applied": false, + "expected": { + "layout": { + "split": { + "id": "s1", + "orientation": "horizontal", + "first": { + "group": "g1" + }, + "second": { + "group": "g2" + } + } + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g2" + }, + { + "id": "c", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + }, + { + "group": "g2", + "tab": "b" + } + ], + "focusedGroup": "g1" + } + }, + { + "name": "center drop joins the target group after its last tab and collapses the empty source", + "state": { + "layout": { + "split": { + "id": "s1", + "orientation": "horizontal", + "first": { + "group": "g1" + }, + "second": { + "group": "g2" + } + } + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g2" + }, + { + "id": "c", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + }, + { + "group": "g2", + "tab": "b" + } + ], + "focusedGroup": "g1" + }, + "action": { + "type": "drop", + "tab": "b", + "group": "g1", + "side": "center" + }, + "applied": true, + "expected": { + "layout": { + "group": "g1" + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "c", + "group": "g1" + }, + { + "id": "b", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "b" + } + ], + "focusedGroup": "g1" + } + }, + { + "name": "leading edge drop puts the new group first", + "state": { + "layout": { + "group": "g1" + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + } + ], + "focusedGroup": "g1" + }, + "action": { + "type": "drop", + "tab": "b", + "group": "g1", + "side": "left", + "newGroup": "g2", + "newSplit": "s1" + }, + "applied": true, + "expected": { + "layout": { + "split": { + "id": "s1", + "orientation": "horizontal", + "first": { + "group": "g2" + }, + "second": { + "group": "g1" + } + } + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g2" + } + ], + "selections": [ + { + "group": "g2", + "tab": "b" + }, + { + "group": "g1", + "tab": "a" + } + ], + "focusedGroup": "g2" + } + }, + { + "name": "bottom edge drop stacks the new group second", + "state": { + "layout": { + "split": { + "id": "s1", + "orientation": "horizontal", + "first": { + "group": "g1" + }, + "second": { + "group": "g2" + } + } + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g2" + }, + { + "id": "c", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + }, + { + "group": "g2", + "tab": "b" + } + ], + "focusedGroup": "g1" + }, + "action": { + "type": "drop", + "tab": "c", + "group": "g2", + "side": "bottom", + "newGroup": "g3", + "newSplit": "s2" + }, + "applied": true, + "expected": { + "layout": { + "split": { + "id": "s1", + "orientation": "horizontal", + "first": { + "group": "g1" + }, + "second": { + "split": { + "id": "s2", + "orientation": "vertical", + "first": { + "group": "g2" + }, + "second": { + "group": "g3" + } + } + } + } + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g2" + }, + { + "id": "c", + "group": "g3" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + }, + { + "group": "g2", + "tab": "b" + }, + { + "group": "g3", + "tab": "c" + } + ], + "focusedGroup": "g3" + } + }, + { + "name": "edge drop from a single-tab group onto itself is rejected", + "state": { + "layout": { + "split": { + "id": "s1", + "orientation": "horizontal", + "first": { + "group": "g1" + }, + "second": { + "group": "g2" + } + } + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g2" + }, + { + "id": "c", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + }, + { + "group": "g2", + "tab": "b" + } + ], + "focusedGroup": "g1" + }, + "action": { + "type": "drop", + "tab": "b", + "group": "g2", + "side": "right", + "newGroup": "g3", + "newSplit": "s2" + }, + "applied": false, + "expected": { + "layout": { + "split": { + "id": "s1", + "orientation": "horizontal", + "first": { + "group": "g1" + }, + "second": { + "group": "g2" + } + } + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g2" + }, + { + "id": "c", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + }, + { + "group": "g2", + "tab": "b" + } + ], + "focusedGroup": "g1" + } + }, + { + "name": "center drop onto the owning group is rejected", + "state": { + "layout": { + "split": { + "id": "s1", + "orientation": "horizontal", + "first": { + "group": "g1" + }, + "second": { + "group": "g2" + } + } + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g2" + }, + { + "id": "c", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + }, + { + "group": "g2", + "tab": "b" + } + ], + "focusedGroup": "g1" + }, + "action": { + "type": "drop", + "tab": "a", + "group": "g1", + "side": "center" + }, + "applied": false, + "expected": { + "layout": { + "split": { + "id": "s1", + "orientation": "horizontal", + "first": { + "group": "g1" + }, + "second": { + "group": "g2" + } + } + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g2" + }, + { + "id": "c", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + }, + { + "group": "g2", + "tab": "b" + } + ], + "focusedGroup": "g1" + } + }, + { + "name": "drop into an unknown group is rejected", + "state": { + "layout": { + "split": { + "id": "s1", + "orientation": "horizontal", + "first": { + "group": "g1" + }, + "second": { + "group": "g2" + } + } + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g2" + }, + { + "id": "c", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + }, + { + "group": "g2", + "tab": "b" + } + ], + "focusedGroup": "g1" + }, + "action": { + "type": "drop", + "tab": "a", + "group": "missing", + "side": "center" + }, + "applied": false, + "expected": { + "layout": { + "split": { + "id": "s1", + "orientation": "horizontal", + "first": { + "group": "g1" + }, + "second": { + "group": "g2" + } + } + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g2" + }, + { + "id": "c", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + }, + { + "group": "g2", + "tab": "b" + } + ], + "focusedGroup": "g1" + } + }, + { + "name": "canDrop reports split eligibility without changing state", + "state": { + "layout": { + "split": { + "id": "s1", + "orientation": "horizontal", + "first": { + "group": "g1" + }, + "second": { + "group": "g2" + } + } + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g2" + }, + { + "id": "c", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + }, + { + "group": "g2", + "tab": "b" + } + ], + "focusedGroup": "g1" + }, + "action": { + "type": "canDrop", + "tab": "a", + "group": "g1", + "side": "right" + }, + "applied": true, + "expected": { + "layout": { + "split": { + "id": "s1", + "orientation": "horizontal", + "first": { + "group": "g1" + }, + "second": { + "group": "g2" + } + } + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g2" + }, + { + "id": "c", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + }, + { + "group": "g2", + "tab": "b" + } + ], + "focusedGroup": "g1" + } + }, + { + "name": "canDrop rejects a self split of a single-tab group", + "state": { + "layout": { + "split": { + "id": "s1", + "orientation": "horizontal", + "first": { + "group": "g1" + }, + "second": { + "group": "g2" + } + } + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g2" + }, + { + "id": "c", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + }, + { + "group": "g2", + "tab": "b" + } + ], + "focusedGroup": "g1" + }, + "action": { + "type": "canDrop", + "tab": "b", + "group": "g2", + "side": "top" + }, + "applied": false, + "expected": { + "layout": { + "split": { + "id": "s1", + "orientation": "horizontal", + "first": { + "group": "g1" + }, + "second": { + "group": "g2" + } + } + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g2" + }, + { + "id": "c", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + }, + { + "group": "g2", + "tab": "b" + } + ], + "focusedGroup": "g1" + } + }, + { + "name": "insert without a group joins the focused group at the end", + "state": { + "layout": { + "split": { + "id": "s1", + "orientation": "horizontal", + "first": { + "group": "g1" + }, + "second": { + "group": "g2" + } + } + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g2" + }, + { + "id": "c", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + }, + { + "group": "g2", + "tab": "b" + } + ], + "focusedGroup": "g1" + }, + "action": { + "type": "insert", + "tab": "n", + "group": null, + "side": "right" + }, + "applied": true, + "expected": { + "layout": { + "split": { + "id": "s1", + "orientation": "horizontal", + "first": { + "group": "g1" + }, + "second": { + "group": "g2" + } + } + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g2" + }, + { + "id": "c", + "group": "g1" + }, + { + "id": "n", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "n" + }, + { + "group": "g2", + "tab": "b" + } + ], + "focusedGroup": "g1" + } + }, + { + "name": "center insert follows the target group's last tab", + "state": { + "layout": { + "split": { + "id": "s1", + "orientation": "horizontal", + "first": { + "group": "g1" + }, + "second": { + "group": "g2" + } + } + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g2" + }, + { + "id": "c", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + }, + { + "group": "g2", + "tab": "b" + } + ], + "focusedGroup": "g1" + }, + "action": { + "type": "insert", + "tab": "n", + "group": "g2", + "side": "center" + }, + "applied": true, + "expected": { + "layout": { + "split": { + "id": "s1", + "orientation": "horizontal", + "first": { + "group": "g1" + }, + "second": { + "group": "g2" + } + } + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g2" + }, + { + "id": "n", + "group": "g2" + }, + { + "id": "c", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + }, + { + "group": "g2", + "tab": "n" + } + ], + "focusedGroup": "g2" + } + }, + { + "name": "edge insert splits even a single-tab group", + "state": { + "layout": { + "split": { + "id": "s1", + "orientation": "horizontal", + "first": { + "group": "g1" + }, + "second": { + "group": "g2" + } + } + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g2" + }, + { + "id": "c", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + }, + { + "group": "g2", + "tab": "b" + } + ], + "focusedGroup": "g1" + }, + "action": { + "type": "insert", + "tab": "n", + "group": "g2", + "side": "top", + "newGroup": "g3", + "newSplit": "s2" + }, + "applied": true, + "expected": { + "layout": { + "split": { + "id": "s1", + "orientation": "horizontal", + "first": { + "group": "g1" + }, + "second": { + "split": { + "id": "s2", + "orientation": "vertical", + "first": { + "group": "g3" + }, + "second": { + "group": "g2" + } + } + } + } + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g2" + }, + { + "id": "c", + "group": "g1" + }, + { + "id": "n", + "group": "g3" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + }, + { + "group": "g3", + "tab": "n" + }, + { + "group": "g2", + "tab": "b" + } + ], + "focusedGroup": "g3" + } + }, + { + "name": "inserting an open tab is rejected", + "state": { + "layout": { + "split": { + "id": "s1", + "orientation": "horizontal", + "first": { + "group": "g1" + }, + "second": { + "group": "g2" + } + } + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g2" + }, + { + "id": "c", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + }, + { + "group": "g2", + "tab": "b" + } + ], + "focusedGroup": "g1" + }, + "action": { + "type": "insert", + "tab": "a", + "group": "g2", + "side": "center" + }, + "applied": false, + "expected": { + "layout": { + "split": { + "id": "s1", + "orientation": "horizontal", + "first": { + "group": "g1" + }, + "second": { + "group": "g2" + } + } + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g2" + }, + { + "id": "c", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + }, + { + "group": "g2", + "tab": "b" + } + ], + "focusedGroup": "g1" + } + }, + { + "name": "move before a tab in another group adopts that group", + "state": { + "layout": { + "split": { + "id": "s1", + "orientation": "horizontal", + "first": { + "group": "g1" + }, + "second": { + "group": "g2" + } + } + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g2" + }, + { + "id": "c", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + }, + { + "group": "g2", + "tab": "b" + } + ], + "focusedGroup": "g1" + }, + "action": { + "type": "move", + "tab": "c", + "target": "b", + "position": "before" + }, + "applied": true, + "expected": { + "layout": { + "split": { + "id": "s1", + "orientation": "horizontal", + "first": { + "group": "g1" + }, + "second": { + "group": "g2" + } + } + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "c", + "group": "g2" + }, + { + "id": "b", + "group": "g2" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + }, + { + "group": "g2", + "tab": "b" + } + ], + "focusedGroup": "g1" + } + }, + { + "name": "move of the only tab collapses its former group", + "state": { + "layout": { + "split": { + "id": "s1", + "orientation": "horizontal", + "first": { + "group": "g1" + }, + "second": { + "group": "g2" + } + } + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g2" + }, + { + "id": "c", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + }, + { + "group": "g2", + "tab": "b" + } + ], + "focusedGroup": "g1" + }, + "action": { + "type": "move", + "tab": "b", + "target": "a", + "position": "after" + }, + "applied": true, + "expected": { + "layout": { + "group": "g1" + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g1" + }, + { + "id": "c", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + } + ], + "focusedGroup": "g1" + } + }, + { + "name": "move into an adjacent slot of another group still adopts that group", + "state": { + "layout": { + "split": { + "id": "s1", + "orientation": "horizontal", + "first": { + "group": "g1" + }, + "second": { + "group": "g2" + } + } + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g2" + }, + { + "id": "c", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + }, + { + "group": "g2", + "tab": "b" + } + ], + "focusedGroup": "g1" + }, + "action": { + "type": "move", + "tab": "a", + "target": "b", + "position": "before" + }, + "applied": true, + "expected": { + "layout": { + "split": { + "id": "s1", + "orientation": "horizontal", + "first": { + "group": "g1" + }, + "second": { + "group": "g2" + } + } + }, + "tabs": [ + { + "id": "a", + "group": "g2" + }, + { + "id": "b", + "group": "g2" + }, + { + "id": "c", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "c" + }, + { + "group": "g2", + "tab": "b" + } + ], + "focusedGroup": "g1" + } + }, + { + "name": "move that changes neither order nor group is rejected", + "state": { + "layout": { + "group": "g1" + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + } + ], + "focusedGroup": "g1" + }, + "action": { + "type": "move", + "tab": "a", + "target": "b", + "position": "before" + }, + "applied": false, + "expected": { + "layout": { + "group": "g1" + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + } + ], + "focusedGroup": "g1" + } + }, + { + "name": "move relative to itself is rejected", + "state": { + "layout": { + "split": { + "id": "s1", + "orientation": "horizontal", + "first": { + "group": "g1" + }, + "second": { + "group": "g2" + } + } + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g2" + }, + { + "id": "c", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + }, + { + "group": "g2", + "tab": "b" + } + ], + "focusedGroup": "g1" + }, + "action": { + "type": "move", + "tab": "a", + "target": "a", + "position": "after" + }, + "applied": false, + "expected": { + "layout": { + "split": { + "id": "s1", + "orientation": "horizontal", + "first": { + "group": "g1" + }, + "second": { + "group": "g2" + } + } + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g2" + }, + { + "id": "c", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + }, + { + "group": "g2", + "tab": "b" + } + ], + "focusedGroup": "g1" + } + }, + { + "name": "focus selects the tab and its group", + "state": { + "layout": { + "split": { + "id": "s1", + "orientation": "horizontal", + "first": { + "group": "g1" + }, + "second": { + "group": "g2" + } + } + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g2" + }, + { + "id": "c", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + }, + { + "group": "g2", + "tab": "b" + } + ], + "focusedGroup": "g1" + }, + "action": { + "type": "focus", + "tab": "c" + }, + "applied": true, + "expected": { + "layout": { + "split": { + "id": "s1", + "orientation": "horizontal", + "first": { + "group": "g1" + }, + "second": { + "group": "g2" + } + } + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g2" + }, + { + "id": "c", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "c" + }, + { + "group": "g2", + "tab": "b" + } + ], + "focusedGroup": "g1" + } + }, + { + "name": "focus of a missing tab is rejected", + "state": { + "layout": { + "split": { + "id": "s1", + "orientation": "horizontal", + "first": { + "group": "g1" + }, + "second": { + "group": "g2" + } + } + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g2" + }, + { + "id": "c", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + }, + { + "group": "g2", + "tab": "b" + } + ], + "focusedGroup": "g1" + }, + "action": { + "type": "focus", + "tab": "missing" + }, + "applied": false, + "expected": { + "layout": { + "split": { + "id": "s1", + "orientation": "horizontal", + "first": { + "group": "g1" + }, + "second": { + "group": "g2" + } + } + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g2" + }, + { + "id": "c", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + }, + { + "group": "g2", + "tab": "b" + } + ], + "focusedGroup": "g1" + } + } + ], + "invalid": [ + { + "name": "edge drop without new identities", + "state": { + "layout": { + "split": { + "id": "s1", + "orientation": "horizontal", + "first": { + "group": "g1" + }, + "second": { + "group": "g2" + } + } + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g2" + }, + { + "id": "c", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + }, + { + "group": "g2", + "tab": "b" + } + ], + "focusedGroup": "g1" + }, + "action": { + "type": "drop", + "tab": "a", + "group": "g1", + "side": "left" + } + }, + { + "name": "new group identity already in the layout", + "state": { + "layout": { + "split": { + "id": "s1", + "orientation": "horizontal", + "first": { + "group": "g1" + }, + "second": { + "group": "g2" + } + } + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g2" + }, + { + "id": "c", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + }, + { + "group": "g2", + "tab": "b" + } + ], + "focusedGroup": "g1" + }, + "action": { + "type": "drop", + "tab": "a", + "group": "g1", + "side": "left", + "newGroup": "g2", + "newSplit": "s9" + } + }, + { + "name": "duplicate group in the layout", + "state": { + "layout": { + "split": { + "id": "s1", + "orientation": "horizontal", + "first": { + "group": "g1" + }, + "second": { + "group": "g1" + } + } + }, + "tabs": [], + "selections": [], + "focusedGroup": null + }, + "action": { + "type": "sync", + "tabs": [] + } + }, + { + "name": "unknown action", + "state": { + "layout": { + "split": { + "id": "s1", + "orientation": "horizontal", + "first": { + "group": "g1" + }, + "second": { + "group": "g2" + } + } + }, + "tabs": [ + { + "id": "a", + "group": "g1" + }, + { + "id": "b", + "group": "g2" + }, + { + "id": "c", + "group": "g1" + } + ], + "selections": [ + { + "group": "g1", + "tab": "a" + }, + { + "group": "g2", + "tab": "b" + } + ], + "focusedGroup": "g1" + }, + "action": { + "type": "explode" + } + } + ] +} diff --git a/shared/fixtures/lsp/activation-policy-v1.json b/shared/fixtures/lsp/activation-policy-v1.json new file mode 100644 index 0000000000..2caa80c42a --- /dev/null +++ b/shared/fixtures/lsp/activation-policy-v1.json @@ -0,0 +1,182 @@ +{ + "description": "lsp.activationPolicy: 3 consecutive failures require an explicit retry; automatic starts wait 5000 ms after each failure. nowMs is a platform monotonic clock.", + "maxConsecutiveFailures": 3, + "failureCooldownMs": 5000, + "cases": [ + { + "name": "automatic start without history is allowed", + "request": { + "state": null, + "event": "automaticStart", + "nowMs": 1000 + }, + "response": { + "state": null, + "decision": "allow", + "retryAfterMs": null + } + }, + { + "name": "first failure starts the cooldown", + "request": { + "state": null, + "event": "startFailed", + "nowMs": 1000 + }, + "response": { + "state": { + "consecutiveFailures": 1, + "lastFailureAtMs": 1000 + }, + "decision": "wait", + "retryAfterMs": 5000 + } + }, + { + "name": "automatic start waits for the rest of the cooldown", + "request": { + "state": { + "consecutiveFailures": 1, + "lastFailureAtMs": 1000 + }, + "event": "automaticStart", + "nowMs": 5999 + }, + "response": { + "state": { + "consecutiveFailures": 1, + "lastFailureAtMs": 1000 + }, + "decision": "wait", + "retryAfterMs": 1 + } + }, + { + "name": "automatic start is allowed once the cooldown elapsed", + "request": { + "state": { + "consecutiveFailures": 1, + "lastFailureAtMs": 1000 + }, + "event": "automaticStart", + "nowMs": 6000 + }, + "response": { + "state": { + "consecutiveFailures": 1, + "lastFailureAtMs": 1000 + }, + "decision": "allow", + "retryAfterMs": null + } + }, + { + "name": "second failure restarts the cooldown", + "request": { + "state": { + "consecutiveFailures": 1, + "lastFailureAtMs": 1000 + }, + "event": "startFailed", + "nowMs": 7000 + }, + "response": { + "state": { + "consecutiveFailures": 2, + "lastFailureAtMs": 7000 + }, + "decision": "wait", + "retryAfterMs": 5000 + } + }, + { + "name": "third consecutive failure requires an explicit retry", + "request": { + "state": { + "consecutiveFailures": 2, + "lastFailureAtMs": 7000 + }, + "event": "startFailed", + "nowMs": 20000 + }, + "response": { + "state": { + "consecutiveFailures": 3, + "lastFailureAtMs": 20000 + }, + "decision": "explicitRetryRequired", + "retryAfterMs": null + } + }, + { + "name": "exhausted automatic starts never resume by waiting", + "request": { + "state": { + "consecutiveFailures": 3, + "lastFailureAtMs": 20000 + }, + "event": "automaticStart", + "nowMs": 999999 + }, + "response": { + "state": { + "consecutiveFailures": 3, + "lastFailureAtMs": 20000 + }, + "decision": "explicitRetryRequired", + "retryAfterMs": null + } + }, + { + "name": "explicit start clears the history", + "request": { + "state": { + "consecutiveFailures": 3, + "lastFailureAtMs": 20000 + }, + "event": "explicitStart", + "nowMs": 21000 + }, + "response": { + "state": null, + "decision": "allow", + "retryAfterMs": null + } + }, + { + "name": "ready session clears the history", + "request": { + "state": { + "consecutiveFailures": 2, + "lastFailureAtMs": 7000 + }, + "event": "ready", + "nowMs": 8000 + }, + "response": { + "state": null, + "decision": "allow", + "retryAfterMs": null + } + }, + { + "name": "a clock reading before the last failure restarts the cooldown", + "request": { + "state": { + "consecutiveFailures": 1, + "lastFailureAtMs": 10000 + }, + "event": "automaticStart", + "nowMs": 500 + }, + "response": { + "state": { + "consecutiveFailures": 1, + "lastFailureAtMs": 500 + }, + "decision": "wait", + "retryAfterMs": 5000 + } + } + ] +} diff --git a/shared/fixtures/lsp/completion-merge-v1.json b/shared/fixtures/lsp/completion-merge-v1.json new file mode 100644 index 0000000000..a4eb91710f --- /dev/null +++ b/shared/fixtures/lsp/completion-merge-v1.json @@ -0,0 +1,206 @@ +{ + "description": "lsp.mergeCompletions: lists arrive in provider priority order; a provider's own candidates are never collapsed; a lower-priority candidate is dropped when a higher-priority provider offered the same label.", + "cases": [ + { + "name": "a provider keeps every candidate, including overloads that share a label", + "request": { + "lists": [ + { + "labels": [ + "print", + "print", + "println" + ], + "isIncomplete": false + } + ] + }, + "response": { + "keep": [ + [ + 0, + 1, + 2 + ] + ], + "isIncomplete": false + } + }, + { + "name": "a lower-priority provider is shadowed by labels a higher-priority provider offered", + "request": { + "lists": [ + { + "labels": [ + "value", + "valueOf" + ], + "isIncomplete": false + }, + { + "labels": [ + "value", + "values", + "valueOf" + ], + "isIncomplete": false + } + ] + }, + "response": { + "keep": [ + [ + 0, + 1 + ], + [ + 1 + ] + ], + "isIncomplete": false + } + }, + { + "name": "shadowing cascades through every lower-priority provider", + "request": { + "lists": [ + { + "labels": [ + "a" + ], + "isIncomplete": false + }, + { + "labels": [ + "b", + "a" + ], + "isIncomplete": false + }, + { + "labels": [ + "a", + "b", + "c", + "c" + ], + "isIncomplete": false + } + ] + }, + "response": { + "keep": [ + [ + 0 + ], + [ + 0 + ], + [ + 2, + 3 + ] + ], + "isIncomplete": false + } + }, + { + "name": "an empty higher-priority list shadows nothing", + "request": { + "lists": [ + { + "labels": [], + "isIncomplete": true + }, + { + "labels": [ + "x" + ], + "isIncomplete": false + } + ] + }, + "response": { + "keep": [ + [], + [ + 0 + ] + ], + "isIncomplete": true + } + }, + { + "name": "labels compare exactly", + "request": { + "lists": [ + { + "labels": [ + "Foo" + ], + "isIncomplete": false + }, + { + "labels": [ + "foo", + "Foo " + ], + "isIncomplete": false + } + ] + }, + "response": { + "keep": [ + [ + 0 + ], + [ + 0, + 1 + ] + ], + "isIncomplete": false + } + }, + { + "name": "incomplete from any provider marks the merged list incomplete", + "request": { + "lists": [ + { + "labels": [ + "a" + ], + "isIncomplete": false + }, + { + "labels": [ + "b" + ], + "isIncomplete": true + } + ] + }, + "response": { + "keep": [ + [ + 0 + ], + [ + 0 + ] + ], + "isIncomplete": true + } + }, + { + "name": "no providers", + "request": { + "lists": [] + }, + "response": { + "keep": [], + "isIncomplete": false + } + } + ] +} diff --git a/shared/fixtures/lsp/navigation-target-v1.json b/shared/fixtures/lsp/navigation-target-v1.json new file mode 100644 index 0000000000..446ba74317 --- /dev/null +++ b/shared/fixtures/lsp/navigation-target-v1.json @@ -0,0 +1,305 @@ +{ + "version": 1, + "command": "lsp.classifyNavigationTarget", + "cases": [ + { + "name": "no workspace accepts any file", + "request": { + "platform": "posix", + "path": "/elsewhere/notes.txt" + }, + "expected": { + "classification": "workspace", + "isReadOnly": false + } + }, + { + "name": "workspace source file", + "request": { + "platform": "posix", + "workspaceRoot": "/work/project", + "sourceLanguageID": "go", + "path": "/work/project/main.go", + "languageID": "go" + }, + "expected": { + "classification": "workspace", + "isReadOnly": false + } + }, + { + "name": "workspace non-source file", + "request": { + "platform": "posix", + "workspaceRoot": "/work/project", + "sourceLanguageID": "go", + "path": "/work/project/config.json", + "languageID": "json" + }, + "expected": { + "classification": "workspace", + "isReadOnly": false + } + }, + { + "name": "in-workspace symlink to an outside secret is rejected", + "request": { + "platform": "posix", + "workspaceRoot": "/work/project", + "sourceLanguageID": "go", + "path": "/work/project/secret.go", + "resolvedPath": "/work/outside/id_rsa", + "languageID": "go" + }, + "expected": { + "classification": "rejected", + "isReadOnly": false + } + }, + { + "name": "in-workspace symlink to an outside source opens read-only", + "request": { + "platform": "posix", + "workspaceRoot": "/work/project", + "sourceLanguageID": "go", + "path": "/work/project/vendor.go", + "resolvedPath": "/work/outside/print.go", + "languageID": "go", + "resolvedLanguageID": "go" + }, + "expected": { + "classification": "externalReadOnly", + "isReadOnly": true + } + }, + { + "name": "outside source of the same language opens read-only", + "request": { + "platform": "posix", + "workspaceRoot": "/work/project", + "sourceLanguageID": "go", + "path": "/go/src/fmt/print.go", + "languageID": "go" + }, + "expected": { + "classification": "externalReadOnly", + "isReadOnly": true + } + }, + { + "name": "outside file of another language is rejected", + "request": { + "platform": "posix", + "workspaceRoot": "/work/project", + "sourceLanguageID": "go", + "path": "/home/user/.aws/credentials.json", + "languageID": "json" + }, + "expected": { + "classification": "rejected", + "isReadOnly": false + } + }, + { + "name": "outside file without a language is rejected", + "request": { + "platform": "posix", + "workspaceRoot": "/work/project", + "sourceLanguageID": "go", + "path": "/home/user/.ssh/id_rsa" + }, + "expected": { + "classification": "rejected", + "isReadOnly": false + } + }, + { + "name": "outside source without a source language is rejected", + "request": { + "platform": "posix", + "workspaceRoot": "/work/project", + "path": "/go/src/fmt/print.go", + "languageID": "go" + }, + "expected": { + "classification": "rejected", + "isReadOnly": false + } + }, + { + "name": "outside symlink to a workspace file is external", + "request": { + "platform": "posix", + "workspaceRoot": "/work/project", + "sourceLanguageID": "go", + "path": "/work/outside/link.go", + "resolvedPath": "/work/project/main.go", + "languageID": "go", + "resolvedLanguageID": "go" + }, + "expected": { + "classification": "externalReadOnly", + "isReadOnly": true + } + }, + { + "name": "outside symlink to a workspace file from another language is rejected", + "request": { + "platform": "posix", + "workspaceRoot": "/work/project", + "sourceLanguageID": "php", + "path": "/work/outside/link.go", + "resolvedPath": "/work/project/main.go", + "languageID": "go", + "resolvedLanguageID": "go" + }, + "expected": { + "classification": "rejected", + "isReadOnly": false + } + }, + { + "name": "source-named link to a non-source file is rejected", + "request": { + "platform": "posix", + "workspaceRoot": "/work/project", + "sourceLanguageID": "go", + "path": "/work/outside/notes.go", + "resolvedPath": "/work/project/notes.json", + "languageID": "go", + "resolvedLanguageID": "json" + }, + "expected": { + "classification": "rejected", + "isReadOnly": false + } + }, + { + "name": "symlinked workspace root accepts real paths", + "request": { + "platform": "posix", + "workspaceRoot": "/var/work/project", + "sourceLanguageID": "go", + "resolvedWorkspaceRoot": "/private/var/work/project", + "path": "/private/var/work/project/main.go", + "resolvedPath": "/private/var/work/project/main.go", + "languageID": "go", + "resolvedLanguageID": "go" + }, + "expected": { + "classification": "workspace", + "isReadOnly": false + } + }, + { + "name": "dot segments are resolved before containment", + "request": { + "platform": "posix", + "workspaceRoot": "/work/project", + "sourceLanguageID": "go", + "path": "/work/project/../outside/print.go", + "languageID": "go" + }, + "expected": { + "classification": "externalReadOnly", + "isReadOnly": true + } + }, + { + "name": "sibling directory sharing the root prefix is outside", + "request": { + "platform": "posix", + "workspaceRoot": "/work/project", + "sourceLanguageID": "go", + "path": "/work/project2/main.go", + "languageID": "go" + }, + "expected": { + "classification": "externalReadOnly", + "isReadOnly": true + } + }, + { + "name": "the workspace root itself is not a file inside it", + "request": { + "platform": "posix", + "workspaceRoot": "/work/project", + "sourceLanguageID": "go", + "path": "/work/project", + "languageID": "go" + }, + "expected": { + "classification": "externalReadOnly", + "isReadOnly": true + } + }, + { + "name": "server read-only flag is kept inside the workspace", + "request": { + "platform": "posix", + "workspaceRoot": "/work/project", + "sourceLanguageID": "go", + "path": "/work/project/generated.go", + "languageID": "go", + "isReadOnly": true + }, + "expected": { + "classification": "workspace", + "isReadOnly": true + } + }, + { + "name": "Windows paths compare without case and separator differences", + "request": { + "platform": "windows", + "workspaceRoot": "C:\\Work\\Project", + "sourceLanguageID": "java", + "path": "c:/work/project/src/Main.java", + "languageID": "java" + }, + "expected": { + "classification": "workspace", + "isReadOnly": false + } + }, + { + "name": "Windows sibling prefix is outside", + "request": { + "platform": "windows", + "workspaceRoot": "C:\\Work\\Project", + "sourceLanguageID": "java", + "path": "C:\\Work\\Project2\\Main.java", + "languageID": "java" + }, + "expected": { + "classification": "externalReadOnly", + "isReadOnly": true + } + }, + { + "name": "Windows UNC workspace", + "request": { + "platform": "auto", + "workspaceRoot": "\\\\server\\share\\project", + "sourceLanguageID": "java", + "path": "//SERVER/share/project/Main.java", + "languageID": "java" + }, + "expected": { + "classification": "workspace", + "isReadOnly": false + } + }, + { + "name": "relative target path is invalid", + "request": { + "platform": "posix", + "workspaceRoot": "/work/project", + "sourceLanguageID": "go", + "path": "main.go", + "languageID": "go" + }, + "expectedError": "invalid_request" + } + ] +} diff --git a/shared/fixtures/lsp/problems-query-v1.json b/shared/fixtures/lsp/problems-query-v1.json new file mode 100644 index 0000000000..3f2f5edb7c --- /dev/null +++ b/shared/fixtures/lsp/problems-query-v1.json @@ -0,0 +1,1244 @@ +{ + "description": "lsp.problems.query filters, orders, and groups Problems tool-window diagnostics. Responses return indices into the submitted diagnostics.", + "cases": [ + { + "name": "default groups files in natural path order and sorts by severity inside each file", + "request": { + "diagnostics": [ + { + "fileKey": "/w/src/File10.java", + "displayPath": "src/File10.java", + "line": 4, + "column": 2, + "severity": 2, + "message": "Unused import", + "source": "java", + "code": "268435844" + }, + { + "fileKey": "/w/src/File2.java", + "displayPath": "src/File2.java", + "line": 9, + "column": 0, + "severity": 1, + "message": "Type mismatch", + "source": "java", + "code": "16777233" + }, + { + "fileKey": "/w/src/File2.java", + "displayPath": "src/File2.java", + "line": 1, + "column": 5, + "severity": 3, + "message": "Consider final", + "source": "java" + }, + { + "fileKey": "/w/README.md", + "displayPath": "README.md", + "line": 0, + "column": 0, + "severity": null, + "message": "Spelling", + "source": "cspell" + }, + { + "fileKey": "/w/src/File10.java", + "displayPath": "src/File10.java", + "line": 4, + "column": 2, + "severity": 2, + "message": "Duplicate at the same position", + "source": "java" + }, + { + "fileKey": "/w/src/File2.java", + "displayPath": "src/File2.java", + "line": 1, + "column": 5, + "severity": 4, + "message": "Hint here", + "source": "Java" + } + ] + }, + "response": { + "groups": [ + { + "key": "file:/w/README.md", + "kind": "file", + "fileKey": "/w/README.md", + "displayPath": "README.md", + "indices": [ + 3 + ], + "counts": { + "error": 0, + "warning": 0, + "information": 0, + "hint": 0, + "unknown": 1 + } + }, + { + "key": "file:/w/src/File2.java", + "kind": "file", + "fileKey": "/w/src/File2.java", + "displayPath": "src/File2.java", + "indices": [ + 1, + 2, + 5 + ], + "counts": { + "error": 1, + "warning": 0, + "information": 1, + "hint": 1, + "unknown": 0 + } + }, + { + "key": "file:/w/src/File10.java", + "kind": "file", + "fileKey": "/w/src/File10.java", + "displayPath": "src/File10.java", + "indices": [ + 0, + 4 + ], + "counts": { + "error": 0, + "warning": 2, + "information": 0, + "hint": 0, + "unknown": 0 + } + } + ], + "files": [ + { + "fileKey": "/w/README.md", + "displayPath": "README.md", + "count": 1, + "highestSeverity": 0, + "counts": { + "error": 0, + "warning": 0, + "information": 0, + "hint": 0, + "unknown": 1 + } + }, + { + "fileKey": "/w/src/File2.java", + "displayPath": "src/File2.java", + "count": 3, + "highestSeverity": 1, + "counts": { + "error": 1, + "warning": 0, + "information": 1, + "hint": 1, + "unknown": 0 + } + }, + { + "fileKey": "/w/src/File10.java", + "displayPath": "src/File10.java", + "count": 2, + "highestSeverity": 2, + "counts": { + "error": 0, + "warning": 2, + "information": 0, + "hint": 0, + "unknown": 0 + } + } + ], + "visibleCounts": { + "error": 1, + "warning": 2, + "information": 1, + "hint": 1, + "unknown": 1 + }, + "totalCounts": { + "error": 1, + "warning": 2, + "information": 1, + "hint": 1, + "unknown": 1 + } + } + }, + { + "name": "severity groups list errors first and unknown severities last", + "request": { + "diagnostics": [ + { + "fileKey": "/w/src/File10.java", + "displayPath": "src/File10.java", + "line": 4, + "column": 2, + "severity": 2, + "message": "Unused import", + "source": "java", + "code": "268435844" + }, + { + "fileKey": "/w/src/File2.java", + "displayPath": "src/File2.java", + "line": 9, + "column": 0, + "severity": 1, + "message": "Type mismatch", + "source": "java", + "code": "16777233" + }, + { + "fileKey": "/w/src/File2.java", + "displayPath": "src/File2.java", + "line": 1, + "column": 5, + "severity": 3, + "message": "Consider final", + "source": "java" + }, + { + "fileKey": "/w/README.md", + "displayPath": "README.md", + "line": 0, + "column": 0, + "severity": null, + "message": "Spelling", + "source": "cspell" + }, + { + "fileKey": "/w/src/File10.java", + "displayPath": "src/File10.java", + "line": 4, + "column": 2, + "severity": 2, + "message": "Duplicate at the same position", + "source": "java" + }, + { + "fileKey": "/w/src/File2.java", + "displayPath": "src/File2.java", + "line": 1, + "column": 5, + "severity": 4, + "message": "Hint here", + "source": "Java" + } + ], + "groupBy": "severity", + "sortBy": "file" + }, + "response": { + "groups": [ + { + "key": "severity:1", + "kind": "severity", + "severity": 1, + "indices": [ + 1 + ], + "counts": { + "error": 1, + "warning": 0, + "information": 0, + "hint": 0, + "unknown": 0 + } + }, + { + "key": "severity:2", + "kind": "severity", + "severity": 2, + "indices": [ + 0, + 4 + ], + "counts": { + "error": 0, + "warning": 2, + "information": 0, + "hint": 0, + "unknown": 0 + } + }, + { + "key": "severity:3", + "kind": "severity", + "severity": 3, + "indices": [ + 2 + ], + "counts": { + "error": 0, + "warning": 0, + "information": 1, + "hint": 0, + "unknown": 0 + } + }, + { + "key": "severity:4", + "kind": "severity", + "severity": 4, + "indices": [ + 5 + ], + "counts": { + "error": 0, + "warning": 0, + "information": 0, + "hint": 1, + "unknown": 0 + } + }, + { + "key": "severity:0", + "kind": "severity", + "severity": 0, + "indices": [ + 3 + ], + "counts": { + "error": 0, + "warning": 0, + "information": 0, + "hint": 0, + "unknown": 1 + } + } + ], + "files": [ + { + "fileKey": "/w/README.md", + "displayPath": "README.md", + "count": 1, + "highestSeverity": 0, + "counts": { + "error": 0, + "warning": 0, + "information": 0, + "hint": 0, + "unknown": 1 + } + }, + { + "fileKey": "/w/src/File2.java", + "displayPath": "src/File2.java", + "count": 3, + "highestSeverity": 1, + "counts": { + "error": 1, + "warning": 0, + "information": 1, + "hint": 1, + "unknown": 0 + } + }, + { + "fileKey": "/w/src/File10.java", + "displayPath": "src/File10.java", + "count": 2, + "highestSeverity": 2, + "counts": { + "error": 0, + "warning": 2, + "information": 0, + "hint": 0, + "unknown": 0 + } + } + ], + "visibleCounts": { + "error": 1, + "warning": 2, + "information": 1, + "hint": 1, + "unknown": 1 + }, + "totalCounts": { + "error": 1, + "warning": 2, + "information": 1, + "hint": 1, + "unknown": 1 + } + } + }, + { + "name": "position order ranks line and column before file and keeps submission order for ties", + "request": { + "diagnostics": [ + { + "fileKey": "/w/src/File10.java", + "displayPath": "src/File10.java", + "line": 4, + "column": 2, + "severity": 2, + "message": "Unused import", + "source": "java", + "code": "268435844" + }, + { + "fileKey": "/w/src/File2.java", + "displayPath": "src/File2.java", + "line": 9, + "column": 0, + "severity": 1, + "message": "Type mismatch", + "source": "java", + "code": "16777233" + }, + { + "fileKey": "/w/src/File2.java", + "displayPath": "src/File2.java", + "line": 1, + "column": 5, + "severity": 3, + "message": "Consider final", + "source": "java" + }, + { + "fileKey": "/w/README.md", + "displayPath": "README.md", + "line": 0, + "column": 0, + "severity": null, + "message": "Spelling", + "source": "cspell" + }, + { + "fileKey": "/w/src/File10.java", + "displayPath": "src/File10.java", + "line": 4, + "column": 2, + "severity": 2, + "message": "Duplicate at the same position", + "source": "java" + }, + { + "fileKey": "/w/src/File2.java", + "displayPath": "src/File2.java", + "line": 1, + "column": 5, + "severity": 4, + "message": "Hint here", + "source": "Java" + } + ], + "groupBy": "none", + "sortBy": "position" + }, + "response": { + "groups": [ + { + "key": "all", + "kind": "all", + "indices": [ + 3, + 2, + 5, + 0, + 4, + 1 + ], + "counts": { + "error": 1, + "warning": 2, + "information": 1, + "hint": 1, + "unknown": 1 + } + } + ], + "files": [ + { + "fileKey": "/w/README.md", + "displayPath": "README.md", + "count": 1, + "highestSeverity": 0, + "counts": { + "error": 0, + "warning": 0, + "information": 0, + "hint": 0, + "unknown": 1 + } + }, + { + "fileKey": "/w/src/File2.java", + "displayPath": "src/File2.java", + "count": 3, + "highestSeverity": 1, + "counts": { + "error": 1, + "warning": 0, + "information": 1, + "hint": 1, + "unknown": 0 + } + }, + { + "fileKey": "/w/src/File10.java", + "displayPath": "src/File10.java", + "count": 2, + "highestSeverity": 2, + "counts": { + "error": 0, + "warning": 2, + "information": 0, + "hint": 0, + "unknown": 0 + } + } + ], + "visibleCounts": { + "error": 1, + "warning": 2, + "information": 1, + "hint": 1, + "unknown": 1 + }, + "totalCounts": { + "error": 1, + "warning": 2, + "information": 1, + "hint": 1, + "unknown": 1 + } + } + }, + { + "name": "severity filter keeps totals for the unfiltered list", + "request": { + "diagnostics": [ + { + "fileKey": "/w/src/File10.java", + "displayPath": "src/File10.java", + "line": 4, + "column": 2, + "severity": 2, + "message": "Unused import", + "source": "java", + "code": "268435844" + }, + { + "fileKey": "/w/src/File2.java", + "displayPath": "src/File2.java", + "line": 9, + "column": 0, + "severity": 1, + "message": "Type mismatch", + "source": "java", + "code": "16777233" + }, + { + "fileKey": "/w/src/File2.java", + "displayPath": "src/File2.java", + "line": 1, + "column": 5, + "severity": 3, + "message": "Consider final", + "source": "java" + }, + { + "fileKey": "/w/README.md", + "displayPath": "README.md", + "line": 0, + "column": 0, + "severity": null, + "message": "Spelling", + "source": "cspell" + }, + { + "fileKey": "/w/src/File10.java", + "displayPath": "src/File10.java", + "line": 4, + "column": 2, + "severity": 2, + "message": "Duplicate at the same position", + "source": "java" + }, + { + "fileKey": "/w/src/File2.java", + "displayPath": "src/File2.java", + "line": 1, + "column": 5, + "severity": 4, + "message": "Hint here", + "source": "Java" + } + ], + "filter": { + "severities": [ + 1, + 2 + ] + } + }, + "response": { + "groups": [ + { + "key": "file:/w/src/File2.java", + "kind": "file", + "fileKey": "/w/src/File2.java", + "displayPath": "src/File2.java", + "indices": [ + 1 + ], + "counts": { + "error": 1, + "warning": 0, + "information": 0, + "hint": 0, + "unknown": 0 + } + }, + { + "key": "file:/w/src/File10.java", + "kind": "file", + "fileKey": "/w/src/File10.java", + "displayPath": "src/File10.java", + "indices": [ + 0, + 4 + ], + "counts": { + "error": 0, + "warning": 2, + "information": 0, + "hint": 0, + "unknown": 0 + } + } + ], + "files": [ + { + "fileKey": "/w/src/File2.java", + "displayPath": "src/File2.java", + "count": 1, + "highestSeverity": 1, + "counts": { + "error": 1, + "warning": 0, + "information": 0, + "hint": 0, + "unknown": 0 + } + }, + { + "fileKey": "/w/src/File10.java", + "displayPath": "src/File10.java", + "count": 2, + "highestSeverity": 2, + "counts": { + "error": 0, + "warning": 2, + "information": 0, + "hint": 0, + "unknown": 0 + } + } + ], + "visibleCounts": { + "error": 1, + "warning": 2, + "information": 0, + "hint": 0, + "unknown": 0 + }, + "totalCounts": { + "error": 1, + "warning": 2, + "information": 1, + "hint": 1, + "unknown": 1 + } + } + }, + { + "name": "unknown severity is selected with zero", + "request": { + "diagnostics": [ + { + "fileKey": "/w/src/File10.java", + "displayPath": "src/File10.java", + "line": 4, + "column": 2, + "severity": 2, + "message": "Unused import", + "source": "java", + "code": "268435844" + }, + { + "fileKey": "/w/src/File2.java", + "displayPath": "src/File2.java", + "line": 9, + "column": 0, + "severity": 1, + "message": "Type mismatch", + "source": "java", + "code": "16777233" + }, + { + "fileKey": "/w/src/File2.java", + "displayPath": "src/File2.java", + "line": 1, + "column": 5, + "severity": 3, + "message": "Consider final", + "source": "java" + }, + { + "fileKey": "/w/README.md", + "displayPath": "README.md", + "line": 0, + "column": 0, + "severity": null, + "message": "Spelling", + "source": "cspell" + }, + { + "fileKey": "/w/src/File10.java", + "displayPath": "src/File10.java", + "line": 4, + "column": 2, + "severity": 2, + "message": "Duplicate at the same position", + "source": "java" + }, + { + "fileKey": "/w/src/File2.java", + "displayPath": "src/File2.java", + "line": 1, + "column": 5, + "severity": 4, + "message": "Hint here", + "source": "Java" + } + ], + "filter": { + "severities": [ + 0 + ] + } + }, + "response": { + "groups": [ + { + "key": "file:/w/README.md", + "kind": "file", + "fileKey": "/w/README.md", + "displayPath": "README.md", + "indices": [ + 3 + ], + "counts": { + "error": 0, + "warning": 0, + "information": 0, + "hint": 0, + "unknown": 1 + } + } + ], + "files": [ + { + "fileKey": "/w/README.md", + "displayPath": "README.md", + "count": 1, + "highestSeverity": 0, + "counts": { + "error": 0, + "warning": 0, + "information": 0, + "hint": 0, + "unknown": 1 + } + } + ], + "visibleCounts": { + "error": 0, + "warning": 0, + "information": 0, + "hint": 0, + "unknown": 1 + }, + "totalCounts": { + "error": 1, + "warning": 2, + "information": 1, + "hint": 1, + "unknown": 1 + } + } + }, + { + "name": "file scope and source filter compare the source case-insensitively", + "request": { + "diagnostics": [ + { + "fileKey": "/w/src/File10.java", + "displayPath": "src/File10.java", + "line": 4, + "column": 2, + "severity": 2, + "message": "Unused import", + "source": "java", + "code": "268435844" + }, + { + "fileKey": "/w/src/File2.java", + "displayPath": "src/File2.java", + "line": 9, + "column": 0, + "severity": 1, + "message": "Type mismatch", + "source": "java", + "code": "16777233" + }, + { + "fileKey": "/w/src/File2.java", + "displayPath": "src/File2.java", + "line": 1, + "column": 5, + "severity": 3, + "message": "Consider final", + "source": "java" + }, + { + "fileKey": "/w/README.md", + "displayPath": "README.md", + "line": 0, + "column": 0, + "severity": null, + "message": "Spelling", + "source": "cspell" + }, + { + "fileKey": "/w/src/File10.java", + "displayPath": "src/File10.java", + "line": 4, + "column": 2, + "severity": 2, + "message": "Duplicate at the same position", + "source": "java" + }, + { + "fileKey": "/w/src/File2.java", + "displayPath": "src/File2.java", + "line": 1, + "column": 5, + "severity": 4, + "message": "Hint here", + "source": "Java" + } + ], + "filter": { + "fileKey": "/w/src/File2.java", + "source": "JAVA" + } + }, + "response": { + "groups": [ + { + "key": "file:/w/src/File2.java", + "kind": "file", + "fileKey": "/w/src/File2.java", + "displayPath": "src/File2.java", + "indices": [ + 1, + 2, + 5 + ], + "counts": { + "error": 1, + "warning": 0, + "information": 1, + "hint": 1, + "unknown": 0 + } + } + ], + "files": [ + { + "fileKey": "/w/src/File2.java", + "displayPath": "src/File2.java", + "count": 3, + "highestSeverity": 1, + "counts": { + "error": 1, + "warning": 0, + "information": 1, + "hint": 1, + "unknown": 0 + } + } + ], + "visibleCounts": { + "error": 1, + "warning": 0, + "information": 1, + "hint": 1, + "unknown": 0 + }, + "totalCounts": { + "error": 1, + "warning": 2, + "information": 1, + "hint": 1, + "unknown": 1 + } + } + }, + { + "name": "query trims and matches the one-based line and column label", + "request": { + "diagnostics": [ + { + "fileKey": "/w/src/File10.java", + "displayPath": "src/File10.java", + "line": 4, + "column": 2, + "severity": 2, + "message": "Unused import", + "source": "java", + "code": "268435844" + }, + { + "fileKey": "/w/src/File2.java", + "displayPath": "src/File2.java", + "line": 9, + "column": 0, + "severity": 1, + "message": "Type mismatch", + "source": "java", + "code": "16777233" + }, + { + "fileKey": "/w/src/File2.java", + "displayPath": "src/File2.java", + "line": 1, + "column": 5, + "severity": 3, + "message": "Consider final", + "source": "java" + }, + { + "fileKey": "/w/README.md", + "displayPath": "README.md", + "line": 0, + "column": 0, + "severity": null, + "message": "Spelling", + "source": "cspell" + }, + { + "fileKey": "/w/src/File10.java", + "displayPath": "src/File10.java", + "line": 4, + "column": 2, + "severity": 2, + "message": "Duplicate at the same position", + "source": "java" + }, + { + "fileKey": "/w/src/File2.java", + "displayPath": "src/File2.java", + "line": 1, + "column": 5, + "severity": 4, + "message": "Hint here", + "source": "Java" + } + ], + "filter": { + "query": " 10:1 " + } + }, + "response": { + "groups": [ + { + "key": "file:/w/src/File2.java", + "kind": "file", + "fileKey": "/w/src/File2.java", + "displayPath": "src/File2.java", + "indices": [ + 1 + ], + "counts": { + "error": 1, + "warning": 0, + "information": 0, + "hint": 0, + "unknown": 0 + } + } + ], + "files": [ + { + "fileKey": "/w/src/File2.java", + "displayPath": "src/File2.java", + "count": 1, + "highestSeverity": 1, + "counts": { + "error": 1, + "warning": 0, + "information": 0, + "hint": 0, + "unknown": 0 + } + } + ], + "visibleCounts": { + "error": 1, + "warning": 0, + "information": 0, + "hint": 0, + "unknown": 0 + }, + "totalCounts": { + "error": 1, + "warning": 2, + "information": 1, + "hint": 1, + "unknown": 1 + } + } + }, + { + "name": "query matches the display path case-insensitively", + "request": { + "diagnostics": [ + { + "fileKey": "/w/src/File10.java", + "displayPath": "src/File10.java", + "line": 4, + "column": 2, + "severity": 2, + "message": "Unused import", + "source": "java", + "code": "268435844" + }, + { + "fileKey": "/w/src/File2.java", + "displayPath": "src/File2.java", + "line": 9, + "column": 0, + "severity": 1, + "message": "Type mismatch", + "source": "java", + "code": "16777233" + }, + { + "fileKey": "/w/src/File2.java", + "displayPath": "src/File2.java", + "line": 1, + "column": 5, + "severity": 3, + "message": "Consider final", + "source": "java" + }, + { + "fileKey": "/w/README.md", + "displayPath": "README.md", + "line": 0, + "column": 0, + "severity": null, + "message": "Spelling", + "source": "cspell" + }, + { + "fileKey": "/w/src/File10.java", + "displayPath": "src/File10.java", + "line": 4, + "column": 2, + "severity": 2, + "message": "Duplicate at the same position", + "source": "java" + }, + { + "fileKey": "/w/src/File2.java", + "displayPath": "src/File2.java", + "line": 1, + "column": 5, + "severity": 4, + "message": "Hint here", + "source": "Java" + } + ], + "groupBy": "none", + "sortBy": "file", + "filter": { + "query": "FILE10" + } + }, + "response": { + "groups": [ + { + "key": "all", + "kind": "all", + "indices": [ + 0, + 4 + ], + "counts": { + "error": 0, + "warning": 2, + "information": 0, + "hint": 0, + "unknown": 0 + } + } + ], + "files": [ + { + "fileKey": "/w/src/File10.java", + "displayPath": "src/File10.java", + "count": 2, + "highestSeverity": 2, + "counts": { + "error": 0, + "warning": 2, + "information": 0, + "hint": 0, + "unknown": 0 + } + } + ], + "visibleCounts": { + "error": 0, + "warning": 2, + "information": 0, + "hint": 0, + "unknown": 0 + }, + "totalCounts": { + "error": 1, + "warning": 2, + "information": 1, + "hint": 1, + "unknown": 1 + } + } + }, + { + "name": "no visible diagnostics produce no groups", + "request": { + "diagnostics": [ + { + "fileKey": "/w/src/File10.java", + "displayPath": "src/File10.java", + "line": 4, + "column": 2, + "severity": 2, + "message": "Unused import", + "source": "java", + "code": "268435844" + }, + { + "fileKey": "/w/src/File2.java", + "displayPath": "src/File2.java", + "line": 9, + "column": 0, + "severity": 1, + "message": "Type mismatch", + "source": "java", + "code": "16777233" + }, + { + "fileKey": "/w/src/File2.java", + "displayPath": "src/File2.java", + "line": 1, + "column": 5, + "severity": 3, + "message": "Consider final", + "source": "java" + }, + { + "fileKey": "/w/README.md", + "displayPath": "README.md", + "line": 0, + "column": 0, + "severity": null, + "message": "Spelling", + "source": "cspell" + }, + { + "fileKey": "/w/src/File10.java", + "displayPath": "src/File10.java", + "line": 4, + "column": 2, + "severity": 2, + "message": "Duplicate at the same position", + "source": "java" + }, + { + "fileKey": "/w/src/File2.java", + "displayPath": "src/File2.java", + "line": 1, + "column": 5, + "severity": 4, + "message": "Hint here", + "source": "Java" + } + ], + "groupBy": "none", + "filter": { + "query": "absent" + } + }, + "response": { + "groups": [], + "files": [], + "visibleCounts": { + "error": 0, + "warning": 0, + "information": 0, + "hint": 0, + "unknown": 0 + }, + "totalCounts": { + "error": 1, + "warning": 2, + "information": 1, + "hint": 1, + "unknown": 1 + } + } + }, + { + "name": "empty input", + "request": { + "diagnostics": [] + }, + "response": { + "groups": [], + "files": [], + "visibleCounts": { + "error": 0, + "warning": 0, + "information": 0, + "hint": 0, + "unknown": 0 + }, + "totalCounts": { + "error": 0, + "warning": 0, + "information": 0, + "hint": 0, + "unknown": 0 + } + } + } + ] +} diff --git a/shared/fixtures/lsp/text-edits-v1.json b/shared/fixtures/lsp/text-edits-v1.json index dd731d9449..16b60b8d88 100644 --- a/shared/fixtures/lsp/text-edits-v1.json +++ b/shared/fixtures/lsp/text-edits-v1.json @@ -324,6 +324,59 @@ } ], "expectedError": "overlappingEdits" + }, + { + "name": "lone CR terminates a line", + "text": "a\rb\rc", + "edits": [ + { + "range": { + "start": { + "line": 2, + "character": 0 + }, + "end": { + "line": 2, + "character": 1 + } + }, + "newText": "C" + } + ], + "expectedText": "a\rb\rC" + }, + { + "name": "insert at a replacement start is adjacent regardless of batch order", + "text": "abcd", + "edits": [ + { + "range": { + "start": { + "line": 0, + "character": 1 + }, + "end": { + "line": 0, + "character": 3 + } + }, + "newText": "R" + }, + { + "range": { + "start": { + "line": 0, + "character": 1 + }, + "end": { + "line": 0, + "character": 1 + } + }, + "newText": "I" + } + ], + "expectedText": "aIRd" } ] } diff --git a/shared/fixtures/lsp/workspace-edit-plan-v1.json b/shared/fixtures/lsp/workspace-edit-plan-v1.json new file mode 100644 index 0000000000..fd156d8fd2 --- /dev/null +++ b/shared/fixtures/lsp/workspace-edit-plan-v1.json @@ -0,0 +1,613 @@ +{ + "version": 1, + "command": "lsp.planWorkspaceEdit", + "cases": [ + { + "name": "closed files commit before open documents, each by relative path", + "request": { + "platform": "posix", + "workspaceRoot": "/work/project", + "changes": { + "/work/project/src/b.go": [ + { + "range": { + "start": { + "line": 0, + "utf16Column": 0 + }, + "end": { + "line": 0, + "utf16Column": 0 + } + }, + "newText": "b" + } + ], + "file:///work/project/src/a.go": [ + { + "range": { + "start": { + "line": 0, + "utf16Column": 0 + }, + "end": { + "line": 0, + "utf16Column": 0 + } + }, + "newText": "a" + } + ], + "/work/project/z.go": [ + { + "range": { + "start": { + "line": 0, + "utf16Column": 0 + }, + "end": { + "line": 0, + "utf16Column": 0 + } + }, + "newText": "z" + } + ] + }, + "openDocuments": [ + { + "path": "/work/project/src/b.go", + "isReadOnly": false + } + ] + }, + "expected": { + "rejection": null, + "targets": [ + { + "path": "/work/project/src/a.go", + "relativePath": "src/a.go", + "effect": "writeFile", + "edits": [ + { + "range": { + "start": { + "line": 0, + "utf16Column": 0 + }, + "end": { + "line": 0, + "utf16Column": 0 + } + }, + "newText": "a" + } + ] + }, + { + "path": "/work/project/z.go", + "relativePath": "z.go", + "effect": "writeFile", + "edits": [ + { + "range": { + "start": { + "line": 0, + "utf16Column": 0 + }, + "end": { + "line": 0, + "utf16Column": 0 + } + }, + "newText": "z" + } + ] + }, + { + "path": "/work/project/src/b.go", + "relativePath": "src/b.go", + "effect": "updateDocument", + "edits": [ + { + "range": { + "start": { + "line": 0, + "utf16Column": 0 + }, + "end": { + "line": 0, + "utf16Column": 0 + } + }, + "newText": "b" + } + ] + } + ] + } + }, + { + "name": "percent-encoded URI keys decode to native paths", + "request": { + "platform": "posix", + "workspaceRoot": "/work/project", + "changes": { + "file:///work/project/A%23B%20c.go": [ + { + "range": { + "start": { + "line": 0, + "utf16Column": 0 + }, + "end": { + "line": 0, + "utf16Column": 0 + } + }, + "newText": "x" + } + ] + } + }, + "expected": { + "rejection": null, + "targets": [ + { + "path": "/work/project/A#B c.go", + "relativePath": "A#B c.go", + "effect": "writeFile", + "edits": [ + { + "range": { + "start": { + "line": 0, + "utf16Column": 0 + }, + "end": { + "line": 0, + "utf16Column": 0 + } + }, + "newText": "x" + } + ] + } + ] + } + }, + { + "name": "a target outside the workspace rejects the whole edit", + "request": { + "platform": "posix", + "workspaceRoot": "/work/project", + "changes": { + "/work/project/a.go": [ + { + "range": { + "start": { + "line": 0, + "utf16Column": 0 + }, + "end": { + "line": 0, + "utf16Column": 0 + } + }, + "newText": "a" + } + ], + "/etc/hosts": [ + { + "range": { + "start": { + "line": 0, + "utf16Column": 0 + }, + "end": { + "line": 0, + "utf16Column": 0 + } + }, + "newText": "x" + } + ] + } + }, + "expected": { + "rejection": { + "code": "outsideWorkspace", + "path": "/etc/hosts" + }, + "targets": [] + } + }, + { + "name": "dot segments cannot escape the workspace", + "request": { + "platform": "posix", + "workspaceRoot": "/work/project", + "changes": { + "/work/project/../secret.go": [ + { + "range": { + "start": { + "line": 0, + "utf16Column": 0 + }, + "end": { + "line": 0, + "utf16Column": 0 + } + }, + "newText": "x" + } + ] + } + }, + "expected": { + "rejection": { + "code": "outsideWorkspace", + "path": "/work/project/../secret.go" + }, + "targets": [] + } + }, + { + "name": "a sibling directory sharing the root prefix is outside", + "request": { + "platform": "posix", + "workspaceRoot": "/work/project", + "changes": { + "/work/project2/a.go": [ + { + "range": { + "start": { + "line": 0, + "utf16Column": 0 + }, + "end": { + "line": 0, + "utf16Column": 0 + } + }, + "newText": "x" + } + ] + } + }, + "expected": { + "rejection": { + "code": "outsideWorkspace", + "path": "/work/project2/a.go" + }, + "targets": [] + } + }, + { + "name": "non-file URIs are outside the workspace", + "request": { + "platform": "posix", + "workspaceRoot": "/work/project", + "changes": { + "jdt://contents/rt.jar/java.lang/String.class": [ + { + "range": { + "start": { + "line": 0, + "utf16Column": 0 + }, + "end": { + "line": 0, + "utf16Column": 0 + } + }, + "newText": "x" + } + ] + } + }, + "expected": { + "rejection": { + "code": "outsideWorkspace", + "path": "jdt://contents/rt.jar/java.lang/String.class" + }, + "targets": [] + } + }, + { + "name": "a read-only open document rejects the whole edit", + "request": { + "platform": "posix", + "workspaceRoot": "/work/project", + "changes": { + "/work/project/a.go": [ + { + "range": { + "start": { + "line": 0, + "utf16Column": 0 + }, + "end": { + "line": 0, + "utf16Column": 0 + } + }, + "newText": "a" + } + ], + "/work/project/gen.go": [ + { + "range": { + "start": { + "line": 0, + "utf16Column": 0 + }, + "end": { + "line": 0, + "utf16Column": 0 + } + }, + "newText": "g" + } + ] + }, + "openDocuments": [ + { + "path": "/work/project/gen.go", + "isReadOnly": true + } + ] + }, + "expected": { + "rejection": { + "code": "readOnlyDocument", + "path": "/work/project/gen.go" + }, + "targets": [] + } + }, + { + "name": "targets without edits produce no effect", + "request": { + "platform": "posix", + "workspaceRoot": "/work/project", + "changes": { + "/work/project/a.go": [], + "/work/project/b.go": [ + { + "range": { + "start": { + "line": 0, + "utf16Column": 0 + }, + "end": { + "line": 0, + "utf16Column": 0 + } + }, + "newText": "b" + } + ] + } + }, + "expected": { + "rejection": null, + "targets": [ + { + "path": "/work/project/b.go", + "relativePath": "b.go", + "effect": "writeFile", + "edits": [ + { + "range": { + "start": { + "line": 0, + "utf16Column": 0 + }, + "end": { + "line": 0, + "utf16Column": 0 + } + }, + "newText": "b" + } + ] + } + ] + } + }, + { + "name": "Windows keys match open documents without case or separator differences and keep the host path", + "request": { + "platform": "windows", + "workspaceRoot": "C:\\Work\\Project", + "changes": { + "/C:/Work/Project/src/Main.java": [ + { + "range": { + "start": { + "line": 0, + "utf16Column": 0 + }, + "end": { + "line": 0, + "utf16Column": 0 + } + }, + "newText": "m" + } + ], + "file:///c%3A/work/project/README.md": [ + { + "range": { + "start": { + "line": 0, + "utf16Column": 0 + }, + "end": { + "line": 0, + "utf16Column": 0 + } + }, + "newText": "r" + } + ] + }, + "openDocuments": [ + { + "path": "C:\\Work\\Project\\src\\Main.java", + "isReadOnly": false + } + ] + }, + "expected": { + "rejection": null, + "targets": [ + { + "path": "c:/work/project/README.md", + "relativePath": "README.md", + "effect": "writeFile", + "edits": [ + { + "range": { + "start": { + "line": 0, + "utf16Column": 0 + }, + "end": { + "line": 0, + "utf16Column": 0 + } + }, + "newText": "r" + } + ] + }, + { + "path": "C:\\Work\\Project\\src\\Main.java", + "relativePath": "src/Main.java", + "effect": "updateDocument", + "edits": [ + { + "range": { + "start": { + "line": 0, + "utf16Column": 0 + }, + "end": { + "line": 0, + "utf16Column": 0 + } + }, + "newText": "m" + } + ] + } + ] + } + }, + { + "name": "keys naming one file merge in key order", + "request": { + "platform": "windows", + "workspaceRoot": "C:/Work/Project", + "changes": { + "C:/Work/Project/a.txt": [ + { + "range": { + "start": { + "line": 0, + "utf16Column": 0 + }, + "end": { + "line": 0, + "utf16Column": 0 + } + }, + "newText": "1" + } + ], + "c:\\work\\project\\a.txt": [ + { + "range": { + "start": { + "line": 0, + "utf16Column": 0 + }, + "end": { + "line": 0, + "utf16Column": 0 + } + }, + "newText": "2" + } + ] + } + }, + "expected": { + "rejection": null, + "targets": [ + { + "path": "C:/Work/Project/a.txt", + "relativePath": "a.txt", + "effect": "writeFile", + "edits": [ + { + "range": { + "start": { + "line": 0, + "utf16Column": 0 + }, + "end": { + "line": 0, + "utf16Column": 0 + } + }, + "newText": "1" + }, + { + "range": { + "start": { + "line": 0, + "utf16Column": 0 + }, + "end": { + "line": 0, + "utf16Column": 0 + } + }, + "newText": "2" + } + ] + } + ] + } + }, + { + "name": "a relative workspace root is invalid", + "request": { + "platform": "posix", + "workspaceRoot": "project", + "changes": { + "/work/project/a.go": [ + { + "range": { + "start": { + "line": 0, + "utf16Column": 0 + }, + "end": { + "line": 0, + "utf16Column": 0 + } + }, + "newText": "a" + } + ] + } + }, + "expectedError": "invalid_request" + } + ] +} diff --git a/shared/platform-feature-matrix/features/diagnostics-workspace.json b/shared/platform-feature-matrix/features/diagnostics-workspace.json index 341dfe576d..c5511f1d2c 100644 --- a/shared/platform-feature-matrix/features/diagnostics-workspace.json +++ b/shared/platform-feature-matrix/features/diagnostics-workspace.json @@ -10,7 +10,10 @@ "macos/Tests/LitheTests/ProblemsPanelPresentationTests.swift", "macos/Tests/LitheTests/MonacoWorkbenchSessionLifetimeTests.swift", "macos/Experiments/Monaco/workbench.integration.ts", - "macos/Sources/Lithe/Platform/MacOS/ProblemsDemo.swift" + "macos/Sources/Lithe/Platform/MacOS/ProblemsDemo.swift", + "macos/Sources/Lithe/Models/Editor/ProblemsQuery.swift", + "rust/lithe-core/src/lsp/host_policy/problems.rs", + "shared/fixtures/lsp/problems-query-v1.json" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -19,11 +22,15 @@ "evidence": [ "windows/tauri/src/features/diagnostics", "windows/tauri/src/features/logging", - "windows/tauri/src/ui/dialog.tsx" + "windows/tauri/src/ui/dialog.tsx", + "windows/tauri/src/features/diagnostics/utils/problems-query.ts", + "windows/tauri/src/features/diagnostics/utils/problems-query.test.ts", + "rust/lithe-core/src/lsp/host_policy/problems.rs", + "shared/fixtures/lsp/problems-query-v1.json" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Diagnostics", - "verification": "制造多个来源、多个文件的错误和警告,验证 Problems 的 File 随编辑器切换、关闭而更新,非文本编辑器显示打开文件提示,Project Errors 保留全部文件问题;检查无问题与严重程度过滤空状态,并按文件显示名称、路径和数量,紧凑问题行保留描述、行列和完整提示;在深浅主题及窄面板中检查标题、图标和悬停,并验证筛选、排序、定位和清除。检查左侧 View Options 与 Show Editor Preview;开启后单击及方向键更新右侧只读源码,双击和 Enter 定位主编辑器;拖动分栏、关闭再打开、切换文件和清空诊断,确认不抢焦点、不移动主编辑器 WebView、不接管保存屏障且会话随预览关闭释放。 在系统语言与应用语言不同时切换 English / 简体中文,检查 File、Project Errors、带文件名的空状态、过滤提示和预览失败文案跟随应用语言。 使用 Debug 的 --problems-demo,确认默认 File 显示 Main.java 的 8 条问题,切换 Project Errors 显示两个文件共 10 条问题;检查全部五种诊断级别、废弃与未使用标签、关联信息、长多行文案及 Unicode;点击后用上下键切换,确认只有行选择样式且没有列表外围蓝色焦点框,右侧不出现空查找浮层。退出后检查演示临时目录、进程清理,并比较 bundle 文件哈希不变。 检查 File 与 Project Errors 的全部级别图标使用 IDEA Community SVG,提示使用 intentionBulbGrey,未知级别使用 questionMark;在明暗主题核对对应变体。 文件分组复用 JavaFileIconResolver 显示类或接口图标;问题行的错误和警告使用 inspections 16×16 原始画布与内部留白,右上角统计继续使用 status 图标。 检查已有 Java 启动配置 sourcePath/mainClass 对应的文件显示原始 runnableMark 角标,普通类不显示;演示 Main.java 显示该角标。YAML 与 Java 类图标按主题加载上游明暗变体,application.yml 使用 YAMLFileType 的通用图标。 关闭源码预览后行选择背景仍保留,方向键选择离屏问题时列表自动滚动。 在文本文件、Agent Conversation 编辑器标签与原文本文件之间切换,确认 File 问题先清空再恢复;预览中不出现 Run/Debug 角标和动作,旧动作引用及原生 javaRunMarkerAction 不能启动进程。" + "verification": "制造多个来源、多个文件的错误和警告,验证 Problems 的 File 随编辑器切换、关闭而更新,非文本编辑器显示打开文件提示,Project Errors 保留全部文件问题;检查无问题与严重程度过滤空状态,并按文件显示名称、路径和数量,紧凑问题行保留描述、行列和完整提示;在深浅主题及窄面板中检查标题、图标和悬停,并验证筛选、排序、定位和清除。检查左侧 View Options 与 Show Editor Preview;开启后单击及方向键更新右侧只读源码,双击和 Enter 定位主编辑器;拖动分栏、关闭再打开、切换文件和清空诊断,确认不抢焦点、不移动主编辑器 WebView、不接管保存屏障且会话随预览关闭释放。 在系统语言与应用语言不同时切换 English / 简体中文,检查 File、Project Errors、带文件名的空状态、过滤提示和预览失败文案跟随应用语言。 使用 Debug 的 --problems-demo,确认默认 File 显示 Main.java 的 8 条问题,切换 Project Errors 显示两个文件共 10 条问题;检查全部五种诊断级别、废弃与未使用标签、关联信息、长多行文案及 Unicode;点击后用上下键切换,确认只有行选择样式且没有列表外围蓝色焦点框,右侧不出现空查找浮层。退出后检查演示临时目录、进程清理,并比较 bundle 文件哈希不变。 检查 File 与 Project Errors 的全部级别图标使用 IDEA Community SVG,提示使用 intentionBulbGrey,未知级别使用 questionMark;在明暗主题核对对应变体。 文件分组复用 JavaFileIconResolver 显示类或接口图标;问题行的错误和警告使用 inspections 16×16 原始画布与内部留白,右上角统计继续使用 status 图标。 检查已有 Java 启动配置 sourcePath/mainClass 对应的文件显示原始 runnableMark 角标,普通类不显示;演示 Main.java 显示该角标。YAML 与 Java 类图标按主题加载上游明暗变体,application.yml 使用 YAMLFileType 的通用图标。 关闭源码预览后行选择背景仍保留,方向键选择离屏问题时列表自动滚动。 在文本文件、Agent Conversation 编辑器标签与原文本文件之间切换,确认 File 问题先清空再恢复;预览中不出现 Run/Debug 角标和动作,旧动作引用及原生 javaRunMarkerAction 不能启动进程。 #1662 LSP-17:两端的过滤、排序、分组和计数统一由 Core 的 lsp.problems.query 决定。默认按文件分组、文件内先按严重程度再按行列;文件按工作区相对路径的自然顺序排列(File2 在 File10 之前,大小写不敏感),未知严重程度排在最后。macOS 不再在文件内只按行列排序,Windows 不再用 localeCompare 比较绝对路径。在两端制造同名数字文件和多种严重程度的问题,确认顺序、计数、严重程度过滤、仅当前文件和搜索结果一致。" } diff --git a/shared/platform-feature-matrix/features/editor-text-tabs.json b/shared/platform-feature-matrix/features/editor-text-tabs.json index 20805a185f..9fde37a387 100644 --- a/shared/platform-feature-matrix/features/editor-text-tabs.json +++ b/shared/platform-feature-matrix/features/editor-text-tabs.json @@ -16,10 +16,13 @@ "macos/Tests/LitheTests/EditorTabLayoutTests.swift", ".agents/notes/implemented/architecture/2026-10-12-editor-tab-split-docking.md", "macos/Sources/LitheGitModule/Models/GitRepositoryDiffSession.swift", - "macos/Sources/Lithe/Views/Diff/RepositoryDiffSessionView.swift" + "macos/Sources/Lithe/Views/Diff/RepositoryDiffSessionView.swift", + "rust/lithe-core/src/editor/workbench_state.rs", + "shared/fixtures/editor/workbench-state-v1.json" ], "implementationStatus": "implemented", - "verificationStatus": "pending" + "verificationStatus": "pending", + "notes": " 编辑分组的标签归属、分屏与合并、组内选中和聚焦组改由 Rust Core editor.workbenchState.reduce 计算;关闭组内选中的标签后选中接替原位置的标签(越过末尾选新的末尾),不再回到第一个标签。" }, "windows": { "evidence": [ @@ -35,9 +38,10 @@ "windows/tauri/src/features/editor/stores/buffer.store.ts" ], "implementationStatus": "implemented", - "verificationStatus": "pending" + "verificationStatus": "pending", + "notes": "Windows 窗格树仍由 windows/tauri/src/features/panes/utils/pane-tree.ts 计算(同一缓冲区可同时位于多个窗格,含预览、固定、锁定和底部窗格),尚未接入 editor.workbenchState.reduce;其关闭选中规则(接替原位置)与 Core 一致,聚焦组回退仍按最近使用的窗格。" }, "owner": "Editor", - "verification": "打开、编辑、保存、关闭和恢复多个文本文件,确认光标、脏状态和标签状态;在 macOS 打开无扩展名文本文件,确认标签与项目树图标一致;从外部改为二进制后折叠并展开项目树,确认两处图标更新一致。 macOS 验证所有 Repository Diff 与文件/媒体/终端标签共用排序,切换保留 Diff、关闭命令关闭选中内容,标题按当前文件变化;共享 Islands 圆角标签在编辑区/工具窗口鼠标、键盘和窗口失活间呈现蓝色活动或灰色失焦状态,卸载移除焦点观察器。完整工作区视觉验收 pending。 从 Diff 设置菜单关闭时也清理混合标签槽并恢复原文档;通过原生窗口鼠标拖动检查普通文件跨 Repository Diff 重排。 右键标签页确认关闭分组排在其他操作之前,其中“关闭已保存”关闭无修改且未固定的标签并保留有未保存修改的标签,全程不弹保存确认;在 macOS 打开无扩展名文本文件,确认标签与项目树图标一致;从外部改为二进制后折叠并展开项目树,确认两处图标更新一致。 在 Windows 确认编辑器标签栏左上角不再有前进/后退箭头,后退/前进改由 Navigate 菜单与 Ctrl+Alt+←/→ 提供;选中标签为 IntelliJ Islands 圆角块(浅蓝底加蓝边),焦点移到终端、项目树或另一分栏时变为灰底灰边;长文件名标签完整显示不截断。 macOS 工作区、linked worktree、目录、冲突、行标记、分支/引用、历史提交和 Stash/Shelf Diff 均显示普通共享编辑标签;切回文件/媒体/终端/Agent 不清空比较,重新进入不重复添加标签。重排、拖动分屏与关闭空组合并沿用普通分组逻辑;关闭当前项只关闭活动标签,后台 Diff 不截获文件关闭命令;从 Diff 按钮、设置菜单或 ⌘W 关闭时只清理目标比较状态并拒绝迟到结果,关闭活动 Diff 优先恢复本组标签。 Windows 用同一鼠标滚轮事件对照普通编辑器、笔记本代码区和 Diff,确认共用两倍灵敏度,平滑滚动开关仍生效,滚动边界及标签位置恢复正确;真实整机 IDEA 滚动帧性能对照仍待验收。 macOS 标签拖到编辑区右侧或底部显示对应半区预览,释放后移动到独立标签组;继续拆分、组间移动、关闭空组合并、各组选择和聚焦命令正确,原文档和终端会话不复制,Esc 与无效释放保持原布局。浮动标签使用灰色卡片,无激活蓝底和关闭按钮。空编辑区仅显示 Search Everywhere 与 Find in Files,按当前 Keymap 和自定义配置生成快捷键,16pt 普通字重及 IDEA 明暗提示色;窄窗口裁剪文字而不缩字。 macOS 点击编辑器正文下方空白后,仅移动鼠标到文件、Diff、终端标签及标签栏空白处即恢复箭头,不要求再次点击;返回正文仍使用文本光标,单行、多行及分组标签栏均检查。 与数据库标签集成:文件切换和分组保留数据库概览/查询标签;数据库标签组内重排有效,跨组移动不改变布局,新增数据库标签进入既有数据库分组,避免共享上下文同时渲染为两个不同查询。 普通文本标签连续移到右侧、底部及原组后,撤销/重做移动前后输入;保持同一 WebView 和 Monaco 模型,不重建历史。松开拖动后只有目标文档取得原生焦点,后台正文没有闪烁光标;输入、撤销、查找和保存均作用于目标文件。视口尺寸变化允许滚动范围正常限制。 macOS 横向溢出的隐藏标签不截获相邻分组按下/拖动,部分可见标签仅在视口内可点击;第二、第三行释放执行重排而非分屏,正文边缘仍可分屏。文档→另一组终端或数据库→点击文档正文后,查找及关闭作用于原文档,其他标签保留。 A 与终端 T 分组,分别在挂载前及挂载排队后选 T,验证 A 异步 attach 不夺取 T 的 first responder;切回 A 后挂载可正常聚焦。单标签组右键向右分屏禁用,多文档或文档加终端组启用并实际分屏。 Windows 点击标签栏“新建标签页”按钮或按 Ctrl+N/Ctrl+T,确认打开一个可编辑、可保存的空白未命名编辑器(Untitled/未命名),并出现在标签栏;连续新建依次得到 Untitled-2、Untitled-3;关闭其中编号较小的未命名标签后再次新建,确认仍然打开新的空白页而不是只聚焦遗留标签;在未命名页输入文字后标签标记为未保存,关闭时先弹出保存确认;标签数达到上限时再新建,确认有未保存修改的标签不会被自动驱逐。 macOS测试源码文件标签使用IDEA FileColor.Green(深#1C261C/浅#EDFAEF),选中内缩3pt,活动100%/非活动40%,保留原边框与其他标签行为。优先按Maven导入的项目及子模块源码根角色判定,覆盖自定义测试目录、辅助类和主源码反例,无根信息时使用已有JDT测试元数据,不按文件名推测。EditorTabColorTests验证实际明暗及活动/非活动渲染,完整应用视觉验收仍pending。 多 Diff 回归:同时打开两个文件和同一文件的不同提交,检查独立标签、同一比较去重、切换保留内容/滚动、分屏各自显示、关闭一个不影响另一加载、Git Log 换提交不改变旧比较及工作区退出释放全部会话。", + "verification": "打开、编辑、保存、关闭和恢复多个文本文件,确认光标、脏状态和标签状态;在 macOS 打开无扩展名文本文件,确认标签与项目树图标一致;从外部改为二进制后折叠并展开项目树,确认两处图标更新一致。 macOS 验证所有 Repository Diff 与文件/媒体/终端标签共用排序,切换保留 Diff、关闭命令关闭选中内容,标题按当前文件变化;共享 Islands 圆角标签在编辑区/工具窗口鼠标、键盘和窗口失活间呈现蓝色活动或灰色失焦状态,卸载移除焦点观察器。完整工作区视觉验收 pending。 从 Diff 设置菜单关闭时也清理混合标签槽并恢复原文档;通过原生窗口鼠标拖动检查普通文件跨 Repository Diff 重排。 右键标签页确认关闭分组排在其他操作之前,其中“关闭已保存”关闭无修改且未固定的标签并保留有未保存修改的标签,全程不弹保存确认;在 macOS 打开无扩展名文本文件,确认标签与项目树图标一致;从外部改为二进制后折叠并展开项目树,确认两处图标更新一致。 在 Windows 确认编辑器标签栏左上角不再有前进/后退箭头,后退/前进改由 Navigate 菜单与 Ctrl+Alt+←/→ 提供;选中标签为 IntelliJ Islands 圆角块(浅蓝底加蓝边),焦点移到终端、项目树或另一分栏时变为灰底灰边;长文件名标签完整显示不截断。 macOS 工作区、linked worktree、目录、冲突、行标记、分支/引用、历史提交和 Stash/Shelf Diff 均显示普通共享编辑标签;切回文件/媒体/终端/Agent 不清空比较,重新进入不重复添加标签。重排、拖动分屏与关闭空组合并沿用普通分组逻辑;关闭当前项只关闭活动标签,后台 Diff 不截获文件关闭命令;从 Diff 按钮、设置菜单或 ⌘W 关闭时只清理目标比较状态并拒绝迟到结果,关闭活动 Diff 优先恢复本组标签。 Windows 用同一鼠标滚轮事件对照普通编辑器、笔记本代码区和 Diff,确认共用两倍灵敏度,平滑滚动开关仍生效,滚动边界及标签位置恢复正确;真实整机 IDEA 滚动帧性能对照仍待验收。 macOS 标签拖到编辑区右侧或底部显示对应半区预览,释放后移动到独立标签组;继续拆分、组间移动、关闭空组合并、各组选择和聚焦命令正确,原文档和终端会话不复制,Esc 与无效释放保持原布局。浮动标签使用灰色卡片,无激活蓝底和关闭按钮。空编辑区仅显示 Search Everywhere 与 Find in Files,按当前 Keymap 和自定义配置生成快捷键,16pt 普通字重及 IDEA 明暗提示色;窄窗口裁剪文字而不缩字。 macOS 点击编辑器正文下方空白后,仅移动鼠标到文件、Diff、终端标签及标签栏空白处即恢复箭头,不要求再次点击;返回正文仍使用文本光标,单行、多行及分组标签栏均检查。 与数据库标签集成:文件切换和分组保留数据库概览/查询标签;数据库标签组内重排有效,跨组移动不改变布局,新增数据库标签进入既有数据库分组,避免共享上下文同时渲染为两个不同查询。 普通文本标签连续移到右侧、底部及原组后,撤销/重做移动前后输入;保持同一 WebView 和 Monaco 模型,不重建历史。松开拖动后只有目标文档取得原生焦点,后台正文没有闪烁光标;输入、撤销、查找和保存均作用于目标文件。视口尺寸变化允许滚动范围正常限制。 macOS 横向溢出的隐藏标签不截获相邻分组按下/拖动,部分可见标签仅在视口内可点击;第二、第三行释放执行重排而非分屏,正文边缘仍可分屏。文档→另一组终端或数据库→点击文档正文后,查找及关闭作用于原文档,其他标签保留。 A 与终端 T 分组,分别在挂载前及挂载排队后选 T,验证 A 异步 attach 不夺取 T 的 first responder;切回 A 后挂载可正常聚焦。单标签组右键向右分屏禁用,多文档或文档加终端组启用并实际分屏。 Windows 点击标签栏“新建标签页”按钮或按 Ctrl+N/Ctrl+T,确认打开一个可编辑、可保存的空白未命名编辑器(Untitled/未命名),并出现在标签栏;连续新建依次得到 Untitled-2、Untitled-3;关闭其中编号较小的未命名标签后再次新建,确认仍然打开新的空白页而不是只聚焦遗留标签;在未命名页输入文字后标签标记为未保存,关闭时先弹出保存确认;标签数达到上限时再新建,确认有未保存修改的标签不会被自动驱逐。 macOS测试源码文件标签使用IDEA FileColor.Green(深#1C261C/浅#EDFAEF),选中内缩3pt,活动100%/非活动40%,保留原边框与其他标签行为。优先按Maven导入的项目及子模块源码根角色判定,覆盖自定义测试目录、辅助类和主源码反例,无根信息时使用已有JDT测试元数据,不按文件名推测。EditorTabColorTests验证实际明暗及活动/非活动渲染,完整应用视觉验收仍pending。 多 Diff 回归:同时打开两个文件和同一文件的不同提交,检查独立标签、同一比较去重、切换保留内容/滚动、分屏各自显示、关闭一个不影响另一加载、Git Log 换提交不改变旧比较及工作区退出释放全部会话。 macOS 编辑分组:在一个分组打开 A、B、C,选中 B 后关闭 B,确认选中 C;再关闭 C 选中 A;拖动标签分屏、移回并合并空组,确认右键“Open in Right Split”在单标签组禁用。", "notes": "Windows 编辑器标签按 IntelliJ Islands IslandsTabPainter 绘制:28px 圆角块、6px 圆角、选中填充加 1px 边框、标签间距 8px、无宽度上限;是否置灰按焦点是否位于该标签组判断(对应 EditorTabbedContainer 的 isFocusAncestor)。标签栏不提供前进/后退按钮(IntelliJ 新 UI 同样没有)。Islands 精确色值只写在 Lithe 主题中,其他主题由自身主色、前景和边框色推导。 macOS 仓库 Diff 不再在根编辑区覆盖标签栏;采用带 UUID 的独立 repositoryDiff 标签及 GitRepositoryDiffSession:不同文件、提交、目录和引用比较可以并存,同一比较复用已有标签,服务与写入工作流仍由 GitFeatureModel 共享。EditorTabOrderFeatureModelTests 在深浅主题覆盖八种入口的实际原生分组、切换、分屏与关闭,GitModuleTests 覆盖异步结果隔离;Windows 现有 working-tree/commit-file-preview 已走普通标签,仅核对源码,完整两端工作区验收仍 pending。" } diff --git a/shared/platform-feature-matrix/features/lsp-completion-hover.json b/shared/platform-feature-matrix/features/lsp-completion-hover.json index 57f0698fb6..a9c18890ba 100644 --- a/shared/platform-feature-matrix/features/lsp-completion-hover.json +++ b/shared/platform-feature-matrix/features/lsp-completion-hover.json @@ -10,7 +10,10 @@ "rust/lithe-core/src/lsp/interface/engine.rs", "shared/fixtures/lsp/java-completion-v1.json", "shared/fixtures/lsp", - "shared/fixtures/lsp/completion-refresh-v1.json" + "shared/fixtures/lsp/completion-refresh-v1.json", + "rust/lithe-core/src/lsp/host_policy/completion_merge.rs", + "shared/fixtures/lsp/completion-merge-v1.json", + "macos/Tests/LitheTests/LanguageFeatureProviderTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -22,11 +25,13 @@ "rust/lithe-core/src/lsp/interface/engine.rs", "shared/fixtures/lsp/java-completion-v1.json", "shared/fixtures/lsp", - "shared/fixtures/lsp/completion-refresh-v1.json" + "shared/fixtures/lsp/completion-refresh-v1.json", + "rust/lithe-core/src/lsp/host_policy/completion_merge.rs", + "shared/fixtures/lsp/completion-merge-v1.json" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Language Tooling", - "verification": "在相同 Java fixture 中验证补全、Hover、排序、超时和空结果。Java 构造器和方法补全使用 JDT LS 的 guessMethodArguments=off:输入 new No 后接受 Node 候选应得到 Node(),光标在括号内;方法调用同样不插入参数名,构造器重载候选由 JDT LS 合并为类型候选。验证 initialize、didChangeConfiguration 与 workspace/configuration 三个配置入口一致,父级和叶级 section 均保留插件 favoriteStaticMembers、filteredTypes 等其他补全配置,其他语言 snippet、Java 方法补全签名展示及自动导入仍正常。 连续输入时保留 isIncomplete 并通过 LSP triggerKind=3 重新请求候选;验证空未完整列表、完整/空响应、触发字符、快速修改及文档关闭/会话替换后的过期结果。" + "verification": "在相同 Java fixture 中验证补全、Hover、排序、超时和空结果。Java 构造器和方法补全使用 JDT LS 的 guessMethodArguments=off:输入 new No 后接受 Node 候选应得到 Node(),光标在括号内;方法调用同样不插入参数名,构造器重载候选由 JDT LS 合并为类型候选。验证 initialize、didChangeConfiguration 与 workspace/configuration 三个配置入口一致,父级和叶级 section 均保留插件 favoriteStaticMembers、filteredTypes 等其他补全配置,其他语言 snippet、Java 方法补全签名展示及自动导入仍正常。 连续输入时保留 isIncomplete 并通过 LSP triggerKind=3 重新请求候选;验证空未完整列表、完整/空响应、触发字符、快速修改及文档关闭/会话替换后的过期结果。 #1662 LSP-09:多个补全来源的合并由 Core 的 lsp.mergeCompletions 决定:同一来源的候选全部保留(同名不同签名的重载不再被丢弃),低优先级来源中与高优先级来源同 label 的候选被遮蔽。Windows 只有一个补全来源,原样显示服务器列表,不调用该命令。macOS 删除私有的 Go/Swift/Rust/Python/JS/TS 关键词表,关键词改由语言服务器或插件提供,与 Windows 一致。在 macOS 打开有重载方法的 Java/TS 文件,确认同名重载全部出现;未运行语言服务器的 Go 文件不再出现内置关键词候选,当前文件标识符仍会出现。" } diff --git a/shared/platform-feature-matrix/features/lsp-lifecycle.json b/shared/platform-feature-matrix/features/lsp-lifecycle.json index 16feebd2b7..ca38f3b3ef 100644 --- a/shared/platform-feature-matrix/features/lsp-lifecycle.json +++ b/shared/platform-feature-matrix/features/lsp-lifecycle.json @@ -32,7 +32,11 @@ "macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift", "rust/lithe-core/src/lsp/interface/process.rs", "rust/lithe-core/src/lsp/interface/remote_process.rs", - "rust/lithe-core/src/lsp/interface/scripted.rs" + "rust/lithe-core/src/lsp/interface/scripted.rs", + "rust/lithe-core/src/lsp/host_policy/activation.rs", + "shared/fixtures/lsp/activation-policy-v1.json", + "macos/Sources/Lithe/Core/Rust/RustLanguageToolingPolicy.swift", + "macos/Sources/LitheCoreContracts/Language/LanguageToolingPolicyCore.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -51,12 +55,16 @@ "shared/fixtures/lsp/jdt-workspace-fingerprint-v1.json", ".agents/notes/implemented/feature/2026-10-11-gradle-project-import-settings.md", "rust/lithe-core/src/lsp/interface/engine.rs", - "windows/tauri/src-tauri/src/lsp/jdt_workspace.rs" + "windows/tauri/src-tauri/src/lsp/jdt_workspace.rs", + "rust/lithe-core/src/lsp/host_policy/activation.rs", + "shared/fixtures/lsp/activation-policy-v1.json", + "windows/tauri/src/features/editor/lsp/language-server-activation-policy.ts", + "windows/tauri/src/features/editor/lsp/language-server-activation-policy.test.ts" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Language Tooling", - "verification": "在两端启动真实 JDTLS,验证项目准备、重启、超时、取消和资源清理,并确认典型工作流后已安装程序包(macOS app bundle、Windows 安装目录)的文件清单不变。 验证 Maven 导入连续 45 秒无进度只警告、之后 ServiceReady 可正常就绪,且 10 分钟总期限、显式失败和取消仍有效。 检查 JDT LS 启动参数的最大堆高于 1.5 GB,并在待导入模块超过 50 个、且子模块 Maven 坐标重复的聚合工程中确认工作区准备不再以 serviceReadyFailed 终止整个 Java 服务。 macOS #1524:暂停旧模块图清理后请求普通语言能力、后台 Java 维护及文档激活,确认等待结束后才读取缓存或启动新会话;等待期间取消不得重新激活;匹配 operation 的停止应清除 preparing,旧 operation 的停止不得影响新准备。 macOS #1523:通过插件入口启动 Java,确认宿主集成、Maven/Gradle 与缓存参数不丢失,连续失败最多自动启动三次,显式重启可恢复,且包内文件哈希保持不变。 macOS 最终关闭场景:暂停旧模块清理、排队文档和维护激活、关闭项目会话、释放清理;即使 AppModel 保留工作区 URL 与打开文档,也不能重新发布语言能力或启动 LSP。 macOS 生产 RustCoreBridge 与本地协议进程验证插件身份、host services、配置值和 JDT 缓存启动上下文;修复后的 Java ready 与 getOrder 定义导航截图见 evidence。 PR #1573:在两端打开已导入的 Maven 工程,修改 pom.xml(如增加依赖)后确认 JDT -data 目录不变、jdtls 增量更新而不是重新导入整个工作区;Windows 修改 gradle-wrapper.properties 的 distributionUrl 后确认切换到新的 -data 目录。检查 JDT LS 进程参数包含 -XX:+UseParallelGC、-XX:GCTimeRatio=4、-XX:AdaptiveSizePolicyWeight=90、-Xlog:disable 与 -DDetectVMInstallationsJob.disabled=true,导入完成后常驻内存回落。macOS 在大型工程导入期间打字、滚动与补全不掉帧(事件 drain 已移出主线程)。 Core:cargo test -p lithe-core --lib failed_initialize、failed_start 与 invalid_initial_configuration_is_rejected_before_launch,确认启动失败的进程确认退出后才允许下一次启动,清理不了时拒绝新启动。", + "verification": "在两端启动真实 JDTLS,验证项目准备、重启、超时、取消和资源清理,并确认典型工作流后已安装程序包(macOS app bundle、Windows 安装目录)的文件清单不变。 验证 Maven 导入连续 45 秒无进度只警告、之后 ServiceReady 可正常就绪,且 10 分钟总期限、显式失败和取消仍有效。 检查 JDT LS 启动参数的最大堆高于 1.5 GB,并在待导入模块超过 50 个、且子模块 Maven 坐标重复的聚合工程中确认工作区准备不再以 serviceReadyFailed 终止整个 Java 服务。 macOS #1524:暂停旧模块图清理后请求普通语言能力、后台 Java 维护及文档激活,确认等待结束后才读取缓存或启动新会话;等待期间取消不得重新激活;匹配 operation 的停止应清除 preparing,旧 operation 的停止不得影响新准备。 macOS #1523:通过插件入口启动 Java,确认宿主集成、Maven/Gradle 与缓存参数不丢失,连续失败最多自动启动三次,显式重启可恢复,且包内文件哈希保持不变。 macOS 最终关闭场景:暂停旧模块清理、排队文档和维护激活、关闭项目会话、释放清理;即使 AppModel 保留工作区 URL 与打开文档,也不能重新发布语言能力或启动 LSP。 macOS 生产 RustCoreBridge 与本地协议进程验证插件身份、host services、配置值和 JDT 缓存启动上下文;修复后的 Java ready 与 getOrder 定义导航截图见 evidence。 PR #1573:在两端打开已导入的 Maven 工程,修改 pom.xml(如增加依赖)后确认 JDT -data 目录不变、jdtls 增量更新而不是重新导入整个工作区;Windows 修改 gradle-wrapper.properties 的 distributionUrl 后确认切换到新的 -data 目录。检查 JDT LS 进程参数包含 -XX:+UseParallelGC、-XX:GCTimeRatio=4、-XX:AdaptiveSizePolicyWeight=90、-Xlog:disable 与 -DDetectVMInstallationsJob.disabled=true,导入完成后常驻内存回落。macOS 在大型工程导入期间打字、滚动与补全不掉帧(事件 drain 已移出主线程)。 Core:cargo test -p lithe-core --lib failed_initialize、failed_start 与 invalid_initial_configuration_is_rejected_before_launch,确认启动失败的进程确认退出后才允许下一次启动,清理不了时拒绝新启动。 #1662 LSP-08:启动失败后的自动重试由 Core 的 lsp.activationPolicy 决定,时间使用平台单调时钟。每次失败后自动启动等待 5 秒;连续失败 3 次后停止自动启动,只能显式重启或修复后重试;会话就绪、显式启动或停止会清空记录。macOS 新增失败后 5 秒冷却(此前可立即重试),Windows 新增 3 次上限(此前每 5 秒无限重试)。在两端用无法启动的语言服务器打开文件,确认 5 秒内不重复启动、第 3 次失败后不再自动启动、显式重启后恢复。", "notes": "JDT LS 统一以 -Xmx2048m 启动(#1156):打包的 1.61.0 在待导入模块超过 50 个且最大堆不超过 1.5 GB 时会走带缺陷的分批导入,重复 Maven 坐标会让 null 工程进入 IProject.open 并终止整个 Java 服务;上游修复进入下个里程碑前用这条堆上限规避。代价是语言服务 JVM 的堆上限从 1 GB 提到 2 GB,-Xms 仍为 256m。坐标完全相同的模块仍只有第一个成为 Java 工程,其余由 m2e 跳过。 JDT -data 工作区 key 只按构建文件是否存在、直接 Maven 模块、JDT LS 版本(以及 Windows 的 Gradle wrapper distributionUrl)计算,不再包含修改时间与大小:jdtls 自己检测 pom 与 Gradle 脚本的修改并增量更新。升级到该版本后每个 Java 工程首次打开会重建一次索引。JDT LS 额外以 vscode-java 与上游 CI 的 GC/日志默认值启动,插件同键参数优先。 #1478 拆分(Core):启动失败但已注册的会话进入内部积压,下一次启动前最多再 dispose 一次,确认根进程退出后才释放;仍清理不了时以 ProcessFailed 拒绝新启动,避免同一工作区残留多个语言服务器。非法初始配置在启动进程前拒绝。destroy 先在 2 秒内确认进程退出再移除会话:非 Windows 重复 kill 并轮询退出状态,Windows 等进程树终止,远程会话以强制 PROCESS_KILL 回执为准。macOS 仍在终止事件后调用一次 lsp.destroyServer 并忽略错误;目前只有 Core 单测证据,macOS 真实 UI 流程未复验。" } diff --git a/shared/platform-feature-matrix/features/plugin-host-editor-file-window-services.json b/shared/platform-feature-matrix/features/plugin-host-editor-file-window-services.json index 2c0401fbe1..0a7afceb01 100644 --- a/shared/platform-feature-matrix/features/plugin-host-editor-file-window-services.json +++ b/shared/platform-feature-matrix/features/plugin-host-editor-file-window-services.json @@ -28,7 +28,9 @@ "macos/Sources/Lithe/Application/Features/PluginWindowHostService.swift", "macos/Tests/LitheTests/PluginPublicHostAPITests.swift", "macos/Experiments/Monaco/workbench.integration.ts", - "macos/Tests/LitheTests/PluginHostProcessIntegrationTests.swift" + "macos/Tests/LitheTests/PluginHostProcessIntegrationTests.swift", + "rust/lithe-core/src/editor/text_edit_plan.rs", + "shared/fixtures/editor/text-edit-plan-v1.json" ] }, "windows": { @@ -40,5 +42,5 @@ }, "owner": "Extensions", "verification": "macOS 单测:scripts/macos/test/test-macos.sh --filter \"EditorTextEditPlannerTests|EditorDocumentServiceTests|PluginWorkspaceFileServiceTests|PluginHostEditorServiceTests|PluginHostBrowserServiceTests|PluginHostMessageServiceTests|HTMLPreviewBridgeIsolationTests\";前端:frontend/editor 下运行 plugin-edits.test.ts。服务接入宿主分发、对插件开放后,按计划 §7 用三个验收插件(选区转换器、分页文件浏览器、HTML 预览启动器)在 debug 应用中原生验收:版本冲突与 IME 组字时编辑被拒、撤销一步回退整批编辑;符号链接与 .. 越界被拒、远程工作区返回 authorityUnsupported;后台窗口打开文件不抢焦点、消息限频生效;验收后关闭应用并确认无残留进程。 显式设置 LITHE_PLUGIN_HOST_TEST_NODE 后运行 PluginHostProcessIntegrationTests,验证真实 Node 后端经 Core 请求宿主、编辑 dirty 缓冲区、退出清理及插件包字节不变;scripts/macos/probe/probe-macos-monaco.sh --workbench-tests 验证真实 WebKit 的整批编辑与一次撤销。", - "notes": "公共方法已注册到 Core 与 Mac dispatcher;新增权限生成逐方法授权,不扩大 workspace.metadata。窗口入口统一解析 PluginWorkspaceContext,内部类型仅为 owner 适配值。HTML 预览显式设置呈现模式,支持 previewOnly;editor.open 返回 documentID/version。文件 lease 绑定 contextID,不用稳定 rootID 作为关闭边界。findText 的上游只给行号,range 如实为 null;纯命令通过 ui:4 开放。不含 Windows 实现。保留 pending,直到完成真实安装插件的整机验收。 纯命令入口同时修复命令面板鼠标点击禁用行仍执行的问题;回归由现有 PluginUIIconTests 覆盖。" + "notes": "公共方法已注册到 Core 与 Mac dispatcher;新增权限生成逐方法授权,不扩大 workspace.metadata。窗口入口统一解析 PluginWorkspaceContext,内部类型仅为 owner 适配值。HTML 预览显式设置呈现模式,支持 previewOnly;editor.open 返回 documentID/version。文件 lease 绑定 contextID,不用稳定 rootID 作为关闭边界。findText 的上游只给行号,range 如实为 null;纯命令通过 ui:4 开放。不含 Windows 实现。保留 pending,直到完成真实安装插件的整机验收。 纯命令入口同时修复命令面板鼠标点击禁用行仍执行的问题;回归由现有 PluginUIIconTests 覆盖。 文档编辑计划(行索引、范围/代理对/重叠校验、稳定顺序与换行规范化)改由 Core 的 editor.planTextEdits 统一实现,并与 lsp.applyTextEdits 共用行索引、排序与重叠规则(CR/LF/CRLF 均为换行;同位置插入保留调用顺序;替换范围起点的插入按相邻处理,与批内顺序无关);macOS 删除 Swift 私有规划器,Windows 尚无文档编辑插件 API,接入时复用同一命令与 fixture。" } diff --git a/windows/tauri/src/features/diagnostics/components/diagnostics-pane.tsx b/windows/tauri/src/features/diagnostics/components/diagnostics-pane.tsx index f227d25f66..183ccb285f 100644 --- a/windows/tauri/src/features/diagnostics/components/diagnostics-pane.tsx +++ b/windows/tauri/src/features/diagnostics/components/diagnostics-pane.tsx @@ -20,6 +20,7 @@ import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } fro import { useTranslation } from "@/i18n/locale-provider"; import { LspClient } from "@/features/editor/lsp/lsp-client"; import { useBufferStore } from "@/features/editor/stores/buffer.store"; +import { logger } from "@/features/editor/utils/logger"; import { FileNavigatorSidebar, type FileNavigatorItem, @@ -44,6 +45,13 @@ import { SearchPopover } from "@/ui/search"; import { cn } from "@/utils/cn"; import { getBaseName, getRelativePath, normalizePath } from "@/utils/path-helpers"; import type { Diagnostic, DiagnosticCodeAction } from "../types/diagnostics.types"; +import { + EMPTY_PROBLEMS_VIEW, + type ProblemsGroupBy, + type ProblemsQueryView, + type ProblemsSortBy, + queryProblems, +} from "../utils/problems-query"; interface DiagnosticsPaneProps { diagnostics: Diagnostic[]; @@ -56,8 +64,8 @@ interface DiagnosticsPaneProps { isFullScreen?: boolean; } -type GroupBy = "severity" | "file" | "none"; -type SortBy = "severity" | "file" | "position"; +type GroupBy = ProblemsGroupBy; +type SortBy = ProblemsSortBy; type FilterMenuType = "filters"; @@ -98,12 +106,6 @@ const SORT_OPTIONS: Array<{ value: SortBy; labelKey: string }> = [ { value: "position", labelKey: "diagnostics.sortByPosition" }, ]; -const SEVERITY_ORDER: Record = { - error: 0, - warning: 1, - info: 2, -}; - const SEVERITY_LABEL_KEY: Record = { error: "diagnostics.errors", warning: "diagnostics.warnings", @@ -145,14 +147,6 @@ const getDiagnosticNavigatorPath = (filePath: string, rootFolderPath: string | u return normalizePath(filePath); }; -const getHighestSeverity = ( - counts: Record, -): Diagnostic["severity"] => { - if (counts.error > 0) return "error"; - if (counts.warning > 0) return "warning"; - return "info"; -}; - const buildDiagnosticKey = (diagnostic: Diagnostic) => { return [ diagnostic.filePath, @@ -315,170 +309,81 @@ const DiagnosticsPane = ({ lspClient, ]); - const filteredDiagnostics = useMemo(() => { - const normalizedQuery = searchQuery.trim().toLowerCase(); - const normalizedSourceFilter = sourceFilter?.toLowerCase() ?? null; - - const filtered = diagnostics.filter((diagnostic) => { - if (!severityFilter[diagnostic.severity]) return false; - - if ( - preferences.onlyCurrentFile && - activeFilePath && - diagnostic.filePath.toLowerCase() !== activeFilePath.toLowerCase() - ) { - return false; - } - - if (normalizedSourceFilter) { - const source = diagnostic.source?.toLowerCase() || ""; - if (source !== normalizedSourceFilter) { - return false; - } - } - - if (!normalizedQuery) return true; - - const haystack = [ - diagnostic.message, - diagnostic.source || "", - diagnostic.code || "", - diagnostic.filePath, - `${diagnostic.line + 1}:${diagnostic.column + 1}`, - ] - .join(" ") - .toLowerCase(); - - return haystack.includes(normalizedQuery); - }); - - return filtered.sort((left, right) => { - if (preferences.sortBy === "severity") { - const severityDiff = SEVERITY_ORDER[left.severity] - SEVERITY_ORDER[right.severity]; - if (severityDiff !== 0) return severityDiff; - } - - if (preferences.sortBy === "file" || preferences.sortBy === "severity") { - const fileDiff = left.filePath.localeCompare(right.filePath); - if (fileDiff !== 0) return fileDiff; - } - - const lineDiff = left.line - right.line; - if (lineDiff !== 0) return lineDiff; - - return left.column - right.column; - }); - }, [activeFilePath, diagnostics, preferences, searchQuery, severityFilter, sourceFilter]); - - const groupedDiagnostics = useMemo(() => { - if (preferences.groupBy === "none") { - return [ - { - id: "all", - label: t("diagnostics.all"), - items: filteredDiagnostics, - }, - ]; - } - - if (preferences.groupBy === "severity") { - return ["error", "warning", "info"] - .map((severity) => { - const items = filteredDiagnostics.filter( - (diagnostic) => diagnostic.severity === severity, - ); - return { - id: `severity-${severity}`, - label: t(SEVERITY_LABEL_KEY[severity as Diagnostic["severity"]]), - items, - severity: severity as Diagnostic["severity"], - }; - }) - .filter((group) => group.items.length > 0); - } - - const byFile = new Map(); - for (const diagnostic of filteredDiagnostics) { - const current = byFile.get(diagnostic.filePath) || []; - current.push(diagnostic); - byFile.set(diagnostic.filePath, current); - } - - return Array.from(byFile.entries()) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([filePath, items]) => ({ - id: `file-${filePath}`, - label: filePath, - items, - })); - }, [filteredDiagnostics, preferences.groupBy, t]); + const [problemsView, setProblemsView] = useState(EMPTY_PROBLEMS_VIEW); - const totalBySeverity = useMemo(() => { - return diagnostics.reduce( - (acc, diagnostic) => { - acc[diagnostic.severity] += 1; - return acc; - }, - { error: 0, warning: 0, info: 0 }, - ); - }, [diagnostics]); - - const visibleBySeverity = useMemo(() => { - return filteredDiagnostics.reduce( - (acc, diagnostic) => { - acc[diagnostic.severity] += 1; - return acc; - }, - { error: 0, warning: 0, info: 0 }, - ); - }, [filteredDiagnostics]); - - const diagnosticFileItems = useMemo(() => { - const byFile = new Map< - string, - { - total: number; - counts: Record; - } - >(); - - for (const diagnostic of filteredDiagnostics) { - const current = - byFile.get(diagnostic.filePath) ?? - ({ - total: 0, - counts: { error: 0, warning: 0, info: 0 }, - } satisfies { - total: number; - counts: Record; - }); + // Core owns filtering, order, grouping, and counts; a newer input discards + // an older response. + useEffect(() => { + let cancelled = false; + queryProblems(diagnostics, { + groupBy: preferences.groupBy, + sortBy: preferences.sortBy, + severities: severityFilter, + currentFilePath: preferences.onlyCurrentFile ? activeFilePath : null, + source: sourceFilter, + query: searchQuery, + displayPath: (filePath) => getDiagnosticNavigatorPath(filePath, rootFolderPath), + }) + .then((view) => { + if (!cancelled) setProblemsView(view); + }) + .catch((error) => { + if (cancelled) return; + logger.error("Diagnostics", "Problems query failed:", error); + setProblemsView(EMPTY_PROBLEMS_VIEW); + }); + return () => { + cancelled = true; + }; + }, [ + activeFilePath, + diagnostics, + preferences.groupBy, + preferences.onlyCurrentFile, + preferences.sortBy, + rootFolderPath, + searchQuery, + severityFilter, + sourceFilter, + ]); - current.total += 1; - current.counts[diagnostic.severity] += 1; - byFile.set(diagnostic.filePath, current); - } + const filteredDiagnostics = problemsView.visible; + const totalBySeverity = problemsView.totalBySeverity; + const visibleBySeverity = problemsView.visibleBySeverity; + + const groupedDiagnostics = useMemo( + () => + problemsView.groups.map((group) => ({ + id: group.id, + label: + group.kind === "file" + ? (group.items[0]?.filePath ?? group.id) + : group.kind === "severity" && group.severity + ? t(SEVERITY_LABEL_KEY[group.severity]) + : t("diagnostics.all"), + items: group.items, + severity: group.severity, + })), + [problemsView.groups, t], + ); - return Array.from(byFile.entries()) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([filePath, summary]) => { - const severity = getHighestSeverity(summary.counts); - const navigatorPath = getDiagnosticNavigatorPath(filePath, rootFolderPath); - - return { - key: filePath, - path: navigatorPath, - label: navigatorPath, - iconPath: filePath, - iconClassName: SEVERITY_TEXT_CLASS[severity], - metadata: [ - { - label: summary.total, - className: SEVERITY_TEXT_CLASS[severity], - }, - ], - }; - }); - }, [filteredDiagnostics, rootFolderPath]); + const diagnosticFileItems = useMemo( + () => + problemsView.files.map((file) => ({ + key: file.filePath, + path: file.displayPath, + label: file.displayPath, + iconPath: file.filePath, + iconClassName: SEVERITY_TEXT_CLASS[file.severity], + metadata: [ + { + label: file.total, + className: SEVERITY_TEXT_CLASS[file.severity], + }, + ], + })), + [problemsView.files], + ); const selectedFileNavigatorKey = useMemo(() => { if ( diff --git a/windows/tauri/src/features/diagnostics/utils/problems-query.test.ts b/windows/tauri/src/features/diagnostics/utils/problems-query.test.ts new file mode 100644 index 0000000000..624cedc419 --- /dev/null +++ b/windows/tauri/src/features/diagnostics/utils/problems-query.test.ts @@ -0,0 +1,128 @@ +import { afterEach, expect, spyOn, test } from "bun:test"; +import * as core from "@/platform/tauri-core"; +import fixture from "../../../../../../shared/fixtures/lsp/problems-query-v1.json"; +import type { Diagnostic } from "../types/diagnostics.types"; +import { EMPTY_PROBLEMS_VIEW, queryProblems } from "./problems-query"; + +const diagnostic = (filePath: string, severity: Diagnostic["severity"], line: number): Diagnostic => ({ + severity, + filePath, + line, + column: 0, + endLine: line, + endColumn: 1, + message: `${severity} at ${line}`, + source: "server-a", +}); + +let restore: (() => void) | null = null; +afterEach(() => { + restore?.(); + restore = null; +}); + +test("submits Core facts with case-folded file identity and maps groups back to diagnostics", async () => { + const diagnostics = [ + diagnostic("C:/Work/src/File10.txt", "warning", 4), + diagnostic("C:/Work/src/File2.txt", "error", 9), + diagnostic("c:/work/src/file2.txt", "info", 1), + ]; + const requests: unknown[] = []; + const spy = spyOn(core, "invoke").mockImplementation((async (command, args) => { + expect(command).toBe("lsp.problems.query"); + requests.push(args); + return { + groups: [ + { key: "file:c:/work/src/file2.txt", kind: "file", indices: [1, 2] }, + { key: "file:c:/work/src/file10.txt", kind: "file", indices: [0] }, + ], + files: [ + { fileKey: "c:/work/src/file2.txt", displayPath: "src/File2.txt", count: 2, highestSeverity: 1 }, + { fileKey: "c:/work/src/file10.txt", displayPath: "src/File10.txt", count: 1, highestSeverity: 2 }, + ], + visibleCounts: { error: 1, warning: 1, information: 1, hint: 0, unknown: 0 }, + totalCounts: { error: 1, warning: 1, information: 1, hint: 2, unknown: 1 }, + }; + }) as typeof core.invoke); + restore = () => spy.mockRestore(); + + const view = await queryProblems(diagnostics, { + groupBy: "file", + sortBy: "severity", + severities: { error: true, warning: true, info: false }, + currentFilePath: "C:/WORK/src/File2.txt", + source: null, + query: " mismatch ", + displayPath: (filePath) => filePath.replace(/^[A-Za-z]:\/work\//i, ""), + }); + + expect(requests).toEqual([ + { + diagnostics: [ + { fileKey: "c:/work/src/file10.txt", displayPath: "src/File10.txt", line: 4, column: 0, severity: 2, message: "warning at 4", source: "server-a", code: null }, + { fileKey: "c:/work/src/file2.txt", displayPath: "src/File2.txt", line: 9, column: 0, severity: 1, message: "error at 9", source: "server-a", code: null }, + { fileKey: "c:/work/src/file2.txt", displayPath: "src/file2.txt", line: 1, column: 0, severity: 3, message: "info at 1", source: "server-a", code: null }, + ], + groupBy: "file", + sortBy: "severity", + filter: { severities: [1, 2], fileKey: "c:/work/src/file2.txt", source: null, query: "mismatch" }, + }, + ]); + expect(view.groups.map((group) => [group.id, group.items])).toEqual([ + ["file-C:/Work/src/File2.txt", [diagnostics[1], diagnostics[2]]], + ["file-C:/Work/src/File10.txt", [diagnostics[0]]], + ]); + expect(view.visible).toEqual([diagnostics[1], diagnostics[2], diagnostics[0]]); + expect(view.files).toEqual([ + { filePath: "C:/Work/src/File2.txt", displayPath: "src/File2.txt", total: 2, severity: "error" }, + { filePath: "C:/Work/src/File10.txt", displayPath: "src/File10.txt", total: 1, severity: "warning" }, + ]); + expect(view.visibleBySeverity).toEqual({ error: 1, warning: 1, info: 1 }); + // Hints and unknown severities are shown with information. + expect(view.totalBySeverity).toEqual({ error: 1, warning: 1, info: 4 }); +}); + +test("severity groups use the shared fixture's Core response shape", async () => { + const shared = fixture.cases.find((entry) => entry.request.groupBy === "severity"); + expect(shared).toBeDefined(); + const diagnostics = shared!.request.diagnostics.map((facts) => + diagnostic(facts.fileKey, facts.severity === 1 ? "error" : facts.severity === 2 ? "warning" : "info", facts.line), + ); + const spy = spyOn(core, "invoke").mockResolvedValue(shared!.response as never); + restore = () => spy.mockRestore(); + + const view = await queryProblems(diagnostics, { + groupBy: "severity", + sortBy: "file", + severities: { error: true, warning: true, info: true }, + currentFilePath: null, + source: null, + query: "", + displayPath: (filePath) => filePath, + }); + + expect(view.groups.map((group) => [group.id, group.severity])).toEqual([ + ["severity-1", "error"], + ["severity-2", "warning"], + ["severity-3", "info"], + ["severity-4", "info"], + ["severity-0", "info"], + ]); + expect(view.groups[1]?.items).toEqual([diagnostics[0], diagnostics[4]]); +}); + +test("an empty list does not cross the IPC boundary", async () => { + const spy = spyOn(core, "invoke"); + restore = () => spy.mockRestore(); + const view = await queryProblems([], { + groupBy: "file", + sortBy: "severity", + severities: { error: true, warning: true, info: true }, + currentFilePath: null, + source: null, + query: "", + displayPath: (filePath) => filePath, + }); + expect(view).toBe(EMPTY_PROBLEMS_VIEW); + expect(spy).not.toHaveBeenCalled(); +}); diff --git a/windows/tauri/src/features/diagnostics/utils/problems-query.ts b/windows/tauri/src/features/diagnostics/utils/problems-query.ts new file mode 100644 index 0000000000..0be7902f04 --- /dev/null +++ b/windows/tauri/src/features/diagnostics/utils/problems-query.ts @@ -0,0 +1,143 @@ +import { invoke } from "@/platform/tauri-core"; +import type { Diagnostic } from "../types/diagnostics.types"; + +export type ProblemsGroupBy = "severity" | "file" | "none"; +export type ProblemsSortBy = "severity" | "file" | "position"; + +type Severity = Diagnostic["severity"]; + +const LSP_SEVERITY: Record = { error: 1, warning: 2, info: 3 }; + +export interface ProblemsQueryOptions { + groupBy: ProblemsGroupBy; + sortBy: ProblemsSortBy; + severities: Record; + /** Restricts the query to this file (case-insensitive on Windows). */ + currentFilePath: string | null; + source: string | null; + query: string; + /** Workspace-relative label for each file; it decides file order. */ + displayPath: (filePath: string) => string; +} + +export interface ProblemsGroup { + id: string; + kind: "file" | "severity" | "all"; + items: Diagnostic[]; + severity?: Severity; +} + +export interface ProblemsFile { + filePath: string; + displayPath: string; + total: number; + severity: Severity; +} + +export interface ProblemsQueryView { + /** Visible diagnostics in display order. */ + visible: Diagnostic[]; + groups: ProblemsGroup[]; + files: ProblemsFile[]; + visibleBySeverity: Record; + totalBySeverity: Record; +} + +interface CoreCounts { + error: number; + warning: number; + information: number; + hint: number; + unknown: number; +} + +interface CoreResponse { + groups: Array<{ key: string; kind: ProblemsGroup["kind"]; severity?: number; indices: number[] }>; + files: Array<{ fileKey: string; displayPath: string; count: number; highestSeverity: number }>; + visibleCounts: CoreCounts; + totalCounts: CoreCounts; +} + +export const EMPTY_PROBLEMS_VIEW: ProblemsQueryView = { + visible: [], + groups: [], + files: [], + visibleBySeverity: { error: 0, warning: 0, info: 0 }, + totalBySeverity: { error: 0, warning: 0, info: 0 }, +}; + +/** Windows file identity: paths compare case-insensitively. */ +const fileKey = (filePath: string) => filePath.toLowerCase(); + +const displaySeverity = (severity: number | undefined): Severity => + severity === 1 ? "error" : severity === 2 ? "warning" : "info"; + +const displayCounts = (counts: CoreCounts): Record => ({ + error: counts.error, + warning: counts.warning, + // The pane shows information, hints, and unknown severities together. + info: counts.information + counts.hint + counts.unknown, +}); + +/** + * Filters, orders, and groups the Problems list with the shared Core rules + * (`lsp.problems.query`); only presentation stays in the pane. + */ +export async function queryProblems( + diagnostics: Diagnostic[], + options: ProblemsQueryOptions, +): Promise { + if (diagnostics.length === 0) return EMPTY_PROBLEMS_VIEW; + const severities = (Object.keys(LSP_SEVERITY) as Severity[]) + .filter((severity) => options.severities[severity]) + .map((severity) => LSP_SEVERITY[severity]); + const query = options.query.trim(); + const response = await invoke("lsp.problems.query", { + diagnostics: diagnostics.map((diagnostic) => ({ + fileKey: fileKey(diagnostic.filePath), + displayPath: options.displayPath(diagnostic.filePath), + line: diagnostic.line, + column: diagnostic.column, + severity: LSP_SEVERITY[diagnostic.severity], + message: diagnostic.message, + source: diagnostic.source ?? null, + code: diagnostic.code ?? null, + })), + groupBy: options.groupBy, + sortBy: options.sortBy, + filter: { + severities, + fileKey: options.currentFilePath ? fileKey(options.currentFilePath) : null, + source: options.source, + query: query.length > 0 ? query : null, + }, + }); + const filePaths = new Map(); + for (const diagnostic of diagnostics) { + const key = fileKey(diagnostic.filePath); + if (!filePaths.has(key)) filePaths.set(key, diagnostic.filePath); + } + const groups = response.groups.map((group): ProblemsGroup => { + const items = group.indices.map((index) => diagnostics[index]).filter(Boolean); + if (group.kind === "file") { + return { id: `file-${items[0]?.filePath ?? group.key}`, kind: "file", items }; + } + if (group.kind === "severity") { + const severity = displaySeverity(group.severity); + return { id: `severity-${group.severity}`, kind: "severity", items, severity }; + } + return { id: "all", kind: "all", items }; + }); + return { + visible: groups.flatMap((group) => group.items), + groups, + files: response.files.map((file) => ({ + filePath: filePaths.get(file.fileKey) ?? file.fileKey, + displayPath: file.displayPath, + total: file.count, + severity: displaySeverity(file.highestSeverity), + })), + visibleBySeverity: displayCounts(response.visibleCounts), + totalBySeverity: displayCounts(response.totalCounts), + }; +} diff --git a/windows/tauri/src/features/editor/components/monaco-editor.tsx b/windows/tauri/src/features/editor/components/monaco-editor.tsx index 7534599a29..631fd3a09c 100644 --- a/windows/tauri/src/features/editor/components/monaco-editor.tsx +++ b/windows/tauri/src/features/editor/components/monaco-editor.tsx @@ -93,7 +93,6 @@ import { EDITOR_CONSTANTS } from "../config/constants"; import { JavaRunMarkerMenu } from "../context-menu/java-run-marker-menu"; import { useBufferStore } from "../stores/buffer.store"; import { editorBufferSurfacesEqual, selectEditorBufferSurface } from "../stores/buffer-metadata"; -import { useJumpListStore } from "../stores/jump-list.store"; import { useEditorStateStore } from "../stores/state.store"; import type { EditorContentChangeOptions, @@ -107,10 +106,7 @@ import { lspDocumentTargetForEditor } from "../lsp/lsp-document-target"; import { fileOpenBenchmark } from "../utils/file-open-benchmark"; import { isEditorGoToDefinitionModifierClick } from "../utils/go-to-definition-gesture"; import { getLanguageIdFromPath } from "../utils/language-id"; -import { - cursorEntryToRecordAfterMouseGesture, - type CursorHistoryEntry, -} from "../utils/mouse-cursor-history"; +import { type CursorHistoryEntry, recordMouseGesture } from "../utils/mouse-cursor-history"; import { toggleCaseText } from "../utils/text-operations"; import { editorAPI } from "../extensions/api"; import { @@ -1620,11 +1616,7 @@ export function MonacoEditor({ mouseGestureStartRef.current = null; return; } - const entry = cursorEntryToRecordAfterMouseGesture( - mouseGestureStartRef.current, - cursorEntryFromEditor(), - ); - if (entry) useJumpListStore.getState().actions.recordCursorEntry(entry); + recordMouseGesture(mouseGestureStartRef.current, cursorEntryFromEditor()); mouseGestureStartRef.current = null; }), editor.onDidChangeCursorSelection(() => { @@ -1666,11 +1658,7 @@ export function MonacoEditor({ mouseGestureStartRef.current = null; return; } - const entry = cursorEntryToRecordAfterMouseGesture( - mouseGestureStartRef.current, - cursorEntryFromEditor(), - ); - if (entry) useJumpListStore.getState().actions.recordCursorEntry(entry); + recordMouseGesture(mouseGestureStartRef.current, cursorEntryFromEditor()); mouseGestureStartRef.current = null; }; window.addEventListener("mouseup", handleWindowMouseUp); diff --git a/windows/tauri/src/features/editor/lsp/language-server-activation-policy.test.ts b/windows/tauri/src/features/editor/lsp/language-server-activation-policy.test.ts new file mode 100644 index 0000000000..f6732212fb --- /dev/null +++ b/windows/tauri/src/features/editor/lsp/language-server-activation-policy.test.ts @@ -0,0 +1,62 @@ +import { afterEach, expect, spyOn, test } from "bun:test"; +import * as core from "@/platform/tauri-core"; +import fixture from "../../../../../../shared/fixtures/lsp/activation-policy-v1.json"; +import { LanguageServerActivationHistory } from "./language-server-activation-policy"; + +let restore: (() => void) | null = null; +afterEach(() => { + restore?.(); + restore = null; +}); + +/** Answers each request with the shared fixture case that has the same request. */ +function replayFixture(requests: unknown[]) { + const spy = spyOn(core, "invoke").mockImplementation((async (command, args) => { + expect(command).toBe("lsp.activationPolicy"); + requests.push(args); + const match = fixture.cases.find((entry) => Bun.deepEquals(entry.request, args)); + if (!match) throw new Error(`No fixture case for ${JSON.stringify(args)}`); + return match.response; + }) as typeof core.invoke); + restore = () => spy.mockRestore(); +} + +test("stores Core state per server and sends the monotonic clock", async () => { + const requests: unknown[] = []; + replayFixture(requests); + let now = 1000.9; + const history = new LanguageServerActivationHistory(() => now); + + expect((await history.apply("C:/w:server-a", "startFailed")).decision).toBe("wait"); + now = 5999; + const waiting = await history.apply("C:/w:server-a", "automaticStart"); + expect(waiting).toEqual({ state: { consecutiveFailures: 1, lastFailureAtMs: 1000 }, decision: "wait", retryAfterMs: 1 }); + now = 1000; + // Another workspace keeps its own history. + expect((await history.apply("C:/other:server-a", "automaticStart")).decision).toBe("allow"); + + expect(requests).toEqual([ + { state: null, event: "startFailed", nowMs: 1000 }, + { state: { consecutiveFailures: 1, lastFailureAtMs: 1000 }, event: "automaticStart", nowMs: 5999 }, + { state: null, event: "automaticStart", nowMs: 1000 }, + ]); +}); + +test("a cleared or released history is not sent again", async () => { + const requests: unknown[] = []; + replayFixture(requests); + let now = 1000; + const history = new LanguageServerActivationHistory(() => now); + // Seed two failures through the fixture's own transitions. + await history.apply("k", "startFailed"); + now = 7000; + await history.apply("k", "startFailed"); + now = 8000; + expect((await history.apply("k", "ready")).state).toBeNull(); + now = 1000; + await history.apply("k", "startFailed"); + history.release("k"); + await history.apply("k", "automaticStart"); + + expect(requests[requests.length - 1]).toEqual({ state: null, event: "automaticStart", nowMs: 1000 }); +}); diff --git a/windows/tauri/src/features/editor/lsp/language-server-activation-policy.ts b/windows/tauri/src/features/editor/lsp/language-server-activation-policy.ts new file mode 100644 index 0000000000..4318d73508 --- /dev/null +++ b/windows/tauri/src/features/editor/lsp/language-server-activation-policy.ts @@ -0,0 +1,49 @@ +import { invoke } from "@/platform/tauri-core"; + +/** Failure history Core returns; stored verbatim per server key. */ +export interface LanguageServerActivationState { + consecutiveFailures: number; + lastFailureAtMs: number; +} + +export type LanguageServerActivationEvent = + | "automaticStart" + | "explicitStart" + | "startFailed" + | "ready"; + +export interface LanguageServerActivationOutcome { + state: LanguageServerActivationState | null; + decision: "allow" | "wait" | "explicitRetryRequired"; + retryAfterMs: number | null; +} + +/** + * Keeps each server's startup failure history and asks Core + * (`lsp.activationPolicy`) whether an automatic start may run. The retry + * budget and cooldown are shared with macOS; time is a monotonic reading. + */ +export class LanguageServerActivationHistory { + private readonly states = new Map(); + + constructor(private readonly monotonicNowMs: () => number = () => performance.now()) {} + + async apply( + serverKey: string, + event: LanguageServerActivationEvent, + ): Promise { + const outcome = await invoke("lsp.activationPolicy", { + state: this.states.get(serverKey) ?? null, + event, + nowMs: Math.max(0, Math.floor(this.monotonicNowMs())), + }); + if (outcome.state) this.states.set(serverKey, outcome.state); + else this.states.delete(serverKey); + return outcome; + } + + /** Forgets a server whose last tracked document closed. */ + release(serverKey: string): void { + this.states.delete(serverKey); + } +} diff --git a/windows/tauri/src/features/editor/lsp/language-server-failure-cooldown.test.ts b/windows/tauri/src/features/editor/lsp/language-server-failure-cooldown.test.ts deleted file mode 100644 index 60a47206af..0000000000 --- a/windows/tauri/src/features/editor/lsp/language-server-failure-cooldown.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - LANGUAGE_SERVER_FAILURE_COOLDOWN_MS, - clearLanguageServerFailure, - isLanguageServerFailureCoolingDown, - recordLanguageServerFailure, -} from "./language-server-failure-cooldown"; - -describe("language server failure cooldown", () => { - test("blocks automatic retries during the cooldown window", () => { - const failures = new Map(); - const serverKey = "C:/project:java"; - recordLanguageServerFailure(failures, serverKey, 1_000); - - expect( - isLanguageServerFailureCoolingDown(failures, serverKey, 1_000 + LANGUAGE_SERVER_FAILURE_COOLDOWN_MS - 1), - ).toBe(true); - expect(failures.has(serverKey)).toBe(true); - }); - - test("allows automatic retries after the cooldown expires", () => { - const failures = new Map(); - const serverKey = "C:/project:java"; - recordLanguageServerFailure(failures, serverKey, 1_000); - - expect( - isLanguageServerFailureCoolingDown(failures, serverKey, 1_000 + LANGUAGE_SERVER_FAILURE_COOLDOWN_MS), - ).toBe(false); - expect(failures.has(serverKey)).toBe(false); - }); - - test("manual clear removes the failure so retries start immediately", () => { - const failures = new Map(); - const serverKey = "C:/project:java"; - recordLanguageServerFailure(failures, serverKey, 1_000); - clearLanguageServerFailure(failures, serverKey); - - expect(isLanguageServerFailureCoolingDown(failures, serverKey, 1_001)).toBe(false); - }); -}); diff --git a/windows/tauri/src/features/editor/lsp/language-server-failure-cooldown.ts b/windows/tauri/src/features/editor/lsp/language-server-failure-cooldown.ts deleted file mode 100644 index 901105ff5f..0000000000 --- a/windows/tauri/src/features/editor/lsp/language-server-failure-cooldown.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** Brief throttle after a language-server startup failure before automatic retries. */ -export const LANGUAGE_SERVER_FAILURE_COOLDOWN_MS = 5_000; - -/** - * Returns whether startup should stay blocked for this server key. - * Expired cooldown entries are removed so later automatic starts can retry. - */ -export function isLanguageServerFailureCoolingDown( - failures: Map, - serverKey: string, - nowMs: number, - cooldownMs: number = LANGUAGE_SERVER_FAILURE_COOLDOWN_MS, -): boolean { - const failedAtMs = failures.get(serverKey); - if (failedAtMs === undefined) { - return false; - } - - if (nowMs - failedAtMs >= cooldownMs) { - failures.delete(serverKey); - return false; - } - - return true; -} - -export function recordLanguageServerFailure( - failures: Map, - serverKey: string, - failedAtMs: number, -): void { - failures.set(serverKey, failedAtMs); -} - -export function clearLanguageServerFailure(failures: Map, serverKey: string): void { - failures.delete(serverKey); -} diff --git a/windows/tauri/src/features/editor/lsp/lsp-client.ts b/windows/tauri/src/features/editor/lsp/lsp-client.ts index 1f3f643775..ecbca3233f 100644 --- a/windows/tauri/src/features/editor/lsp/lsp-client.ts +++ b/windows/tauri/src/features/editor/lsp/lsp-client.ts @@ -68,11 +68,7 @@ import { normalizeLspDocumentTarget, type LspDocumentTargetInput, } from "./lsp-document-target"; -import { - clearLanguageServerFailure, - isLanguageServerFailureCoolingDown, - recordLanguageServerFailure, -} from "./language-server-failure-cooldown"; +import { LanguageServerActivationHistory } from "./language-server-activation-policy"; import { isCanceledLspRequest, normalizeLspError } from "./lsp-request-error"; import { applyWorkspaceEdit, @@ -245,8 +241,8 @@ export class LspClient { private activeServerFiles = new Map>(); // workspace:language -> tracked files private workspaceRepresentativeFiles = new Map(); private serverScopes = new Map(); - /** workspace:language -> failure timestamp (ms); expired after a short cooldown. */ - private failedLanguageServers = new Map(); + /** workspace:language -> Core-owned startup failure history. */ + private activationHistory = new LanguageServerActivationHistory(); private repairLanguageServerPromises = new Map>(); private fileAttachmentIds = new Map(); private fileStartTasks = new Map(); @@ -1027,6 +1023,15 @@ export class LspClient { return task; } + /** Records a start outcome; a policy failure must not mask the start result. */ + private async recordActivation(serverKey: string, event: "ready" | "startFailed"): Promise { + try { + await this.activationHistory.apply(serverKey, event); + } catch (error) { + logger.error("LSPClient", `Failed to record language server ${event}:`, error); + } + } + private async startFileAttachment( filePath: string, scope: WorkspaceLaunchScope, @@ -1101,13 +1106,15 @@ export class LspClient { ); const serverKey = `${workspacePath}:${languageId}`; - if (attempt.kind !== "attach") { - clearLanguageServerFailure(this.failedLanguageServers, serverKey); - } - if (isLanguageServerFailureCoolingDown(this.failedLanguageServers, serverKey, Date.now())) { + const activation = await this.activationHistory.apply( + serverKey, + attempt.kind === "attach" ? "automaticStart" : "explicitStart", + ); + if (!isCurrentAttachment()) return { kind: "cancelled", reason: "superseded" }; + if (activation.decision !== "allow") { logger.debug( "LSPClient", - `Skipping LSP restart for ${languageId} in ${workspacePath} during startup failure cooldown`, + `Skipping LSP start for ${languageId} in ${workspacePath}: ${activation.decision}`, ); throw new Error( `${getLanguageDisplayName(languageId)} language server previously failed to start.`, @@ -1151,10 +1158,10 @@ export class LspClient { await invoke("lsp_stop_for_file", { filePath, attachmentId }); return { kind: "cancelled", reason: "superseded" }; } - clearLanguageServerFailure(this.failedLanguageServers, serverKey); + await this.recordActivation(serverKey, "ready"); this.registerActiveServer(serverKey, languageId, scope, filePath); } catch (error) { - recordLanguageServerFailure(this.failedLanguageServers, serverKey, Date.now()); + await this.recordActivation(serverKey, "startFailed"); if (attempt.kind !== "repairRetry" && this.isRepairableStartupError(error)) { const repaired = await this.repairLanguageServerForFile(filePath, languageId); if (repaired.kind === "repaired") { @@ -1335,7 +1342,7 @@ export class LspClient { this.activeLanguageServers.delete(activeKey); this.serverScopes.delete(activeKey); } - clearLanguageServerFailure(this.failedLanguageServers, activeKey); + this.activationHistory.release(activeKey); } const displayName = getLanguageDisplayName(languageId); diff --git a/windows/tauri/src/features/editor/stores/jump-list.store.test.ts b/windows/tauri/src/features/editor/stores/jump-list.store.test.ts index 8ead8ff691..61eb81ef92 100644 --- a/windows/tauri/src/features/editor/stores/jump-list.store.test.ts +++ b/windows/tauri/src/features/editor/stores/jump-list.store.test.ts @@ -1,11 +1,12 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { useJumpListStore, type JumpListEntry } from "./jump-list.store"; - -function cursorEntry( - line: number, - column: number, - paneId?: string, -): Omit { +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import * as core_ from "@/platform/tauri-core"; +import { + type JumpListEntry, + type NavigationHistoryState, + useJumpListStore, +} from "./jump-list.store"; + +function entry(line: number, column: number, paneId?: string): JumpListEntry { return { bufferId: "buffer-a", filePath: "C:/workspace/a.ts", @@ -18,154 +19,106 @@ function cursorEntry( }; } -function clearJumpList() { - useJumpListStore.getState().actions.clear(); +function location(line: number, column: number, paneId?: string) { + const { filePath, line: entryLine, column: entryColumn, ...context } = entry(line, column, paneId); + return { document: filePath, line: entryLine, column: entryColumn, context }; } -beforeEach(clearJumpList); -afterEach(clearJumpList); - -describe("jump list cursor history", () => { - test("keeps distinct positions in the same file, including their columns", () => { - const actions = useJumpListStore.getState().actions; - - actions.recordCursorEntry(cursorEntry(9, 19)); - actions.recordCursorEntry(cursorEntry(9, 4)); - actions.recordCursorEntry(cursorEntry(19, 4)); - - const positions = useJumpListStore - .getState() - .entries.map(({ line, column, offset }) => ({ line, column, offset })); - expect(positions).toEqual([ - { line: 9, column: 19, offset: 919 }, - { line: 9, column: 4, offset: 904 }, - { line: 19, column: 4, offset: 1904 }, - ]); - }); - - test("preserves an exact cursor entry before a nearby explicit navigation entry", () => { - const actions = useJumpListStore.getState().actions; - const cursorPosition = cursorEntry(10, 5); - const explicitNavigationPosition = cursorEntry(10, 20); - const destination = cursorEntry(30, 4); - - actions.recordCursorEntry(cursorPosition); - actions.pushEntry(explicitNavigationPosition); +type Reduce = (args: { state: NavigationHistoryState; event: { type: string } }) => unknown; - expect( - useJumpListStore - .getState() - .entries.map(({ line, column, offset }) => ({ line, column, offset })), - ).toEqual([ - { line: 10, column: 5, offset: 1005 }, - { line: 10, column: 20, offset: 1020 }, - ]); - - expect(actions.goBack(destination)).toMatchObject(explicitNavigationPosition); - expect(actions.goBack()).toMatchObject(cursorPosition); - expect(actions.goForward()).toMatchObject(explicitNavigationPosition); - expect(actions.goForward()).toMatchObject(destination); - }); - - test("navigates back and forward across normal cursor positions", () => { - const actions = useJumpListStore.getState().actions; - const first = cursorEntry(9, 19); - const second = cursorEntry(19, 4); - const third = cursorEntry(29, 11); - - actions.recordCursorEntry(first); - actions.recordCursorEntry(second); +function mockCore(reduce: Reduce) { + return spyOn(core_, "invoke").mockImplementation((async (command, args) => { + expect(command).toBe("editor.navigationHistory.reduce"); + return reduce(args as Parameters[0]); + }) as typeof core_.invoke); +} - expect(actions.goBack(third)).toMatchObject(second); - expect(actions.goBack()).toMatchObject(first); - expect(actions.goForward()).toMatchObject(second); - expect(actions.goForward()).toMatchObject(third); +afterEach(() => useJumpListStore.getState().actions.clear()); + +describe("jump list backed by Core navigation history", () => { + test("records through Core and installs the returned state", async () => { + const core = mockCore(() => ({ state: { back: [location(9, 19, "left")], forward: [] }, destination: null })); + try { + await useJumpListStore.getState().actions.record(entry(9, 19, "left"), entry(30, 1, "left")); + expect(core.mock.calls[0]?.[1]).toEqual({ + state: { back: [], forward: [] }, + event: { type: "record", departure: location(9, 19, "left"), destination: location(30, 1, "left") }, + }); + expect(useJumpListStore.getState().history.back).toEqual([location(9, 19, "left")]); + expect(useJumpListStore.getState().actions.canGoBack()).toBe(true); + expect(useJumpListStore.getState().actions.canGoForward()).toBe(false); + } finally { + core.mockRestore(); + } }); - test("drops forward cursor positions after a new movement", () => { - const actions = useJumpListStore.getState().actions; - const first = cursorEntry(9, 19); - const second = cursorEntry(19, 4); - const third = cursorEntry(29, 11); - - actions.recordCursorEntry(first); - actions.recordCursorEntry(second); - expect(actions.goBack(third)).toMatchObject(second); - expect(actions.goBack()).toMatchObject(first); - - actions.recordCursorEntry(first); - - expect(actions.goForward()).toBeNull(); + test("applies events in order to the state produced by the previous event", async () => { + const seen: NavigationHistoryState[] = []; + const core = mockCore(({ state }) => { + seen.push(state); + return { state: { back: [...state.back, location(seen.length, 0)], forward: [] }, destination: null }; + }); + try { + const actions = useJumpListStore.getState().actions; + await Promise.all([actions.record(entry(1, 0)), actions.record(entry(2, 0))]); + expect(seen).toEqual([ + { back: [], forward: [] }, + { back: [location(1, 0)], forward: [] }, + ]); + } finally { + core.mockRestore(); + } }); - test("keeps nearby explicit entries distinct in the same pane", () => { - const actions = useJumpListStore.getState().actions; - const paneId = "left-pane"; - - actions.pushEntry(cursorEntry(10, 5, paneId)); - actions.pushEntry(cursorEntry(10, 20, paneId)); - - expect( - useJumpListStore - .getState() - .entries.map(({ line, column, paneId: entryPaneId }) => ({ - line, - column, - paneId: entryPaneId, - })), - ).toEqual([ - { line: 10, column: 5, paneId }, - { line: 10, column: 20, paneId }, - ]); + test("returns the Core destination with its pane and scroll context", async () => { + const core = mockCore(({ event }) => ({ + state: { back: [], forward: event.type === "back" ? [location(49, 1, "right")] : [] }, + destination: location(39, 1, "right"), + })); + try { + const step = await useJumpListStore.getState().actions.goBack(entry(49, 1, "right")); + expect(step?.entry).toEqual(entry(39, 1, "right")); + expect(core.mock.calls[0]?.[1]).toMatchObject({ event: { type: "back", current: location(49, 1, "right") } }); + } finally { + core.mockRestore(); + } }); - test("navigates chronologically across panes and preserves each destination pane", () => { - const actions = useJumpListStore.getState().actions; - const leftPaneId = "left-pane"; - const rightPaneId = "right-pane"; - - actions.recordCursorEntry(cursorEntry(9, 1, leftPaneId)); - actions.recordCursorEntry(cursorEntry(19, 1, leftPaneId)); - actions.recordCursorEntry(cursorEntry(29, 1, rightPaneId)); - actions.recordCursorEntry(cursorEntry(39, 1, rightPaneId)); - - expect(actions.goBack(cursorEntry(49, 1, rightPaneId))).toMatchObject({ - line: 39, - paneId: rightPaneId, + test("rolls a failed step back only while no newer event replaced it", async () => { + const original = { back: [location(9, 1)], forward: [] }; + useJumpListStore.setState({ history: original }); + let call = 0; + const core = mockCore(() => { + call += 1; + return call < 3 + ? { state: { back: [], forward: [location(19, 1)] }, destination: location(9, 1) } + : { state: { back: [location(5, 0)], forward: [] }, destination: null }; }); - expect(actions.goBack()).toMatchObject({ line: 29, paneId: rightPaneId }); - expect(actions.goBack()).toMatchObject({ line: 19, paneId: leftPaneId }); - expect(actions.goBack()).toMatchObject({ line: 9, paneId: leftPaneId }); - - expect(actions.goForward()).toMatchObject({ line: 19, paneId: leftPaneId }); - expect(actions.goForward()).toMatchObject({ line: 29, paneId: rightPaneId }); - expect(actions.goForward()).toMatchObject({ line: 39, paneId: rightPaneId }); - expect(actions.goForward()).toMatchObject({ line: 49, paneId: rightPaneId }); + try { + const actions = useJumpListStore.getState().actions; + const step = await actions.goBack(entry(19, 1)); + expect(step).not.toBeNull(); + actions.rollback(step!); + expect(useJumpListStore.getState().history).toBe(original); + + const repeated = await actions.goBack(entry(19, 1)); + await actions.record(entry(5, 0)); + actions.rollback(repeated!); + expect(useJumpListStore.getState().history).toEqual({ back: [location(5, 0)], forward: [] }); + } finally { + core.mockRestore(); + } }); - test("restores the present position when a back navigation fails", () => { - const actions = useJumpListStore.getState().actions; - actions.recordCursorEntry(cursorEntry(9, 1)); - const previousIndex = useJumpListStore.getState().currentIndex; - const destination = actions.goBack(cursorEntry(19, 1)); - - expect(destination).not.toBeNull(); - actions.rollbackNavigation(destination!, previousIndex, true); - - expect(actions.goBack(cursorEntry(19, 1))).toMatchObject({ line: 9, column: 1 }); - }); - - test("restores the previous history index when a forward navigation fails", () => { - const actions = useJumpListStore.getState().actions; - actions.recordCursorEntry(cursorEntry(9, 1)); - actions.recordCursorEntry(cursorEntry(19, 1)); - const back = actions.goBack(cursorEntry(29, 1)); - expect(back).toMatchObject({ line: 19 }); - - const forward = actions.goForward(); - expect(forward).toMatchObject({ line: 29 }); - actions.rollbackNavigation(forward!, 1, false); - - expect(actions.goForward()).toMatchObject({ line: 29, column: 1 }); + test("keeps the history when Core rejects the event", async () => { + const original = { back: [location(9, 1)], forward: [] }; + useJumpListStore.setState({ history: original }); + const core = spyOn(core_, "invoke").mockRejectedValue(new Error("invalid_request")); + try { + expect(await useJumpListStore.getState().actions.goBack(entry(19, 1))).toBeNull(); + expect(useJumpListStore.getState().history).toBe(original); + } finally { + core.mockRestore(); + } }); }); diff --git a/windows/tauri/src/features/editor/stores/jump-list.store.ts b/windows/tauri/src/features/editor/stores/jump-list.store.ts index 63568c8c25..e9fe483914 100644 --- a/windows/tauri/src/features/editor/stores/jump-list.store.ts +++ b/windows/tauri/src/features/editor/stores/jump-list.store.ts @@ -1,11 +1,10 @@ import isEqual from "fast-deep-equal"; -import { immer } from "zustand/middleware/immer"; import { createWithEqualityFn } from "zustand/traditional"; +import { invoke } from "@/platform/tauri-core"; import { createSelectors } from "@/utils/zustand-selectors"; +import { logger } from "../utils/logger"; -type JumpListEntrySource = "cursor" | "explicit"; -type JumpListPosition = Omit; - +/** One editor position the user can return to. */ export interface JumpListEntry { bufferId: string; filePath: string; @@ -15,198 +14,127 @@ export interface JumpListEntry { offset: number; scrollTop: number; scrollLeft: number; - timestamp: number; } -interface StoredJumpListEntry extends JumpListEntry { - source: JumpListEntrySource; +/** + * Core wire form. Core compares `document`, `line` and `column`; the Windows + * pane, buffer, offset and scroll values travel unchanged in `context`. + */ +interface HistoryLocation { + document: string; + line: number; + column: number; + context: Omit; +} + +export interface NavigationHistoryState { + back: HistoryLocation[]; + forward: HistoryLocation[]; +} + +type HistoryEvent = + | { type: "record"; departure: HistoryLocation; destination: HistoryLocation | null } + | { type: "back" | "forward"; current: HistoryLocation | null }; + +interface ReduceResponse { + state: NavigationHistoryState; + destination: HistoryLocation | null; +} + +/** A step the caller must roll back when its destination cannot be opened. */ +export interface JumpListStep { + entry: JumpListEntry; + previous: NavigationHistoryState; + next: NavigationHistoryState; } interface JumpListActions { - pushEntry: (entry: JumpListPosition) => void; - recordCursorEntry: (entry: JumpListPosition) => void; - goBack: (currentPosition?: JumpListPosition) => JumpListEntry | null; - goForward: () => JumpListEntry | null; - rollbackNavigation: (entry: JumpListEntry, previousIndex: number, removePresent: boolean) => void; + /** Records a jump away from `departure`; `destination` is null when unresolved. */ + record: (departure: JumpListEntry, destination?: JumpListEntry | null) => Promise; + goBack: (current?: JumpListEntry) => Promise; + goForward: (current?: JumpListEntry) => Promise; + rollback: (step: JumpListStep) => void; canGoBack: () => boolean; canGoForward: () => boolean; clear: () => void; } interface JumpListState { - entries: StoredJumpListEntry[]; - currentIndex: number; - maxEntries: number; + history: NavigationHistoryState; actions: JumpListActions; } -const DEFAULT_MAX_ENTRIES = 100; +const EMPTY_HISTORY: NavigationHistoryState = { back: [], forward: [] }; -function withTimestamp(entry: JumpListPosition, source: JumpListEntrySource): StoredJumpListEntry { - return { ...entry, source, timestamp: Date.now() }; +function toLocation(entry: JumpListEntry): HistoryLocation { + const { filePath, line, column, ...context } = entry; + return { document: filePath, line, column, context }; } -function truncateForwardEntries(state: JumpListState) { - if (state.currentIndex >= 0 && state.currentIndex < state.entries.length - 1) { - state.entries = state.entries.slice(0, state.currentIndex + 1); - } +function toEntry(location: HistoryLocation): JumpListEntry { + return { + ...location.context, + filePath: location.document, + line: location.line, + column: location.column, + }; } -function appendEntry(state: JumpListState, entry: StoredJumpListEntry) { - state.entries.push(entry); - if (state.entries.length > state.maxEntries) { - state.entries.shift(); - } -} +// Core owns the stack rules. Reductions run one at a time so every event +// applies to the state produced by the previous one. +let reductionQueue: Promise = Promise.resolve(); export const useJumpListStore = createSelectors( createWithEqualityFn()( - immer((set, get) => ({ - entries: [], - currentIndex: -1, - maxEntries: DEFAULT_MAX_ENTRIES, - - actions: { - pushEntry: (entry) => { - set((state) => { - const newEntry = withTimestamp(entry, "explicit"); - - // If we're in the middle of history, truncate future entries. - truncateForwardEntries(state); - - const lastEntry = state.entries[state.entries.length - 1]; - const isSamePosition = - lastEntry && - lastEntry.source !== "cursor" && - lastEntry.filePath === newEntry.filePath && - lastEntry.paneId === newEntry.paneId && - lastEntry.line === newEntry.line && - lastEntry.column === newEntry.column && - lastEntry.offset === newEntry.offset; - - if (isSamePosition) { - // Update the existing entry instead of adding a duplicate. - state.entries[state.entries.length - 1] = newEntry; - state.currentIndex = -1; - return; - } - - appendEntry(state, newEntry); - - // Reset to present (not navigating history). - state.currentIndex = -1; - }); - }, - - recordCursorEntry: (entry) => { - set((state) => { - const newEntry = withTimestamp(entry, "cursor"); - - // A new cursor movement after going back starts a new history branch. - truncateForwardEntries(state); - - const lastEntry = state.entries[state.entries.length - 1]; - const isSamePosition = - lastEntry && - lastEntry.bufferId === newEntry.bufferId && - lastEntry.filePath === newEntry.filePath && - lastEntry.paneId === newEntry.paneId && - lastEntry.line === newEntry.line && - lastEntry.column === newEntry.column && - lastEntry.offset === newEntry.offset; - - if (isSamePosition) { - state.entries[state.entries.length - 1] = newEntry; - state.currentIndex = -1; - return; - } - - appendEntry(state, newEntry); - state.currentIndex = -1; - }); - }, - - goBack: (currentPosition) => { - let result: JumpListEntry | null = null; - - set((state) => { - if (state.entries.length === 0) return; - - let newIndex: number; - if (state.currentIndex === -1) { - // Currently at present - save current position so we can go forward to it. - if (currentPosition) { - appendEntry(state, withTimestamp(currentPosition, "cursor")); - } - // Go to second-to-last entry (last entry is now where we just were). - newIndex = state.entries.length - 2; - } else if (state.currentIndex > 0) { - // Go to previous entry. - newIndex = state.currentIndex - 1; - } else { - // Already at the beginning. - return; - } - - if (newIndex < 0) return; - - const entry = state.entries[newIndex]; - if (!entry) return; - - state.currentIndex = newIndex; - result = { ...entry }; - }); - - return result; - }, - - goForward: () => { - const state = get(); - if (state.currentIndex === -1 || state.currentIndex >= state.entries.length - 1) { + (set, get) => { + const reduce = (event: HistoryEvent): Promise => { + const reduction = reductionQueue.then(async () => { + const previous = get().history; + let response: ReduceResponse; + try { + response = await invoke("editor.navigationHistory.reduce", { + state: previous, + event, + }); + } catch (error) { + logger.error("JumpList", "Navigation history update failed:", error); return null; } - - const newIndex = state.currentIndex + 1; - const entry = state.entries[newIndex]; - if (!entry) return null; - - set((state) => { - state.currentIndex = newIndex; - }); - - return { ...entry }; - }, - - rollbackNavigation: (entry, previousIndex, removePresent) => { - set((state) => { - const current = state.entries[state.currentIndex]; - if (!current || current.timestamp !== entry.timestamp) return; - if (removePresent) state.entries.pop(); - state.currentIndex = previousIndex; - }); - }, - - canGoBack: () => { - const state = get(); - if (state.entries.length === 0) return false; - if (state.currentIndex === -1) return true; - return state.currentIndex > 0; - }, - - canGoForward: () => { - const state = get(); - if (state.currentIndex === -1) return false; - return state.currentIndex < state.entries.length - 1; - }, - - clear: () => { - set((state) => { - state.entries = []; - state.currentIndex = -1; - }); + // clear() may have run while the request was in flight. + if (get().history !== previous) return null; + set({ history: response.state }); + return response.destination + ? { entry: toEntry(response.destination), previous, next: response.state } + : null; + }); + reductionQueue = reduction.catch(() => undefined); + return reduction; + }; + + return { + history: EMPTY_HISTORY, + actions: { + record: async (departure, destination = null) => { + await reduce({ + type: "record", + departure: toLocation(departure), + destination: destination ? toLocation(destination) : null, + }); + }, + goBack: (current) => + reduce({ type: "back", current: current ? toLocation(current) : null }), + goForward: (current) => + reduce({ type: "forward", current: current ? toLocation(current) : null }), + rollback: (step) => { + // A later jump owns the history now; do not overwrite it. + if (get().history === step.next) set({ history: step.previous }); + }, + canGoBack: () => get().history.back.length > 0, + canGoForward: () => get().history.forward.length > 0, + clear: () => set({ history: EMPTY_HISTORY }), }, - }, - })), + }; + }, isEqual, ), ); diff --git a/windows/tauri/src/features/editor/utils/jump-navigation.test.ts b/windows/tauri/src/features/editor/utils/jump-navigation.test.ts index 8aa56abde3..e4f257236a 100644 --- a/windows/tauri/src/features/editor/utils/jump-navigation.test.ts +++ b/windows/tauri/src/features/editor/utils/jump-navigation.test.ts @@ -87,7 +87,6 @@ const entry: JumpListEntry = { offset: 123, scrollTop: 240, scrollLeft: 16, - timestamp: 0, }; const originalRequestAnimationFrame = globalThis.requestAnimationFrame; diff --git a/windows/tauri/src/features/editor/utils/mouse-cursor-history.test.ts b/windows/tauri/src/features/editor/utils/mouse-cursor-history.test.ts index 1032cc73ff..8d3473874a 100644 --- a/windows/tauri/src/features/editor/utils/mouse-cursor-history.test.ts +++ b/windows/tauri/src/features/editor/utils/mouse-cursor-history.test.ts @@ -1,8 +1,7 @@ -import { describe, expect, test } from "bun:test"; -import { - cursorEntryToRecordAfterMouseGesture, - type CursorHistoryEntry, -} from "./mouse-cursor-history"; +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import * as core from "@/platform/tauri-core"; +import { useJumpListStore } from "../stores/jump-list.store"; +import { type CursorHistoryEntry, recordMouseGesture } from "./mouse-cursor-history"; function entry(line: number, column: number): CursorHistoryEntry { return { @@ -16,21 +15,51 @@ function entry(line: number, column: number): CursorHistoryEntry { }; } -describe("mouse cursor history", () => { - test("records the gesture start when the cursor moved", () => { - const start = entry(10, 20); - - expect(cursorEntryToRecordAfterMouseGesture(start, entry(20, 5))).toEqual(start); - }); +afterEach(() => useJumpListStore.getState().actions.clear()); - test("does not record a click without cursor movement", () => { - const start = entry(10, 20); - - expect(cursorEntryToRecordAfterMouseGesture(start, entry(10, 20))).toBeNull(); +describe("mouse cursor history", () => { + test("sends one record event per completed gesture", async () => { + const reduce = spyOn(core, "invoke").mockImplementation((async (_command, args) => ({ + state: (args as { state: unknown }).state, + destination: null, + })) as typeof core.invoke); + try { + recordMouseGesture(entry(10, 20), entry(20, 5)); + await useJumpListStore.getState().actions.goBack(); + expect(reduce.mock.calls[0]).toEqual([ + "editor.navigationHistory.reduce", + { + state: { back: [], forward: [] }, + event: { + type: "record", + departure: { + document: "C:/workspace/a.ts", + line: 10, + column: 20, + context: { bufferId: "buffer-a", offset: 1020, scrollTop: 100, scrollLeft: 20 }, + }, + destination: { + document: "C:/workspace/a.ts", + line: 20, + column: 5, + context: { bufferId: "buffer-a", offset: 2005, scrollTop: 200, scrollLeft: 5 }, + }, + }, + }, + ]); + } finally { + reduce.mockRestore(); + } }); - test("does not record an incomplete gesture", () => { - expect(cursorEntryToRecordAfterMouseGesture(null, entry(20, 5))).toBeNull(); - expect(cursorEntryToRecordAfterMouseGesture(entry(10, 20), null)).toBeNull(); + test("skips a gesture without a known start or end position", () => { + const reduce = spyOn(core, "invoke"); + try { + recordMouseGesture(null, entry(20, 5)); + recordMouseGesture(entry(10, 20), null); + expect(reduce).not.toHaveBeenCalled(); + } finally { + reduce.mockRestore(); + } }); }); diff --git a/windows/tauri/src/features/editor/utils/mouse-cursor-history.ts b/windows/tauri/src/features/editor/utils/mouse-cursor-history.ts index 8d60ff5490..77f545e3f9 100644 --- a/windows/tauri/src/features/editor/utils/mouse-cursor-history.ts +++ b/windows/tauri/src/features/editor/utils/mouse-cursor-history.ts @@ -1,28 +1,16 @@ -import type { JumpListEntry } from "../stores/jump-list.store"; +import { type JumpListEntry, useJumpListStore } from "../stores/jump-list.store"; -export type CursorHistoryEntry = Omit; - -function cursorEntriesEqual(left: CursorHistoryEntry, right: CursorHistoryEntry): boolean { - return ( - left.bufferId === right.bufferId && - left.filePath === right.filePath && - left.line === right.line && - left.column === right.column && - left.offset === right.offset - ); -} +export type CursorHistoryEntry = JumpListEntry; /** - * Returns the position before a mouse gesture when the gesture moved the cursor. - * A click without movement does not create a jump-list entry. + * Records a completed mouse gesture as a jump from its start position. Core + * ignores a gesture that ends where it started, so a click without movement + * adds no history. One call per gesture keeps this off the per-cursor-move path. */ -export function cursorEntryToRecordAfterMouseGesture( +export function recordMouseGesture( gestureStart: CursorHistoryEntry | null, gestureEnd: CursorHistoryEntry | null, -): CursorHistoryEntry | null { - if (!gestureStart || !gestureEnd || cursorEntriesEqual(gestureStart, gestureEnd)) { - return null; - } - - return gestureStart; +): void { + if (!gestureStart || !gestureEnd) return; + void useJumpListStore.getState().actions.record(gestureStart, gestureEnd); } diff --git a/windows/tauri/src/features/keymaps/commands/navigation-command-actions.ts b/windows/tauri/src/features/keymaps/commands/navigation-command-actions.ts index 855c2d1f50..8b4dab761e 100644 --- a/windows/tauri/src/features/keymaps/commands/navigation-command-actions.ts +++ b/windows/tauri/src/features/keymaps/commands/navigation-command-actions.ts @@ -1,7 +1,11 @@ import { editorAPI } from "@/features/editor/extensions/api"; import { useBufferStore } from "@/features/editor/stores/buffer.store"; import { useEditorStateStore } from "@/features/editor/stores/state.store"; -import { useJumpListStore } from "@/features/editor/stores/jump-list.store"; +import { + type JumpListEntry, + type JumpListStep, + useJumpListStore, +} from "@/features/editor/stores/jump-list.store"; import { usePaneStore } from "@/features/panes/stores/pane.store"; import { navigateToJumpEntry } from "@/features/editor/utils/jump-navigation"; import { getLineTextFromContent, getLineTextsFromContent } from "@/features/editor/utils/position"; @@ -530,7 +534,7 @@ async function goToActiveLspLocation( return; } - useJumpListStore.getState().actions.pushEntry({ + void useJumpListStore.getState().actions.record({ bufferId: activeBuffer.id, filePath: activeBuffer.path, paneId: usePaneStore.getState().activePaneId, @@ -836,75 +840,59 @@ function activeWebViewerNavigation() { return useWebViewerNavigationStore.getState().navigationByBufferId[activeBuffer.id] ?? {}; } -export async function goBack(): Promise { - const webViewerNavigation = activeWebViewerNavigation(); - if (webViewerNavigation?.canGoBack && webViewerNavigation.goBack) { - webViewerNavigation.goBack(); - return; - } - +// A web page has no editor cursor to return to, so leaving it records no position. +function currentJumpListPosition(): JumpListEntry | undefined { + if (activeWebViewerNavigation()) return undefined; const bufferStore = useBufferStore.getState(); - const editorState = useEditorStateStore.getState(); - const cursorPosition = editorAPI.getCursorPosition(); const activeBufferId = bufferStore.activeBufferId; const activeBuffer = bufferStore.buffers.find((b) => b.id === activeBufferId); + if (!activeBufferId || !activeBuffer?.path) return undefined; + const editorState = useEditorStateStore.getState(); + const cursorPosition = editorAPI.getCursorPosition(); + return { + bufferId: activeBufferId, + filePath: activeBuffer.path, + paneId: usePaneStore.getState().activePaneId, + line: cursorPosition.line, + column: cursorPosition.column, + offset: cursorPosition.offset, + scrollTop: editorState.scrollTop, + scrollLeft: editorState.scrollLeft, + }; +} - const paneId = usePaneStore.getState().activePaneId; - // A web page has no editor cursor to return to, so leaving it records no position. - const currentPosition = - !webViewerNavigation && activeBufferId && activeBuffer?.path - ? { - bufferId: activeBufferId, - filePath: activeBuffer.path, - paneId, - line: cursorPosition.line, - column: cursorPosition.column, - offset: cursorPosition.offset, - scrollTop: editorState.scrollTop, - scrollLeft: editorState.scrollLeft, - } - : undefined; - - const jumpList = useJumpListStore.getState(); - const previousIndex = jumpList.currentIndex; - const entry = jumpList.actions.goBack(currentPosition); - if (entry) { - const didNavigate = await navigateToJumpEntry(entry); - if (!didNavigate) { - jumpList.actions.rollbackNavigation(entry, previousIndex, previousIndex === -1); +async function openJumpListStep(step: JumpListStep | null): Promise { + if (step) { + if (!(await navigateToJumpEntry(step.entry))) { + useJumpListStore.getState().actions.rollback(step); } return; } - // Repeated navigation at the beginning must not leave Monaco's input surface. + // Repeated navigation at either end must not leave Monaco's input surface. + const activeBufferId = useBufferStore.getState().activeBufferId; + const paneId = usePaneStore.getState().activePaneId; const ownerId = paneId && activeBufferId ? `${paneId}:${activeBufferId}` : undefined; editorAPI.focusWhenReady(ownerId); editorAPI.focus(ownerId); } -export async function goForward(): Promise { +export async function goBack(): Promise { const webViewerNavigation = activeWebViewerNavigation(); - if (webViewerNavigation?.canGoForward && webViewerNavigation.goForward) { - webViewerNavigation.goForward(); + if (webViewerNavigation?.canGoBack && webViewerNavigation.goBack) { + webViewerNavigation.goBack(); return; } + const step = await useJumpListStore.getState().actions.goBack(currentJumpListPosition()); + await openJumpListStep(step); +} - const jumpList = useJumpListStore.getState(); - const previousIndex = jumpList.currentIndex; - const entry = jumpList.actions.goForward(); - if (entry) { - const didNavigate = await navigateToJumpEntry(entry); - if (!didNavigate) { - jumpList.actions.rollbackNavigation(entry, previousIndex, false); - } +export async function goForward(): Promise { + const webViewerNavigation = activeWebViewerNavigation(); + if (webViewerNavigation?.canGoForward && webViewerNavigation.goForward) { + webViewerNavigation.goForward(); return; } - - // Repeated navigation at the end must not leave Monaco's input surface. - const bufferStore = useBufferStore.getState(); - const activeBufferId = bufferStore.activeBufferId; - const paneId = usePaneStore.getState().activePaneId; - const ownerId = paneId && activeBufferId ? `${paneId}:${activeBufferId}` : undefined; - editorAPI.focusWhenReady(ownerId); - editorAPI.focus(ownerId); + const step = await useJumpListStore.getState().actions.goForward(currentJumpListPosition()); + await openJumpListStep(step); } diff --git a/windows/tauri/src/features/keymaps/commands/navigation-command-actions.web-viewer.test.ts b/windows/tauri/src/features/keymaps/commands/navigation-command-actions.web-viewer.test.ts index 7a4b6d365f..0a3752f30d 100644 --- a/windows/tauri/src/features/keymaps/commands/navigation-command-actions.web-viewer.test.ts +++ b/windows/tauri/src/features/keymaps/commands/navigation-command-actions.web-viewer.test.ts @@ -26,7 +26,6 @@ beforeEach(() => { buffers: [{ id: WEB_BUFFER_ID, type: "webViewer", path: "https://example.com" }], } as unknown as ReturnType), spyOn(useJumpListStore, "getState").mockReturnValue({ - currentIndex: -1, actions: { goBack: jumpGoBack, goForward: jumpGoForward }, } as unknown as ReturnType), spyOn(editorAPI, "getCursorPosition").mockReturnValue({ line: 0, column: 0, offset: 0 }), @@ -64,6 +63,6 @@ describe("navigation commands on a web viewer tab", () => { expect(webGoForward).not.toHaveBeenCalled(); // The page has no editor cursor, so leaving it must not record a jump position. expect(jumpGoBack).toHaveBeenCalledWith(undefined); - expect(jumpGoForward).toHaveBeenCalledTimes(1); + expect(jumpGoForward).toHaveBeenCalledWith(undefined); }); }); diff --git a/windows/tauri/src/features/quick-open/hooks/use-quick-open.ts b/windows/tauri/src/features/quick-open/hooks/use-quick-open.ts index 513c519e35..a1df63ad60 100644 --- a/windows/tauri/src/features/quick-open/hooks/use-quick-open.ts +++ b/windows/tauri/src/features/quick-open/hooks/use-quick-open.ts @@ -30,7 +30,7 @@ function pushActiveEditorToJumpList() { if (activeBuffer?.type !== "editor" || !activeBuffer.path) return; const editorState = useEditorStateStore.getState(); - useJumpListStore.getState().actions.pushEntry({ + void useJumpListStore.getState().actions.record({ bufferId: activeBuffer.id, filePath: activeBuffer.path, paneId: usePaneStore.getState().activePaneId, From 0577447f9213c6ad3e079e004683e75efcd6f56d Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Wed, 4 Nov 2026 22:36:57 -0800 Subject: [PATCH 2/7] =?UTF-8?q?refactor(core):=20#1662=20LSP-16=20?= =?UTF-8?q?=E6=A0=87=E7=AD=BE/=E5=88=86=E7=BB=84=20reducer=20=E6=92=A4?= =?UTF-8?q?=E5=87=BA=E6=9C=AC=20PR=EF=BC=8C=E7=95=99=E5=BE=85=E5=90=8E?= =?UTF-8?q?=E7=BB=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows 的 pane-tree 是同步树结构,与 Core 的 WorkbenchState 数据模型不同,接入需要大重构,不适合本 PR。撤掉 editor.workbenchState.reduce 的命令、契约、fixture、测试和 macOS 接入。 Co-Authored-By: Claude Opus 5.5 --- .../2026-10-12-editor-tab-split-docking.md | 4 +- .../Features/EditorGroupLayout.swift | 48 +- .../Features/EditorTabOrderFeatureModel.swift | 241 +- .../EditorTabOrderFeatureModelTests.swift | 17 - rust/lithe-core/src/editor/mod.rs | 1 - rust/lithe-core/src/editor/tests.rs | 55 - rust/lithe-core/src/editor/workbench_state.rs | 626 ----- rust/lithe-core/src/protocol/command.rs | 4 - rust/lithe-core/src/runtime/dispatcher.rs | 5 - .../fixtures/editor/workbench-state-v1.json | 2429 ----------------- .../features/editor-text-tabs.json | 12 +- 11 files changed, 101 insertions(+), 3341 deletions(-) delete mode 100644 rust/lithe-core/src/editor/workbench_state.rs delete mode 100644 shared/fixtures/editor/workbench-state-v1.json diff --git a/.agents/notes/implemented/architecture/2026-10-12-editor-tab-split-docking.md b/.agents/notes/implemented/architecture/2026-10-12-editor-tab-split-docking.md index 4eeaa0487a..3bfc65ff3a 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-editor-tab-split-docking.md +++ b/.agents/notes/implemented/architecture/2026-10-12-editor-tab-split-docking.md @@ -16,7 +16,7 @@ Git 的工作区、工作树、目录、分支、历史提交和 Stash/Shelf 比 ## 决策 -编辑分组的二叉布局树(每次把一组拆成两组)、标签归属、选中项和聚焦组的规则由 Rust Core 的 `editor.workbenchState.reduce` 统一持有(`rust/lithe-core/src/editor/workbench_state.rs`,样例 `shared/fixtures/editor/workbench-state-v1.json`);`EditorTabOrderFeatureModel` 只保存 Core 返回的状态,新分组和分隔条 ID 由宿主生成后交给 Core。关闭或移走组内选中的标签后,该组选中接替原位置的标签(越过末尾则选新的末尾),不再回到第一个标签,与 Windows 窗格的既有规则一致。拖动时 `canDrop` 每次指针移动都会查询,宿主按当前状态缓存 Core 的结论,状态变化后清空,不逐帧跨 FFI。Windows 窗格树仍在 `pane-tree.ts`:它允许同一缓冲区同时出现在多个窗格,带预览、固定、锁定和底部窗格树,并由同步 zustand 调用链驱动打开与关闭流程,需要后续整体迁移到同一命令。 +编辑分组的二叉布局树(每次把一组拆成两组)与标签位置、选中项由已有 `EditorTabOrderFeatureModel` 管理。 普通分组编辑器的 Monaco 原生容器按项目及文档复用;同一个文档在组间移动时重新挂载原 WebView,不重建其 Monaco 模型,保留撤销/重做栈和视图状态。SwiftUI 内容身份也按文档固定,避免切换标签时继续持有另一文档的 StateObject。移动文件前通过原文档的同步屏障排空输入,失败保留原布局,较旧的待处理落点不能覆盖新请求;解绑时检查同步所有者,不能清除另一组已接管的保存回调。 跨文件 Rename / Code Action 读取其他文档快照前,先调用目标文档原有同步屏障;注册编辑镜像只能观察文本变化,不能接管目标文档保存与关闭的同步回调。目标 WebView 继续通过已有 replace 的 pushEditOperations 接收修改,保留自身撤销栈。标签关闭菜单按所属组当前混合标签顺序计算 Other / Left / Right / All,仅关闭文档类型,忽略终端等其他类型。 文档容器只绑定自己的文档,旧挂载退出不能移除目标容器中的编辑器。后台光标事件及没有原生 first responder 的迟到焦点事件不能切换活动组。分组选择变化后通过公开 DOM blur 和 Monaco focus 同步正文焦点,避免不同 WebView 同时保留闪烁光标。 @@ -128,8 +128,6 @@ Windows 的既有分栏实现不受此次 macOS 呈现改动影响,完整两 - `macos/Sources/Lithe/Application/Features/EditorTabOrderFeatureModel.swift` - `macos/Sources/Lithe/Application/Features/EditorGroupLayout.swift` -- `rust/lithe-core/src/editor/workbench_state.rs` -- `shared/fixtures/editor/workbench-state-v1.json` - `macos/Sources/Lithe/Models/AppModel/AppModel+EditorGroups.swift` - `macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift` - `macos/Sources/Lithe/Views/Editor/EditorAreaView.swift` diff --git a/macos/Sources/Lithe/Application/Features/EditorGroupLayout.swift b/macos/Sources/Lithe/Application/Features/EditorGroupLayout.swift index 534fc4c754..a69ec06b78 100644 --- a/macos/Sources/Lithe/Application/Features/EditorGroupLayout.swift +++ b/macos/Sources/Lithe/Application/Features/EditorGroupLayout.swift @@ -1,7 +1,6 @@ import Foundation -/// Editor group split tree as returned by Rust Core (`editor.workbenchState.reduce`). -/// Core owns how the tree splits and collapses; this type only presents it. +/// Editor groups own tab placement and selection; documents and sessions keep their existing owners. indirect enum EditorGroupLayout: Equatable { case group(UUID) case split(id: UUID, vertical: Bool, first: EditorGroupLayout, second: EditorGroupLayout) @@ -12,41 +11,36 @@ indirect enum EditorGroupLayout: Equatable { case .split(_, _, let first, let second): first.groups + second.groups } } -} -extension EditorGroupLayout: Codable { - private struct Split: Codable { - let id: UUID - let orientation: String - let first: EditorGroupLayout - let second: EditorGroupLayout + func replacing(_ id: UUID, with replacement: EditorGroupLayout) -> EditorGroupLayout { + switch self { + case .group(let current): current == id ? replacement : self + case .split(let splitID, let vertical, let first, let second): + .split(id: splitID, vertical: vertical, first: first.replacing(id, with: replacement), second: second.replacing(id, with: replacement)) + } } - private enum CodingKeys: String, CodingKey { case group, split } - - init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - if let id = try container.decodeIfPresent(UUID.self, forKey: .group) { - self = .group(id) - return - } - let split = try container.decode(Split.self, forKey: .split) - self = .split(id: split.id, vertical: split.orientation == "vertical", first: split.first, second: split.second) + /// Splits `id` along `side`; a leading edge puts the new group first. + func splitting(_ id: UUID, by side: EditorGroupDropSide, newGroup: UUID) -> EditorGroupLayout { + let new = Self.group(newGroup), old = Self.group(id) + let leading = side == .left || side == .top + let layout = Self.split(id: UUID(), vertical: side.isVertical, first: leading ? new : old, second: leading ? old : new) + return replacing(id, with: layout) } - func encode(to encoder: any Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) + func keeping(_ ids: Set) -> EditorGroupLayout? { switch self { - case .group(let id): - try container.encode(id, forKey: .group) + case .group(let id): return ids.contains(id) ? self : nil case .split(let id, let vertical, let first, let second): - try container.encode( - Split(id: id, orientation: vertical ? "vertical" : "horizontal", first: first, second: second), - forKey: .split) + let a = first.keeping(ids), b = second.keeping(ids) + if let a, let b { return .split(id: id, vertical: vertical, first: a, second: b) } + return a ?? b } } } -enum EditorGroupDropSide: String, CaseIterable, Encodable { +enum EditorGroupDropSide: CaseIterable { case center, left, right, top, bottom + + var isVertical: Bool { self == .top || self == .bottom } } diff --git a/macos/Sources/Lithe/Application/Features/EditorTabOrderFeatureModel.swift b/macos/Sources/Lithe/Application/Features/EditorTabOrderFeatureModel.swift index fee08b6043..7be829f774 100644 --- a/macos/Sources/Lithe/Application/Features/EditorTabOrderFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/EditorTabOrderFeatureModel.swift @@ -39,9 +39,7 @@ final class EditorTabOrderFeatureModel: ObservableObject { @Published var documentPreviewModes: [UUID: DocumentPreviewMode] = [:] @Published private(set) var items: [EditorTabItem] = [] { didSet { - if !isApplyingWorkbenchState { - reduceWorkbench(.sync(tabs: items.map(Self.workbenchID)), previousItems: oldValue) - } + reconcileGroups() let open = Set(agentTabIDs) if agentSessionByTab.keys.contains(where: { !open.contains($0) }) { agentSessionByTab = agentSessionByTab.filter { open.contains($0.key) } @@ -60,10 +58,6 @@ final class EditorTabOrderFeatureModel: ObservableObject { @Published private(set) var agentSessionByTab: [UUID: AgentEditorConversation] = [:] private var placements: [EditorTabItem: UUID] = [:] private var focusedGroup: UUID? - private var isApplyingWorkbenchState = false - /// Drop eligibility per state; drags query it on every pointer move, so - /// Core answers each tab/group/side once until the state changes. - private var dropEligibility: [String: Bool] = [:] var groupDropRequestID = UUID() func items(in group: UUID) -> [EditorTabItem] { @@ -128,35 +122,85 @@ final class EditorTabOrderFeatureModel: ObservableObject { func selectedItem(in group: UUID) -> EditorTabItem? { groupSelections[group] } func focus(_ item: EditorTabItem) { - guard let group = placements[item], focusedGroup != group || groupSelections[group] != item else { return } - reduceWorkbench(.focus(tab: Self.workbenchID(item))) + guard let group = placements[item] else { return } + focusedGroup = group + if groupSelections[group] != item { groupSelections[group] = item } } func canDrop(_ item: EditorTabItem, into group: UUID, side: EditorGroupDropSide) -> Bool { - let key = "\(Self.workbenchID(item))|\(group.uuidString)|\(side.rawValue)" - if let cached = dropEligibility[key] { return cached } - let allowed = reduceWorkbench(.canDrop(tab: Self.workbenchID(item), group: group, side: side)) - dropEligibility[key] = allowed - return allowed + guard items.contains(item), groupLayout.groups.contains(group) else { return false } + return placements[item] != group || (side != .center && items(in: group).count > 1) } /// A drop moves membership only; it never closes or reopens the underlying resource. @discardableResult func drop(_ item: EditorTabItem, into group: UUID, side: EditorGroupDropSide) -> Bool { - let split = side == .center ? nil : (group: UUID(), split: UUID()) - return reduceWorkbench(.drop( - tab: Self.workbenchID(item), group: group, side: side, - newGroup: split?.group, newSplit: split?.split)) + guard canDrop(item, into: group, side: side) else { return false } + if side == .center { + placements[item] = group + var ordered = items.filter { $0 != item } + let last = ordered.lastIndex { placements[$0] == group } + ordered.insert(item, at: last.map { $0 + 1 } ?? ordered.count) + if ordered != items { items = ordered } + } else { + let created = UUID() + groupLayout = groupLayout.splitting(group, by: side, newGroup: created) + placements[item] = created + } + reconcileGroups() + focus(item) + return true } /// Adds a tab from outside the editor; an edge always splits because the target group does not own it yet. @discardableResult func insert(_ item: EditorTabItem, into group: UUID?, side: EditorGroupDropSide) -> Bool { - let split = group == nil || side == .center ? nil : (group: UUID(), split: UUID()) - return reduceWorkbench( - .insert(tab: Self.workbenchID(item), group: group, side: side, - newGroup: split?.group, newSplit: split?.split), - adding: [item]) + guard !items.contains(item) else { return false } + let groups = groupLayout.groups + guard let group else { + let destination = focusedGroup.flatMap { groups.contains($0) ? $0 : nil } ?? groups[0] + placements[item] = destination + var next = items + next.append(item) + items = next + focus(item) + return true + } + guard groups.contains(group) else { return false } + if side == .center { + placements[item] = group + var next = items + let last = next.lastIndex { placements[$0] == group } + next.insert(item, at: last.map { $0 + 1 } ?? next.count) + items = next + } else { + let created = UUID() + groupLayout = groupLayout.splitting(group, by: side, newGroup: created) + placements[item] = created + var next = items + next.append(item) + items = next + } + focus(item) + return true + } + + private func reconcileGroups() { + let groups = groupLayout.groups + let destination = focusedGroup.flatMap { groups.contains($0) ? $0 : nil } ?? groups[0] + placements = placements.filter { items.contains($0.key) } + for item in items where placements[item] == nil { placements[item] = destination } + let occupied = Set(placements.values) + groupLayout = groupLayout.keeping(occupied) ?? .group(groups[0]) + let remaining = groupLayout.groups + if !remaining.contains(destination) { focusedGroup = remaining[0] } + var selections: [UUID: EditorTabItem] = [:] + for group in remaining { + let tabs = items(in: group) + if let selected = groupSelections[group], tabs.contains(selected) { selections[group] = selected } + else { selections[group] = tabs.first } + } + if selections != groupSelections { groupSelections = selections } } @Published var repositoryDiffSelected = false @@ -293,149 +337,14 @@ final class EditorTabOrderFeatureModel: ObservableObject { relativeTo target: EditorTabItem, insertAfter: Bool ) -> Bool { - reduceWorkbench( - .move(tab: Self.workbenchID(item), target: Self.workbenchID(target), - position: insertAfter ? "after" : "before"), - adding: [item]) - } - - // MARK: Core-owned group membership - - /// Rust Core (`editor.workbenchState.reduce`) owns placement, split and - /// collapse, selection and focus; this model only stores the result. - private enum WorkbenchAction: Encodable { - case sync(tabs: [String]) - case drop(tab: String, group: UUID, side: EditorGroupDropSide, newGroup: UUID?, newSplit: UUID?) - case canDrop(tab: String, group: UUID, side: EditorGroupDropSide) - case insert(tab: String, group: UUID?, side: EditorGroupDropSide, newGroup: UUID?, newSplit: UUID?) - case move(tab: String, target: String, position: String) - case focus(tab: String) - - private enum CodingKeys: String, CodingKey { - case type, tabs, tab, group, side, newGroup, newSplit, target, position - } - - func encode(to encoder: any Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - switch self { - case .sync(let tabs): - try container.encode("sync", forKey: .type) - try container.encode(tabs, forKey: .tabs) - case .drop(let tab, let group, let side, let newGroup, let newSplit): - try container.encode("drop", forKey: .type) - try container.encode(tab, forKey: .tab) - try container.encode(group, forKey: .group) - try container.encode(side, forKey: .side) - try container.encodeIfPresent(newGroup, forKey: .newGroup) - try container.encodeIfPresent(newSplit, forKey: .newSplit) - case .canDrop(let tab, let group, let side): - try container.encode("canDrop", forKey: .type) - try container.encode(tab, forKey: .tab) - try container.encode(group, forKey: .group) - try container.encode(side, forKey: .side) - case .insert(let tab, let group, let side, let newGroup, let newSplit): - try container.encode("insert", forKey: .type) - try container.encode(tab, forKey: .tab) - try container.encode(group, forKey: .group) - try container.encode(side, forKey: .side) - try container.encodeIfPresent(newGroup, forKey: .newGroup) - try container.encodeIfPresent(newSplit, forKey: .newSplit) - case .move(let tab, let target, let position): - try container.encode("move", forKey: .type) - try container.encode(tab, forKey: .tab) - try container.encode(target, forKey: .target) - try container.encode(position, forKey: .position) - case .focus(let tab): - try container.encode("focus", forKey: .type) - try container.encode(tab, forKey: .tab) - } - } - } - - private struct WorkbenchState: Codable { - struct Tab: Codable { let id: String; let group: UUID? } - struct Selection: Codable { let group: UUID; let tab: String? } - let layout: EditorGroupLayout - let tabs: [Tab] - let selections: [Selection] - let focusedGroup: UUID? - } - - private struct WorkbenchRequest: Encodable { - let state: WorkbenchState - let action: WorkbenchAction - } - - private struct WorkbenchReduction: Decodable { - let applied: Bool - let state: WorkbenchState - } - - private static func workbenchID(_ item: EditorTabItem) -> String { - switch item { - case .document(let id): "document:\(id.uuidString)" - case .terminal(let id): "terminal:\(id.uuidString)" - case .media(let id): "media:\(id.uuidString)" - case .repositoryDiff(let id): "repositoryDiff:\(id.uuidString)" - case .agentConversation(let id): "agentConversation:\(id.uuidString)" - case .languageLogs: "languageLogs" - } - } - - /// Sends the current state and one action to Core and stores an applied - /// result. `previousItems` describes the state a `sync` started from; - /// `adding` names tabs the action may introduce. - @discardableResult - private func reduceWorkbench( - _ action: WorkbenchAction, - previousItems: [EditorTabItem]? = nil, - adding: [EditorTabItem] = [] - ) -> Bool { - let source = previousItems ?? items - let state = WorkbenchState( - layout: groupLayout, - tabs: source.map { WorkbenchState.Tab(id: Self.workbenchID($0), group: placements[$0]) }, - selections: groupLayout.groups.map { - WorkbenchState.Selection(group: $0, tab: groupSelections[$0].map(Self.workbenchID)) - }, - focusedGroup: focusedGroup) - let result: Result = RustCoreBridge().executeResult( - command: "editor.workbenchState.reduce", payload: WorkbenchRequest(state: state, action: action)) - guard case .success(let reduction) = result else { - // log-exempt: 同一次失败已经记录过:Core 请求轨迹已记录这次失败,标签分组保持原状态 - return false - } - if case .canDrop = action { return reduction.applied } - guard reduction.applied else { return false } - var lookup: [EditorTabItem] = items - lookup.append(contentsOf: source) - lookup.append(contentsOf: adding) - applyWorkbench(reduction.state, lookup: Dictionary( - lookup.map { (Self.workbenchID($0), $0) }, uniquingKeysWith: { first, _ in first })) + guard item != target, items.contains(target) else { return false } + var next = items + next.removeAll { $0 == item } + guard let targetIndex = next.firstIndex(of: target) else { return false } + next.insert(item, at: targetIndex + (insertAfter ? 1 : 0)) + guard next != items || placements[item] != placements[target] else { return false } + if let destination = placements[target] { placements[item] = destination } + items = next return true } - - private func applyWorkbench(_ state: WorkbenchState, lookup: [String: EditorTabItem]) { - var nextPlacements: [EditorTabItem: UUID] = [:] - var nextItems: [EditorTabItem] = [] - for tab in state.tabs { - guard let item = lookup[tab.id] else { continue } - nextItems.append(item) - if let group = tab.group { nextPlacements[item] = group } - } - var selections: [UUID: EditorTabItem] = [:] - for selection in state.selections { - if let tab = selection.tab, let item = lookup[tab] { selections[selection.group] = item } - } - dropEligibility.removeAll() - placements = nextPlacements - focusedGroup = state.focusedGroup - if groupLayout != state.layout { groupLayout = state.layout } - if groupSelections != selections { groupSelections = selections } - if nextItems != items { - isApplyingWorkbenchState = true - defer { isApplyingWorkbenchState = false } - items = nextItems - } - } } diff --git a/macos/Tests/LitheTests/EditorTabOrderFeatureModelTests.swift b/macos/Tests/LitheTests/EditorTabOrderFeatureModelTests.swift index 8f612e3498..f57e52c526 100644 --- a/macos/Tests/LitheTests/EditorTabOrderFeatureModelTests.swift +++ b/macos/Tests/LitheTests/EditorTabOrderFeatureModelTests.swift @@ -1373,23 +1373,6 @@ struct EditorTabOrderFeatureModelTests { #expect(appModel.activeDocumentID == firstDocument.id) #expect(appModel.activeEditorTerminalSession == nil) } - /// Core owns selection convergence: closing the selected tab selects the tab - /// that slides into its slot (shared fixture `workbench-state-v1.json`), - /// not the group's first tab. - @Test func closingTheSelectedTabSelectsTheTabThatTakesItsPlace() throws { - let feature = EditorTabOrderFeatureModel() - let a = UUID(), b = UUID(), c = UUID() - feature.reconcileDocuments(orderedIDs: [a, b, c]) - let group = try #require(feature.groupLayout.groups.first) - feature.focus(.document(b)) - feature.reconcileDocuments(orderedIDs: [a, c]) - #expect(feature.selectedItem(in: group) == .document(c)) - feature.focus(.document(c)) - feature.reconcileDocuments(orderedIDs: [a]) - #expect(feature.selectedItem(in: group) == .document(a)) - #expect(feature.focusedItem == .document(a)) - } - @Test func editorGroupsMoveTabsKeepSelectionAndCollapseEmptySplits() throws { let feature = EditorTabOrderFeatureModel() let a = UUID(), b = UUID(), c = UUID() diff --git a/rust/lithe-core/src/editor/mod.rs b/rust/lithe-core/src/editor/mod.rs index ad42f4914a..ffa421fcf6 100644 --- a/rust/lithe-core/src/editor/mod.rs +++ b/rust/lithe-core/src/editor/mod.rs @@ -10,7 +10,6 @@ pub(crate) mod line_edit; pub(crate) mod navigation_history; pub(crate) mod navigation_input; pub(crate) mod text_edit_plan; -pub(crate) mod workbench_state; pub(crate) use line_edit::{ line_comment_token, line_edit, LineCommentTokenRequest, LineEditRequest, diff --git a/rust/lithe-core/src/editor/tests.rs b/rust/lithe-core/src/editor/tests.rs index 613a9078da..9cb2db86f9 100644 --- a/rust/lithe-core/src/editor/tests.rs +++ b/rust/lithe-core/src/editor/tests.rs @@ -133,61 +133,6 @@ fn navigation_history_keeps_only_the_newest_entries() { assert_eq!(forward[0]["line"], 5); } -/// Locks the shared tab group rules both hosts consume; each case runs through -/// the public JSON command so routing and encoding are covered as well. -#[test] -fn workbench_state_matches_shared_fixture() { - let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/editor/workbench-state-v1.json" - )) - .unwrap(); - let run = |state: &Value, action: &Value| -> Value { - serde_json::from_str(&crate::execute_json( - &json!({"command":"editor.workbenchState.reduce", - "payload":{"state":state, "action":action}}) - .to_string(), - )) - .unwrap() - }; - for case in fixture["cases"].as_array().unwrap() { - let response = run(&case["state"], &case["action"]); - assert_eq!(response["ok"], true, "{}: {response}", case["name"]); - assert_eq!( - response["data"]["applied"], case["applied"], - "{}", - case["name"] - ); - assert_eq!( - response["data"]["state"], case["expected"], - "{}", - case["name"] - ); - // A returned state is already normalized: reducing it again with a - // no-op sync must not change it. - let tabs: Vec = case["expected"]["tabs"] - .as_array() - .unwrap() - .iter() - .map(|tab| tab["id"].clone()) - .collect(); - let again = run(&case["expected"], &json!({"type":"sync", "tabs":tabs})); - assert_eq!( - again["data"]["applied"], false, - "{} idempotence", - case["name"] - ); - } - for case in fixture["invalid"].as_array().unwrap() { - let response = run(&case["state"], &case["action"]); - assert_eq!(response["ok"], false, "{}: {response}", case["name"]); - assert_eq!( - response["error"]["code"], "invalid_request", - "{}", - case["name"] - ); - } -} - fn edit( operation: &str, source: &str, diff --git a/rust/lithe-core/src/editor/workbench_state.rs b/rust/lithe-core/src/editor/workbench_state.rs deleted file mode 100644 index 994391859f..0000000000 --- a/rust/lithe-core/src/editor/workbench_state.rs +++ /dev/null @@ -1,626 +0,0 @@ -//! Editor tab group membership rules owned by Rust Core (`editor.workbenchState.reduce`). -//! -//! The reducer owns which group presents each tab, how the binary split tree -//! grows and collapses, which tab each group selects, and which group has the -//! focus after opening, closing, moving, dropping and focusing tabs. Hosts own -//! everything visual: split ratios, pixel geometry, drag hit testing and the -//! per-frame drag target. Tab and group identifiers are opaque host strings; -//! new group and split identities are supplied by the host so the reducer -//! stays deterministic. - -use std::collections::{BTreeMap, BTreeSet, HashSet}; - -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -use crate::protocol::{CoreError, ErrorCode}; - -/// Upper bound on tabs in one request; real workbenches hold far fewer and the -/// bound keeps the quadratic order operations cheap. -const MAX_TABS: usize = 10_000; - -/// Complete tab group state exchanged with a host. Hosts treat a returned state -/// as the only source for placement, selection and focus. -#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct WorkbenchState { - layout: GroupLayout, - /// Tabs in the host's presentation order. A group presents its tabs in - /// this relative order. - tabs: Vec, - /// One entry per group, in layout order. `tab` is null only for an empty group. - #[serde(default)] - selections: Vec, - /// Group that receives tabs opened without an explicit target. Null until - /// the host first focuses a tab. - focused_group: Option, -} - -/// Binary split tree of editor groups. -#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -enum GroupLayout { - /// A leaf group identified by its opaque host ID. - Group(String), - Split(Box), -} - -#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct SplitLayout { - /// Opaque host ID; hosts key split ratios by it. - id: String, - orientation: Orientation, - /// Left child for horizontal splits, top child for vertical splits. - first: GroupLayout, - second: GroupLayout, -} - -/// `horizontal` places the children side by side; `vertical` stacks them. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -enum Orientation { - Horizontal, - Vertical, -} - -#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct TabPlacement { - id: String, - /// Owning group. Null or an unknown group places the tab in the focused group. - group: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct GroupSelection { - group: String, - tab: Option, -} - -/// Drop or insert position relative to a target group. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] -#[serde(rename_all = "camelCase")] -enum DropSide { - /// Join the target group's tab strip. - Center, - Left, - Right, - Top, - Bottom, -} - -impl DropSide { - fn orientation(self) -> Orientation { - match self { - Self::Top | Self::Bottom => Orientation::Vertical, - _ => Orientation::Horizontal, - } - } - - fn leading(self) -> bool { - matches!(self, Self::Left | Self::Top) - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] -#[serde(rename_all = "camelCase")] -enum MovePosition { - Before, - After, -} - -/// One user-level tab action. Every action is followed by the same -/// normalization, so membership, selection and focus rules have one owner. -#[derive(Debug, Deserialize)] -#[serde(tag = "type", rename_all = "camelCase", deny_unknown_fields)] -enum WorkbenchAction { - /// Replaces the set and order of open tabs; existing placements are kept - /// and new tabs join the focused group. - Sync { tabs: Vec }, - /// Moves an open tab into a group, or into a new group split off it. - #[serde(rename_all = "camelCase")] - Drop { - tab: String, - group: String, - side: DropSide, - new_group: Option, - new_split: Option, - }, - /// Reports whether the matching `drop` would apply, without changing state. - CanDrop { - tab: String, - group: String, - side: DropSide, - }, - /// Opens a tab that is not yet presented. A null group targets the - /// focused group and ignores `side`. - #[serde(rename_all = "camelCase")] - Insert { - tab: String, - group: Option, - side: DropSide, - new_group: Option, - new_split: Option, - }, - /// Reorders a tab next to another tab and adopts that tab's group. - Move { - tab: String, - target: String, - position: MovePosition, - }, - /// Selects a tab and focuses its group. - Focus { tab: String }, -} - -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct Request { - state: WorkbenchState, - action: WorkbenchAction, -} - -/// Reduction outcome. `applied` is false for rejected or no-op actions, which -/// return the normalized input state. -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct Response { - applied: bool, - state: WorkbenchState, -} - -/// Entry point for `editor.workbenchState.reduce`. -/// -/// Returns `invalid_request` for malformed payloads, structurally invalid -/// layouts (duplicate group or split IDs), oversized tab lists, and edge -/// drops whose new group or split identity is missing or already in use. -pub(crate) fn reduce_command(payload: Value) -> Result { - let request: Request = serde_json::from_value(payload).map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid workbench state request") - .with_details(error.to_string()) - })?; - let response = reduce(request.state, request.action)?; - Ok(serde_json::to_value(response).expect("workbench state response should encode")) -} - -fn invalid(message: &str) -> CoreError { - CoreError::new(ErrorCode::InvalidRequest, message) -} - -fn reduce(state: WorkbenchState, action: WorkbenchAction) -> Result { - validate_layout(&state.layout)?; - if state.tabs.len() > MAX_TABS { - return Err(invalid("Too many workbench tabs")); - } - let current = normalize(&state, state.clone()); - let unchanged = |current: WorkbenchState| Response { - applied: false, - state: current, - }; - let next = match action { - WorkbenchAction::Sync { tabs } => { - if tabs.len() > MAX_TABS { - return Err(invalid("Too many workbench tabs")); - } - let mut draft = current.clone(); - draft.tabs = tabs - .into_iter() - .map(|id| { - let group = group_of(¤t, &id).map(str::to_owned); - TabPlacement { id, group } - }) - .collect(); - Some(normalize(¤t, draft)) - } - WorkbenchAction::CanDrop { tab, group, side } => { - let allowed = can_drop(¤t, &tab, &group, side); - return Ok(Response { - applied: allowed, - state: current, - }); - } - WorkbenchAction::Drop { - tab, - group, - side, - new_group, - new_split, - } => { - if !can_drop(¤t, &tab, &group, side) { - return Ok(unchanged(current)); - } - let mut draft = current.clone(); - if side == DropSide::Center { - place_after_group(&mut draft.tabs, &tab, &group); - } else { - let created = split_group(&mut draft, &group, side, new_group, new_split)?; - set_group(&mut draft.tabs, &tab, created); - } - let mut next = normalize(¤t, draft); - focus(&mut next, &tab); - Some(next) - } - WorkbenchAction::Insert { - tab, - group, - side, - new_group, - new_split, - } => { - if group_of(¤t, &tab).is_some() { - return Ok(unchanged(current)); - } - let mut draft = current.clone(); - match group { - None => draft.tabs.push(TabPlacement { - id: tab.clone(), - group: Some(destination(¤t).to_owned()), - }), - Some(group) if !current.layout.groups().contains(&group.as_str()) => { - return Ok(unchanged(current)); - } - Some(group) if side == DropSide::Center => { - draft.tabs.push(TabPlacement { - id: tab.clone(), - group: None, - }); - place_after_group(&mut draft.tabs, &tab, &group); - } - Some(group) => { - let created = split_group(&mut draft, &group, side, new_group, new_split)?; - draft.tabs.push(TabPlacement { - id: tab.clone(), - group: Some(created), - }); - } - } - let mut next = normalize(¤t, draft); - focus(&mut next, &tab); - Some(next) - } - WorkbenchAction::Move { - tab, - target, - position, - } => { - let target_group = group_of(¤t, &target).map(str::to_owned); - match target_group { - Some(target_group) if tab != target => { - let mut draft = current.clone(); - draft.tabs.retain(|placement| placement.id != tab); - let index = draft - .tabs - .iter() - .position(|placement| placement.id == target) - .expect("target remains after removing a different tab"); - let index = index + usize::from(position == MovePosition::After); - draft.tabs.insert( - index, - TabPlacement { - id: tab.clone(), - group: Some(target_group.clone()), - }, - ); - let same_order = draft - .tabs - .iter() - .map(|placement| &placement.id) - .eq(current.tabs.iter().map(|placement| &placement.id)); - if same_order && group_of(¤t, &tab) == Some(target_group.as_str()) { - None - } else { - Some(normalize(¤t, draft)) - } - } - _ => None, - } - } - WorkbenchAction::Focus { tab } => { - let mut next = current.clone(); - focus(&mut next, &tab); - Some(next) - } - }; - Ok(match next { - Some(next) if next != current => Response { - applied: true, - state: next, - }, - _ => unchanged(current), - }) -} - -impl GroupLayout { - fn groups(&self) -> Vec<&str> { - let mut groups = Vec::new(); - self.collect_groups(&mut groups); - groups - } - - fn collect_groups<'a>(&'a self, groups: &mut Vec<&'a str>) { - match self { - Self::Group(id) => groups.push(id), - Self::Split(split) => { - split.first.collect_groups(groups); - split.second.collect_groups(groups); - } - } - } - - fn collect_ids<'a>(&'a self, ids: &mut Vec<&'a str>) { - match self { - Self::Group(id) => ids.push(id), - Self::Split(split) => { - ids.push(&split.id); - split.first.collect_ids(ids); - split.second.collect_ids(ids); - } - } - } - - /// Replaces the leaf `group` with a split holding it and `created`. - fn splitting(self, group: &str, side: DropSide, created: &str, split_id: &str) -> Self { - match self { - Self::Group(id) if id == group => { - let old = Self::Group(id); - let new = Self::Group(created.to_owned()); - let (first, second) = if side.leading() { - (new, old) - } else { - (old, new) - }; - Self::Split(Box::new(SplitLayout { - id: split_id.to_owned(), - orientation: side.orientation(), - first, - second, - })) - } - Self::Group(id) => Self::Group(id), - Self::Split(split) => { - let SplitLayout { - id, - orientation, - first, - second, - } = *split; - Self::Split(Box::new(SplitLayout { - id, - orientation, - first: first.splitting(group, side, created, split_id), - second: second.splitting(group, side, created, split_id), - })) - } - } - } - - /// Removes groups outside `occupied`; a split with one surviving child is - /// replaced by that child, keeping the surviving subtree's identities. - fn keeping(self, occupied: &BTreeSet<&str>) -> Option { - match self { - Self::Group(id) => occupied.contains(id.as_str()).then_some(Self::Group(id)), - Self::Split(split) => { - let SplitLayout { - id, - orientation, - first, - second, - } = *split; - match (first.keeping(occupied), second.keeping(occupied)) { - (Some(first), Some(second)) => Some(Self::Split(Box::new(SplitLayout { - id, - orientation, - first, - second, - }))), - (first, second) => first.or(second), - } - } - } - } -} - -fn validate_layout(layout: &GroupLayout) -> Result<(), CoreError> { - let mut ids = Vec::new(); - layout.collect_ids(&mut ids); - let mut seen = HashSet::new(); - if ids.iter().all(|id| seen.insert(*id)) { - Ok(()) - } else { - Err(invalid("Workbench layout repeats a group or split ID")) - } -} - -fn group_of<'a>(state: &'a WorkbenchState, tab: &str) -> Option<&'a str> { - state - .tabs - .iter() - .find(|placement| placement.id == tab) - .and_then(|placement| placement.group.as_deref()) -} - -/// Group receiving tabs without a valid placement: the focused group while it -/// exists, otherwise the first group in layout order. -fn destination(state: &WorkbenchState) -> &str { - let groups = state.layout.groups(); - state - .focused_group - .as_deref() - .filter(|focused| groups.contains(focused)) - .unwrap_or(groups[0]) -} - -fn can_drop(state: &WorkbenchState, tab: &str, group: &str, side: DropSide) -> bool { - let Some(source) = group_of(state, tab) else { - return false; - }; - if !state.layout.groups().contains(&group) { - return false; - } - // Moving into another group always applies; within the owning group only - // an edge split of a group that keeps another tab changes anything. - source != group - || (side != DropSide::Center - && state - .tabs - .iter() - .filter(|placement| placement.group.as_deref() == Some(group)) - .count() - > 1) -} - -/// Moves `tab` into `group`, right after the group's last tab in -/// presentation order (or to the end when the group has none). -fn place_after_group(tabs: &mut Vec, tab: &str, group: &str) { - tabs.retain(|placement| placement.id != tab); - let index = tabs - .iter() - .rposition(|placement| placement.group.as_deref() == Some(group)) - .map_or(tabs.len(), |last| last + 1); - tabs.insert( - index, - TabPlacement { - id: tab.to_owned(), - group: Some(group.to_owned()), - }, - ); -} - -fn set_group(tabs: &mut [TabPlacement], tab: &str, group: String) { - if let Some(placement) = tabs.iter_mut().find(|placement| placement.id == tab) { - placement.group = Some(group); - } -} - -fn split_group( - draft: &mut WorkbenchState, - group: &str, - side: DropSide, - new_group: Option, - new_split: Option, -) -> Result { - let (Some(created), Some(split_id)) = (new_group, new_split) else { - return Err(invalid( - "Workbench edge drops require new group and split IDs", - )); - }; - let mut ids = Vec::new(); - draft.layout.collect_ids(&mut ids); - if created == split_id || ids.contains(&created.as_str()) || ids.contains(&split_id.as_str()) { - return Err(invalid("Workbench split identities must be new")); - } - let layout = std::mem::replace(&mut draft.layout, GroupLayout::Group(String::new())); - draft.layout = layout.splitting(group, side, &created, &split_id); - Ok(created) -} - -fn focus(state: &mut WorkbenchState, tab: &str) { - let Some(group) = group_of(state, tab).map(str::to_owned) else { - return; - }; - if let Some(selection) = state - .selections - .iter_mut() - .find(|selection| selection.group == group) - { - selection.tab = Some(tab.to_owned()); - } - state.focused_group = Some(group); -} - -/// Applies the shared invariants to `draft`, using `previous` to keep each -/// group's selection near its former position. -/// -/// - Tab IDs are unique; the first occurrence wins. -/// - Tabs without a valid group join the focused group (else the first group). -/// - Empty groups are removed and their splits collapse; when every group is -/// empty, the first group remains as the single reusable group. -/// - A focused group that disappeared falls back to the first remaining group. -/// - A group keeps its selected tab while the tab stays in the group; -/// otherwise it selects the tab now at the former selection's index, or the -/// last tab when the index passed the end, or its first tab when it had no -/// selection. -fn normalize(previous: &WorkbenchState, mut draft: WorkbenchState) -> WorkbenchState { - let groups: Vec = draft - .layout - .groups() - .into_iter() - .map(str::to_owned) - .collect(); - let fallback = destination(&draft).to_owned(); - let mut seen = HashSet::new(); - draft - .tabs - .retain(|placement| seen.insert(placement.id.clone())); - for placement in &mut draft.tabs { - if !placement - .group - .as_ref() - .is_some_and(|group| groups.contains(group)) - { - placement.group = Some(fallback.clone()); - } - } - - let occupied: BTreeSet<&str> = draft - .tabs - .iter() - .filter_map(|placement| placement.group.as_deref()) - .collect(); - let layout = draft - .layout - .clone() - .keeping(&occupied) - .unwrap_or_else(|| GroupLayout::Group(groups[0].clone())); - let remaining: Vec = layout.groups().into_iter().map(str::to_owned).collect(); - draft.layout = layout; - if draft - .focused_group - .as_ref() - .is_some_and(|focused| !remaining.contains(focused)) - { - draft.focused_group = Some(remaining[0].clone()); - } - - let members = members_by_group(&draft); - let previous_members = members_by_group(previous); - let previous_selection: BTreeMap<&str, &str> = previous - .selections - .iter() - .filter_map(|selection| Some((selection.group.as_str(), selection.tab.as_deref()?))) - .collect(); - let selections = remaining - .iter() - .map(|group| { - let tabs = members.get(group.as_str()).cloned().unwrap_or_default(); - let selected = previous_selection.get(group.as_str()).copied(); - let tab = match selected { - Some(selected) if tabs.contains(&selected) => Some(selected), - Some(selected) => previous_members - .get(group.as_str()) - .and_then(|old| old.iter().position(|tab| *tab == selected)) - .and_then(|index| tabs.get(index.min(tabs.len().saturating_sub(1)))) - .or(tabs.first()) - .copied(), - None => tabs.first().copied(), - }; - GroupSelection { - group: group.clone(), - tab: tab.map(str::to_owned), - } - }) - .collect(); - draft.selections = selections; - draft -} - -fn members_by_group(state: &WorkbenchState) -> BTreeMap<&str, Vec<&str>> { - let mut members: BTreeMap<&str, Vec<&str>> = BTreeMap::new(); - for placement in &state.tabs { - if let Some(group) = placement.group.as_deref() { - members.entry(group).or_default().push(&placement.id); - } - } - members -} diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index f11c964e7d..3041c36b20 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -432,9 +432,6 @@ pub enum CoreCommand { /// Resolves the line comment token for a file extension /// (`editor.lineCommentToken`). EditorLineCommentToken, - /// Reduces editor tab group membership, selection and focus - /// (`editor.workbenchState.reduce`). - EditorWorkbenchStateReduce, /// Normalizes the stored proxy settings into the policy a network stack /// must apply (`network.proxyPolicy`). NetworkProxyPolicy, @@ -991,7 +988,6 @@ impl CoreCommand { "editor.parseNavigationInput" => Some(Self::EditorParseNavigationInput), "editor.navigationHistory.reduce" => Some(Self::EditorNavigationHistoryReduce), "editor.lineCommentToken" => Some(Self::EditorLineCommentToken), - "editor.workbenchState.reduce" => Some(Self::EditorWorkbenchStateReduce), "network.proxyPolicy" => Some(Self::NetworkProxyPolicy), "lsp.plainSnippet" => Some(Self::LspPlainSnippet), "lsp.builtinCompletions" => Some(Self::LspBuiltinCompletions), diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index f097bc4f77..dd2fbe918f 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -1919,11 +1919,6 @@ fn execute_parsed(mut parsed: CoreRequest) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } - CoreCommand::EditorWorkbenchStateReduce => plugin_response( - id, - parsed.payload, - crate::editor::workbench_state::reduce_command, - ), CoreCommand::NetworkProxyPolicy => { match serde_json::from_value::( parsed.payload, diff --git a/shared/fixtures/editor/workbench-state-v1.json b/shared/fixtures/editor/workbench-state-v1.json deleted file mode 100644 index f807498c6f..0000000000 --- a/shared/fixtures/editor/workbench-state-v1.json +++ /dev/null @@ -1,2429 +0,0 @@ -{ - "description": "Editor tab group membership, selection, split and collapse rules owned by Rust Core (editor.workbenchState.reduce). Orientation horizontal places children side by side; vertical stacks them. Sizes and drag geometry stay in the platform.", - "cases": [ - { - "name": "sync removes duplicate members and keeps the first occurrence", - "state": { - "layout": { - "group": "g1" - }, - "tabs": [ - { - "id": "a", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - } - ], - "focusedGroup": "g1" - }, - "action": { - "type": "sync", - "tabs": [ - "a", - "b", - "a" - ] - }, - "applied": true, - "expected": { - "layout": { - "group": "g1" - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - } - ], - "focusedGroup": "g1" - } - }, - { - "name": "sync places new and orphaned members in the focused group", - "state": { - "layout": { - "split": { - "id": "s1", - "orientation": "horizontal", - "first": { - "group": "g1" - }, - "second": { - "group": "g2" - } - } - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g2" - }, - { - "id": "x", - "group": "gone" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - }, - { - "group": "g2", - "tab": "b" - } - ], - "focusedGroup": "g2" - }, - "action": { - "type": "sync", - "tabs": [ - "a", - "b", - "x", - "n" - ] - }, - "applied": true, - "expected": { - "layout": { - "split": { - "id": "s1", - "orientation": "horizontal", - "first": { - "group": "g1" - }, - "second": { - "group": "g2" - } - } - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g2" - }, - { - "id": "x", - "group": "g2" - }, - { - "id": "n", - "group": "g2" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - }, - { - "group": "g2", - "tab": "b" - } - ], - "focusedGroup": "g2" - } - }, - { - "name": "sync without a focused group places new members in the first group", - "state": { - "layout": { - "group": "g1" - }, - "tabs": [], - "selections": [ - { - "group": "g1", - "tab": null - } - ], - "focusedGroup": null - }, - "action": { - "type": "sync", - "tabs": [ - "a", - "b" - ] - }, - "applied": true, - "expected": { - "layout": { - "group": "g1" - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - } - ], - "focusedGroup": null - } - }, - { - "name": "closing the selected tab selects the tab that takes its place", - "state": { - "layout": { - "group": "g1" - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g1" - }, - { - "id": "c", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "b" - } - ], - "focusedGroup": "g1" - }, - "action": { - "type": "sync", - "tabs": [ - "a", - "c" - ] - }, - "applied": true, - "expected": { - "layout": { - "group": "g1" - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "c", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "c" - } - ], - "focusedGroup": "g1" - } - }, - { - "name": "closing the selected last tab selects the new last tab", - "state": { - "layout": { - "group": "g1" - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g1" - }, - { - "id": "c", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "c" - } - ], - "focusedGroup": "g1" - }, - "action": { - "type": "sync", - "tabs": [ - "a", - "b" - ] - }, - "applied": true, - "expected": { - "layout": { - "group": "g1" - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "b" - } - ], - "focusedGroup": "g1" - } - }, - { - "name": "closing the last tab of a focused split group collapses the split and focuses the first remaining group", - "state": { - "layout": { - "split": { - "id": "s1", - "orientation": "horizontal", - "first": { - "group": "g1" - }, - "second": { - "group": "g2" - } - } - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g2" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - }, - { - "group": "g2", - "tab": "b" - } - ], - "focusedGroup": "g2" - }, - "action": { - "type": "sync", - "tabs": [ - "a" - ] - }, - "applied": true, - "expected": { - "layout": { - "group": "g1" - }, - "tabs": [ - { - "id": "a", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - } - ], - "focusedGroup": "g1" - } - }, - { - "name": "nested split collapse keeps the surviving split identity", - "state": { - "layout": { - "split": { - "id": "s1", - "orientation": "horizontal", - "first": { - "group": "g1" - }, - "second": { - "split": { - "id": "s2", - "orientation": "vertical", - "first": { - "group": "g2" - }, - "second": { - "group": "g3" - } - } - } - } - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g2" - }, - { - "id": "c", - "group": "g3" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - }, - { - "group": "g2", - "tab": "b" - }, - { - "group": "g3", - "tab": "c" - } - ], - "focusedGroup": "g1" - }, - "action": { - "type": "sync", - "tabs": [ - "a", - "c" - ] - }, - "applied": true, - "expected": { - "layout": { - "split": { - "id": "s1", - "orientation": "horizontal", - "first": { - "group": "g1" - }, - "second": { - "group": "g3" - } - } - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "c", - "group": "g3" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - }, - { - "group": "g3", - "tab": "c" - } - ], - "focusedGroup": "g1" - } - }, - { - "name": "closing every tab leaves one reusable empty group", - "state": { - "layout": { - "split": { - "id": "s1", - "orientation": "horizontal", - "first": { - "group": "g1" - }, - "second": { - "group": "g2" - } - } - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g2" - }, - { - "id": "c", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - }, - { - "group": "g2", - "tab": "b" - } - ], - "focusedGroup": "g1" - }, - "action": { - "type": "sync", - "tabs": [] - }, - "applied": true, - "expected": { - "layout": { - "group": "g1" - }, - "tabs": [], - "selections": [ - { - "group": "g1", - "tab": null - } - ], - "focusedGroup": "g1" - } - }, - { - "name": "unchanged sync is not applied", - "state": { - "layout": { - "split": { - "id": "s1", - "orientation": "horizontal", - "first": { - "group": "g1" - }, - "second": { - "group": "g2" - } - } - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g2" - }, - { - "id": "c", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - }, - { - "group": "g2", - "tab": "b" - } - ], - "focusedGroup": "g1" - }, - "action": { - "type": "sync", - "tabs": [ - "a", - "b", - "c" - ] - }, - "applied": false, - "expected": { - "layout": { - "split": { - "id": "s1", - "orientation": "horizontal", - "first": { - "group": "g1" - }, - "second": { - "group": "g2" - } - } - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g2" - }, - { - "id": "c", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - }, - { - "group": "g2", - "tab": "b" - } - ], - "focusedGroup": "g1" - } - }, - { - "name": "center drop joins the target group after its last tab and collapses the empty source", - "state": { - "layout": { - "split": { - "id": "s1", - "orientation": "horizontal", - "first": { - "group": "g1" - }, - "second": { - "group": "g2" - } - } - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g2" - }, - { - "id": "c", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - }, - { - "group": "g2", - "tab": "b" - } - ], - "focusedGroup": "g1" - }, - "action": { - "type": "drop", - "tab": "b", - "group": "g1", - "side": "center" - }, - "applied": true, - "expected": { - "layout": { - "group": "g1" - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "c", - "group": "g1" - }, - { - "id": "b", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "b" - } - ], - "focusedGroup": "g1" - } - }, - { - "name": "leading edge drop puts the new group first", - "state": { - "layout": { - "group": "g1" - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - } - ], - "focusedGroup": "g1" - }, - "action": { - "type": "drop", - "tab": "b", - "group": "g1", - "side": "left", - "newGroup": "g2", - "newSplit": "s1" - }, - "applied": true, - "expected": { - "layout": { - "split": { - "id": "s1", - "orientation": "horizontal", - "first": { - "group": "g2" - }, - "second": { - "group": "g1" - } - } - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g2" - } - ], - "selections": [ - { - "group": "g2", - "tab": "b" - }, - { - "group": "g1", - "tab": "a" - } - ], - "focusedGroup": "g2" - } - }, - { - "name": "bottom edge drop stacks the new group second", - "state": { - "layout": { - "split": { - "id": "s1", - "orientation": "horizontal", - "first": { - "group": "g1" - }, - "second": { - "group": "g2" - } - } - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g2" - }, - { - "id": "c", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - }, - { - "group": "g2", - "tab": "b" - } - ], - "focusedGroup": "g1" - }, - "action": { - "type": "drop", - "tab": "c", - "group": "g2", - "side": "bottom", - "newGroup": "g3", - "newSplit": "s2" - }, - "applied": true, - "expected": { - "layout": { - "split": { - "id": "s1", - "orientation": "horizontal", - "first": { - "group": "g1" - }, - "second": { - "split": { - "id": "s2", - "orientation": "vertical", - "first": { - "group": "g2" - }, - "second": { - "group": "g3" - } - } - } - } - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g2" - }, - { - "id": "c", - "group": "g3" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - }, - { - "group": "g2", - "tab": "b" - }, - { - "group": "g3", - "tab": "c" - } - ], - "focusedGroup": "g3" - } - }, - { - "name": "edge drop from a single-tab group onto itself is rejected", - "state": { - "layout": { - "split": { - "id": "s1", - "orientation": "horizontal", - "first": { - "group": "g1" - }, - "second": { - "group": "g2" - } - } - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g2" - }, - { - "id": "c", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - }, - { - "group": "g2", - "tab": "b" - } - ], - "focusedGroup": "g1" - }, - "action": { - "type": "drop", - "tab": "b", - "group": "g2", - "side": "right", - "newGroup": "g3", - "newSplit": "s2" - }, - "applied": false, - "expected": { - "layout": { - "split": { - "id": "s1", - "orientation": "horizontal", - "first": { - "group": "g1" - }, - "second": { - "group": "g2" - } - } - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g2" - }, - { - "id": "c", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - }, - { - "group": "g2", - "tab": "b" - } - ], - "focusedGroup": "g1" - } - }, - { - "name": "center drop onto the owning group is rejected", - "state": { - "layout": { - "split": { - "id": "s1", - "orientation": "horizontal", - "first": { - "group": "g1" - }, - "second": { - "group": "g2" - } - } - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g2" - }, - { - "id": "c", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - }, - { - "group": "g2", - "tab": "b" - } - ], - "focusedGroup": "g1" - }, - "action": { - "type": "drop", - "tab": "a", - "group": "g1", - "side": "center" - }, - "applied": false, - "expected": { - "layout": { - "split": { - "id": "s1", - "orientation": "horizontal", - "first": { - "group": "g1" - }, - "second": { - "group": "g2" - } - } - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g2" - }, - { - "id": "c", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - }, - { - "group": "g2", - "tab": "b" - } - ], - "focusedGroup": "g1" - } - }, - { - "name": "drop into an unknown group is rejected", - "state": { - "layout": { - "split": { - "id": "s1", - "orientation": "horizontal", - "first": { - "group": "g1" - }, - "second": { - "group": "g2" - } - } - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g2" - }, - { - "id": "c", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - }, - { - "group": "g2", - "tab": "b" - } - ], - "focusedGroup": "g1" - }, - "action": { - "type": "drop", - "tab": "a", - "group": "missing", - "side": "center" - }, - "applied": false, - "expected": { - "layout": { - "split": { - "id": "s1", - "orientation": "horizontal", - "first": { - "group": "g1" - }, - "second": { - "group": "g2" - } - } - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g2" - }, - { - "id": "c", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - }, - { - "group": "g2", - "tab": "b" - } - ], - "focusedGroup": "g1" - } - }, - { - "name": "canDrop reports split eligibility without changing state", - "state": { - "layout": { - "split": { - "id": "s1", - "orientation": "horizontal", - "first": { - "group": "g1" - }, - "second": { - "group": "g2" - } - } - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g2" - }, - { - "id": "c", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - }, - { - "group": "g2", - "tab": "b" - } - ], - "focusedGroup": "g1" - }, - "action": { - "type": "canDrop", - "tab": "a", - "group": "g1", - "side": "right" - }, - "applied": true, - "expected": { - "layout": { - "split": { - "id": "s1", - "orientation": "horizontal", - "first": { - "group": "g1" - }, - "second": { - "group": "g2" - } - } - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g2" - }, - { - "id": "c", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - }, - { - "group": "g2", - "tab": "b" - } - ], - "focusedGroup": "g1" - } - }, - { - "name": "canDrop rejects a self split of a single-tab group", - "state": { - "layout": { - "split": { - "id": "s1", - "orientation": "horizontal", - "first": { - "group": "g1" - }, - "second": { - "group": "g2" - } - } - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g2" - }, - { - "id": "c", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - }, - { - "group": "g2", - "tab": "b" - } - ], - "focusedGroup": "g1" - }, - "action": { - "type": "canDrop", - "tab": "b", - "group": "g2", - "side": "top" - }, - "applied": false, - "expected": { - "layout": { - "split": { - "id": "s1", - "orientation": "horizontal", - "first": { - "group": "g1" - }, - "second": { - "group": "g2" - } - } - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g2" - }, - { - "id": "c", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - }, - { - "group": "g2", - "tab": "b" - } - ], - "focusedGroup": "g1" - } - }, - { - "name": "insert without a group joins the focused group at the end", - "state": { - "layout": { - "split": { - "id": "s1", - "orientation": "horizontal", - "first": { - "group": "g1" - }, - "second": { - "group": "g2" - } - } - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g2" - }, - { - "id": "c", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - }, - { - "group": "g2", - "tab": "b" - } - ], - "focusedGroup": "g1" - }, - "action": { - "type": "insert", - "tab": "n", - "group": null, - "side": "right" - }, - "applied": true, - "expected": { - "layout": { - "split": { - "id": "s1", - "orientation": "horizontal", - "first": { - "group": "g1" - }, - "second": { - "group": "g2" - } - } - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g2" - }, - { - "id": "c", - "group": "g1" - }, - { - "id": "n", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "n" - }, - { - "group": "g2", - "tab": "b" - } - ], - "focusedGroup": "g1" - } - }, - { - "name": "center insert follows the target group's last tab", - "state": { - "layout": { - "split": { - "id": "s1", - "orientation": "horizontal", - "first": { - "group": "g1" - }, - "second": { - "group": "g2" - } - } - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g2" - }, - { - "id": "c", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - }, - { - "group": "g2", - "tab": "b" - } - ], - "focusedGroup": "g1" - }, - "action": { - "type": "insert", - "tab": "n", - "group": "g2", - "side": "center" - }, - "applied": true, - "expected": { - "layout": { - "split": { - "id": "s1", - "orientation": "horizontal", - "first": { - "group": "g1" - }, - "second": { - "group": "g2" - } - } - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g2" - }, - { - "id": "n", - "group": "g2" - }, - { - "id": "c", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - }, - { - "group": "g2", - "tab": "n" - } - ], - "focusedGroup": "g2" - } - }, - { - "name": "edge insert splits even a single-tab group", - "state": { - "layout": { - "split": { - "id": "s1", - "orientation": "horizontal", - "first": { - "group": "g1" - }, - "second": { - "group": "g2" - } - } - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g2" - }, - { - "id": "c", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - }, - { - "group": "g2", - "tab": "b" - } - ], - "focusedGroup": "g1" - }, - "action": { - "type": "insert", - "tab": "n", - "group": "g2", - "side": "top", - "newGroup": "g3", - "newSplit": "s2" - }, - "applied": true, - "expected": { - "layout": { - "split": { - "id": "s1", - "orientation": "horizontal", - "first": { - "group": "g1" - }, - "second": { - "split": { - "id": "s2", - "orientation": "vertical", - "first": { - "group": "g3" - }, - "second": { - "group": "g2" - } - } - } - } - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g2" - }, - { - "id": "c", - "group": "g1" - }, - { - "id": "n", - "group": "g3" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - }, - { - "group": "g3", - "tab": "n" - }, - { - "group": "g2", - "tab": "b" - } - ], - "focusedGroup": "g3" - } - }, - { - "name": "inserting an open tab is rejected", - "state": { - "layout": { - "split": { - "id": "s1", - "orientation": "horizontal", - "first": { - "group": "g1" - }, - "second": { - "group": "g2" - } - } - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g2" - }, - { - "id": "c", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - }, - { - "group": "g2", - "tab": "b" - } - ], - "focusedGroup": "g1" - }, - "action": { - "type": "insert", - "tab": "a", - "group": "g2", - "side": "center" - }, - "applied": false, - "expected": { - "layout": { - "split": { - "id": "s1", - "orientation": "horizontal", - "first": { - "group": "g1" - }, - "second": { - "group": "g2" - } - } - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g2" - }, - { - "id": "c", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - }, - { - "group": "g2", - "tab": "b" - } - ], - "focusedGroup": "g1" - } - }, - { - "name": "move before a tab in another group adopts that group", - "state": { - "layout": { - "split": { - "id": "s1", - "orientation": "horizontal", - "first": { - "group": "g1" - }, - "second": { - "group": "g2" - } - } - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g2" - }, - { - "id": "c", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - }, - { - "group": "g2", - "tab": "b" - } - ], - "focusedGroup": "g1" - }, - "action": { - "type": "move", - "tab": "c", - "target": "b", - "position": "before" - }, - "applied": true, - "expected": { - "layout": { - "split": { - "id": "s1", - "orientation": "horizontal", - "first": { - "group": "g1" - }, - "second": { - "group": "g2" - } - } - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "c", - "group": "g2" - }, - { - "id": "b", - "group": "g2" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - }, - { - "group": "g2", - "tab": "b" - } - ], - "focusedGroup": "g1" - } - }, - { - "name": "move of the only tab collapses its former group", - "state": { - "layout": { - "split": { - "id": "s1", - "orientation": "horizontal", - "first": { - "group": "g1" - }, - "second": { - "group": "g2" - } - } - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g2" - }, - { - "id": "c", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - }, - { - "group": "g2", - "tab": "b" - } - ], - "focusedGroup": "g1" - }, - "action": { - "type": "move", - "tab": "b", - "target": "a", - "position": "after" - }, - "applied": true, - "expected": { - "layout": { - "group": "g1" - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g1" - }, - { - "id": "c", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - } - ], - "focusedGroup": "g1" - } - }, - { - "name": "move into an adjacent slot of another group still adopts that group", - "state": { - "layout": { - "split": { - "id": "s1", - "orientation": "horizontal", - "first": { - "group": "g1" - }, - "second": { - "group": "g2" - } - } - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g2" - }, - { - "id": "c", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - }, - { - "group": "g2", - "tab": "b" - } - ], - "focusedGroup": "g1" - }, - "action": { - "type": "move", - "tab": "a", - "target": "b", - "position": "before" - }, - "applied": true, - "expected": { - "layout": { - "split": { - "id": "s1", - "orientation": "horizontal", - "first": { - "group": "g1" - }, - "second": { - "group": "g2" - } - } - }, - "tabs": [ - { - "id": "a", - "group": "g2" - }, - { - "id": "b", - "group": "g2" - }, - { - "id": "c", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "c" - }, - { - "group": "g2", - "tab": "b" - } - ], - "focusedGroup": "g1" - } - }, - { - "name": "move that changes neither order nor group is rejected", - "state": { - "layout": { - "group": "g1" - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - } - ], - "focusedGroup": "g1" - }, - "action": { - "type": "move", - "tab": "a", - "target": "b", - "position": "before" - }, - "applied": false, - "expected": { - "layout": { - "group": "g1" - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - } - ], - "focusedGroup": "g1" - } - }, - { - "name": "move relative to itself is rejected", - "state": { - "layout": { - "split": { - "id": "s1", - "orientation": "horizontal", - "first": { - "group": "g1" - }, - "second": { - "group": "g2" - } - } - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g2" - }, - { - "id": "c", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - }, - { - "group": "g2", - "tab": "b" - } - ], - "focusedGroup": "g1" - }, - "action": { - "type": "move", - "tab": "a", - "target": "a", - "position": "after" - }, - "applied": false, - "expected": { - "layout": { - "split": { - "id": "s1", - "orientation": "horizontal", - "first": { - "group": "g1" - }, - "second": { - "group": "g2" - } - } - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g2" - }, - { - "id": "c", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - }, - { - "group": "g2", - "tab": "b" - } - ], - "focusedGroup": "g1" - } - }, - { - "name": "focus selects the tab and its group", - "state": { - "layout": { - "split": { - "id": "s1", - "orientation": "horizontal", - "first": { - "group": "g1" - }, - "second": { - "group": "g2" - } - } - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g2" - }, - { - "id": "c", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - }, - { - "group": "g2", - "tab": "b" - } - ], - "focusedGroup": "g1" - }, - "action": { - "type": "focus", - "tab": "c" - }, - "applied": true, - "expected": { - "layout": { - "split": { - "id": "s1", - "orientation": "horizontal", - "first": { - "group": "g1" - }, - "second": { - "group": "g2" - } - } - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g2" - }, - { - "id": "c", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "c" - }, - { - "group": "g2", - "tab": "b" - } - ], - "focusedGroup": "g1" - } - }, - { - "name": "focus of a missing tab is rejected", - "state": { - "layout": { - "split": { - "id": "s1", - "orientation": "horizontal", - "first": { - "group": "g1" - }, - "second": { - "group": "g2" - } - } - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g2" - }, - { - "id": "c", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - }, - { - "group": "g2", - "tab": "b" - } - ], - "focusedGroup": "g1" - }, - "action": { - "type": "focus", - "tab": "missing" - }, - "applied": false, - "expected": { - "layout": { - "split": { - "id": "s1", - "orientation": "horizontal", - "first": { - "group": "g1" - }, - "second": { - "group": "g2" - } - } - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g2" - }, - { - "id": "c", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - }, - { - "group": "g2", - "tab": "b" - } - ], - "focusedGroup": "g1" - } - } - ], - "invalid": [ - { - "name": "edge drop without new identities", - "state": { - "layout": { - "split": { - "id": "s1", - "orientation": "horizontal", - "first": { - "group": "g1" - }, - "second": { - "group": "g2" - } - } - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g2" - }, - { - "id": "c", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - }, - { - "group": "g2", - "tab": "b" - } - ], - "focusedGroup": "g1" - }, - "action": { - "type": "drop", - "tab": "a", - "group": "g1", - "side": "left" - } - }, - { - "name": "new group identity already in the layout", - "state": { - "layout": { - "split": { - "id": "s1", - "orientation": "horizontal", - "first": { - "group": "g1" - }, - "second": { - "group": "g2" - } - } - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g2" - }, - { - "id": "c", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - }, - { - "group": "g2", - "tab": "b" - } - ], - "focusedGroup": "g1" - }, - "action": { - "type": "drop", - "tab": "a", - "group": "g1", - "side": "left", - "newGroup": "g2", - "newSplit": "s9" - } - }, - { - "name": "duplicate group in the layout", - "state": { - "layout": { - "split": { - "id": "s1", - "orientation": "horizontal", - "first": { - "group": "g1" - }, - "second": { - "group": "g1" - } - } - }, - "tabs": [], - "selections": [], - "focusedGroup": null - }, - "action": { - "type": "sync", - "tabs": [] - } - }, - { - "name": "unknown action", - "state": { - "layout": { - "split": { - "id": "s1", - "orientation": "horizontal", - "first": { - "group": "g1" - }, - "second": { - "group": "g2" - } - } - }, - "tabs": [ - { - "id": "a", - "group": "g1" - }, - { - "id": "b", - "group": "g2" - }, - { - "id": "c", - "group": "g1" - } - ], - "selections": [ - { - "group": "g1", - "tab": "a" - }, - { - "group": "g2", - "tab": "b" - } - ], - "focusedGroup": "g1" - }, - "action": { - "type": "explode" - } - } - ] -} diff --git a/shared/platform-feature-matrix/features/editor-text-tabs.json b/shared/platform-feature-matrix/features/editor-text-tabs.json index 9fde37a387..20805a185f 100644 --- a/shared/platform-feature-matrix/features/editor-text-tabs.json +++ b/shared/platform-feature-matrix/features/editor-text-tabs.json @@ -16,13 +16,10 @@ "macos/Tests/LitheTests/EditorTabLayoutTests.swift", ".agents/notes/implemented/architecture/2026-10-12-editor-tab-split-docking.md", "macos/Sources/LitheGitModule/Models/GitRepositoryDiffSession.swift", - "macos/Sources/Lithe/Views/Diff/RepositoryDiffSessionView.swift", - "rust/lithe-core/src/editor/workbench_state.rs", - "shared/fixtures/editor/workbench-state-v1.json" + "macos/Sources/Lithe/Views/Diff/RepositoryDiffSessionView.swift" ], "implementationStatus": "implemented", - "verificationStatus": "pending", - "notes": " 编辑分组的标签归属、分屏与合并、组内选中和聚焦组改由 Rust Core editor.workbenchState.reduce 计算;关闭组内选中的标签后选中接替原位置的标签(越过末尾选新的末尾),不再回到第一个标签。" + "verificationStatus": "pending" }, "windows": { "evidence": [ @@ -38,10 +35,9 @@ "windows/tauri/src/features/editor/stores/buffer.store.ts" ], "implementationStatus": "implemented", - "verificationStatus": "pending", - "notes": "Windows 窗格树仍由 windows/tauri/src/features/panes/utils/pane-tree.ts 计算(同一缓冲区可同时位于多个窗格,含预览、固定、锁定和底部窗格),尚未接入 editor.workbenchState.reduce;其关闭选中规则(接替原位置)与 Core 一致,聚焦组回退仍按最近使用的窗格。" + "verificationStatus": "pending" }, "owner": "Editor", - "verification": "打开、编辑、保存、关闭和恢复多个文本文件,确认光标、脏状态和标签状态;在 macOS 打开无扩展名文本文件,确认标签与项目树图标一致;从外部改为二进制后折叠并展开项目树,确认两处图标更新一致。 macOS 验证所有 Repository Diff 与文件/媒体/终端标签共用排序,切换保留 Diff、关闭命令关闭选中内容,标题按当前文件变化;共享 Islands 圆角标签在编辑区/工具窗口鼠标、键盘和窗口失活间呈现蓝色活动或灰色失焦状态,卸载移除焦点观察器。完整工作区视觉验收 pending。 从 Diff 设置菜单关闭时也清理混合标签槽并恢复原文档;通过原生窗口鼠标拖动检查普通文件跨 Repository Diff 重排。 右键标签页确认关闭分组排在其他操作之前,其中“关闭已保存”关闭无修改且未固定的标签并保留有未保存修改的标签,全程不弹保存确认;在 macOS 打开无扩展名文本文件,确认标签与项目树图标一致;从外部改为二进制后折叠并展开项目树,确认两处图标更新一致。 在 Windows 确认编辑器标签栏左上角不再有前进/后退箭头,后退/前进改由 Navigate 菜单与 Ctrl+Alt+←/→ 提供;选中标签为 IntelliJ Islands 圆角块(浅蓝底加蓝边),焦点移到终端、项目树或另一分栏时变为灰底灰边;长文件名标签完整显示不截断。 macOS 工作区、linked worktree、目录、冲突、行标记、分支/引用、历史提交和 Stash/Shelf Diff 均显示普通共享编辑标签;切回文件/媒体/终端/Agent 不清空比较,重新进入不重复添加标签。重排、拖动分屏与关闭空组合并沿用普通分组逻辑;关闭当前项只关闭活动标签,后台 Diff 不截获文件关闭命令;从 Diff 按钮、设置菜单或 ⌘W 关闭时只清理目标比较状态并拒绝迟到结果,关闭活动 Diff 优先恢复本组标签。 Windows 用同一鼠标滚轮事件对照普通编辑器、笔记本代码区和 Diff,确认共用两倍灵敏度,平滑滚动开关仍生效,滚动边界及标签位置恢复正确;真实整机 IDEA 滚动帧性能对照仍待验收。 macOS 标签拖到编辑区右侧或底部显示对应半区预览,释放后移动到独立标签组;继续拆分、组间移动、关闭空组合并、各组选择和聚焦命令正确,原文档和终端会话不复制,Esc 与无效释放保持原布局。浮动标签使用灰色卡片,无激活蓝底和关闭按钮。空编辑区仅显示 Search Everywhere 与 Find in Files,按当前 Keymap 和自定义配置生成快捷键,16pt 普通字重及 IDEA 明暗提示色;窄窗口裁剪文字而不缩字。 macOS 点击编辑器正文下方空白后,仅移动鼠标到文件、Diff、终端标签及标签栏空白处即恢复箭头,不要求再次点击;返回正文仍使用文本光标,单行、多行及分组标签栏均检查。 与数据库标签集成:文件切换和分组保留数据库概览/查询标签;数据库标签组内重排有效,跨组移动不改变布局,新增数据库标签进入既有数据库分组,避免共享上下文同时渲染为两个不同查询。 普通文本标签连续移到右侧、底部及原组后,撤销/重做移动前后输入;保持同一 WebView 和 Monaco 模型,不重建历史。松开拖动后只有目标文档取得原生焦点,后台正文没有闪烁光标;输入、撤销、查找和保存均作用于目标文件。视口尺寸变化允许滚动范围正常限制。 macOS 横向溢出的隐藏标签不截获相邻分组按下/拖动,部分可见标签仅在视口内可点击;第二、第三行释放执行重排而非分屏,正文边缘仍可分屏。文档→另一组终端或数据库→点击文档正文后,查找及关闭作用于原文档,其他标签保留。 A 与终端 T 分组,分别在挂载前及挂载排队后选 T,验证 A 异步 attach 不夺取 T 的 first responder;切回 A 后挂载可正常聚焦。单标签组右键向右分屏禁用,多文档或文档加终端组启用并实际分屏。 Windows 点击标签栏“新建标签页”按钮或按 Ctrl+N/Ctrl+T,确认打开一个可编辑、可保存的空白未命名编辑器(Untitled/未命名),并出现在标签栏;连续新建依次得到 Untitled-2、Untitled-3;关闭其中编号较小的未命名标签后再次新建,确认仍然打开新的空白页而不是只聚焦遗留标签;在未命名页输入文字后标签标记为未保存,关闭时先弹出保存确认;标签数达到上限时再新建,确认有未保存修改的标签不会被自动驱逐。 macOS测试源码文件标签使用IDEA FileColor.Green(深#1C261C/浅#EDFAEF),选中内缩3pt,活动100%/非活动40%,保留原边框与其他标签行为。优先按Maven导入的项目及子模块源码根角色判定,覆盖自定义测试目录、辅助类和主源码反例,无根信息时使用已有JDT测试元数据,不按文件名推测。EditorTabColorTests验证实际明暗及活动/非活动渲染,完整应用视觉验收仍pending。 多 Diff 回归:同时打开两个文件和同一文件的不同提交,检查独立标签、同一比较去重、切换保留内容/滚动、分屏各自显示、关闭一个不影响另一加载、Git Log 换提交不改变旧比较及工作区退出释放全部会话。 macOS 编辑分组:在一个分组打开 A、B、C,选中 B 后关闭 B,确认选中 C;再关闭 C 选中 A;拖动标签分屏、移回并合并空组,确认右键“Open in Right Split”在单标签组禁用。", + "verification": "打开、编辑、保存、关闭和恢复多个文本文件,确认光标、脏状态和标签状态;在 macOS 打开无扩展名文本文件,确认标签与项目树图标一致;从外部改为二进制后折叠并展开项目树,确认两处图标更新一致。 macOS 验证所有 Repository Diff 与文件/媒体/终端标签共用排序,切换保留 Diff、关闭命令关闭选中内容,标题按当前文件变化;共享 Islands 圆角标签在编辑区/工具窗口鼠标、键盘和窗口失活间呈现蓝色活动或灰色失焦状态,卸载移除焦点观察器。完整工作区视觉验收 pending。 从 Diff 设置菜单关闭时也清理混合标签槽并恢复原文档;通过原生窗口鼠标拖动检查普通文件跨 Repository Diff 重排。 右键标签页确认关闭分组排在其他操作之前,其中“关闭已保存”关闭无修改且未固定的标签并保留有未保存修改的标签,全程不弹保存确认;在 macOS 打开无扩展名文本文件,确认标签与项目树图标一致;从外部改为二进制后折叠并展开项目树,确认两处图标更新一致。 在 Windows 确认编辑器标签栏左上角不再有前进/后退箭头,后退/前进改由 Navigate 菜单与 Ctrl+Alt+←/→ 提供;选中标签为 IntelliJ Islands 圆角块(浅蓝底加蓝边),焦点移到终端、项目树或另一分栏时变为灰底灰边;长文件名标签完整显示不截断。 macOS 工作区、linked worktree、目录、冲突、行标记、分支/引用、历史提交和 Stash/Shelf Diff 均显示普通共享编辑标签;切回文件/媒体/终端/Agent 不清空比较,重新进入不重复添加标签。重排、拖动分屏与关闭空组合并沿用普通分组逻辑;关闭当前项只关闭活动标签,后台 Diff 不截获文件关闭命令;从 Diff 按钮、设置菜单或 ⌘W 关闭时只清理目标比较状态并拒绝迟到结果,关闭活动 Diff 优先恢复本组标签。 Windows 用同一鼠标滚轮事件对照普通编辑器、笔记本代码区和 Diff,确认共用两倍灵敏度,平滑滚动开关仍生效,滚动边界及标签位置恢复正确;真实整机 IDEA 滚动帧性能对照仍待验收。 macOS 标签拖到编辑区右侧或底部显示对应半区预览,释放后移动到独立标签组;继续拆分、组间移动、关闭空组合并、各组选择和聚焦命令正确,原文档和终端会话不复制,Esc 与无效释放保持原布局。浮动标签使用灰色卡片,无激活蓝底和关闭按钮。空编辑区仅显示 Search Everywhere 与 Find in Files,按当前 Keymap 和自定义配置生成快捷键,16pt 普通字重及 IDEA 明暗提示色;窄窗口裁剪文字而不缩字。 macOS 点击编辑器正文下方空白后,仅移动鼠标到文件、Diff、终端标签及标签栏空白处即恢复箭头,不要求再次点击;返回正文仍使用文本光标,单行、多行及分组标签栏均检查。 与数据库标签集成:文件切换和分组保留数据库概览/查询标签;数据库标签组内重排有效,跨组移动不改变布局,新增数据库标签进入既有数据库分组,避免共享上下文同时渲染为两个不同查询。 普通文本标签连续移到右侧、底部及原组后,撤销/重做移动前后输入;保持同一 WebView 和 Monaco 模型,不重建历史。松开拖动后只有目标文档取得原生焦点,后台正文没有闪烁光标;输入、撤销、查找和保存均作用于目标文件。视口尺寸变化允许滚动范围正常限制。 macOS 横向溢出的隐藏标签不截获相邻分组按下/拖动,部分可见标签仅在视口内可点击;第二、第三行释放执行重排而非分屏,正文边缘仍可分屏。文档→另一组终端或数据库→点击文档正文后,查找及关闭作用于原文档,其他标签保留。 A 与终端 T 分组,分别在挂载前及挂载排队后选 T,验证 A 异步 attach 不夺取 T 的 first responder;切回 A 后挂载可正常聚焦。单标签组右键向右分屏禁用,多文档或文档加终端组启用并实际分屏。 Windows 点击标签栏“新建标签页”按钮或按 Ctrl+N/Ctrl+T,确认打开一个可编辑、可保存的空白未命名编辑器(Untitled/未命名),并出现在标签栏;连续新建依次得到 Untitled-2、Untitled-3;关闭其中编号较小的未命名标签后再次新建,确认仍然打开新的空白页而不是只聚焦遗留标签;在未命名页输入文字后标签标记为未保存,关闭时先弹出保存确认;标签数达到上限时再新建,确认有未保存修改的标签不会被自动驱逐。 macOS测试源码文件标签使用IDEA FileColor.Green(深#1C261C/浅#EDFAEF),选中内缩3pt,活动100%/非活动40%,保留原边框与其他标签行为。优先按Maven导入的项目及子模块源码根角色判定,覆盖自定义测试目录、辅助类和主源码反例,无根信息时使用已有JDT测试元数据,不按文件名推测。EditorTabColorTests验证实际明暗及活动/非活动渲染,完整应用视觉验收仍pending。 多 Diff 回归:同时打开两个文件和同一文件的不同提交,检查独立标签、同一比较去重、切换保留内容/滚动、分屏各自显示、关闭一个不影响另一加载、Git Log 换提交不改变旧比较及工作区退出释放全部会话。", "notes": "Windows 编辑器标签按 IntelliJ Islands IslandsTabPainter 绘制:28px 圆角块、6px 圆角、选中填充加 1px 边框、标签间距 8px、无宽度上限;是否置灰按焦点是否位于该标签组判断(对应 EditorTabbedContainer 的 isFocusAncestor)。标签栏不提供前进/后退按钮(IntelliJ 新 UI 同样没有)。Islands 精确色值只写在 Lithe 主题中,其他主题由自身主色、前景和边框色推导。 macOS 仓库 Diff 不再在根编辑区覆盖标签栏;采用带 UUID 的独立 repositoryDiff 标签及 GitRepositoryDiffSession:不同文件、提交、目录和引用比较可以并存,同一比较复用已有标签,服务与写入工作流仍由 GitFeatureModel 共享。EditorTabOrderFeatureModelTests 在深浅主题覆盖八种入口的实际原生分组、切换、分屏与关闭,GitModuleTests 覆盖异步结果隔离;Windows 现有 working-tree/commit-file-preview 已走普通标签,仅核对源码,完整两端工作区验收仍 pending。" } From b84a8f5896b2d932f397c967ebe8aee70b5fad11 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Wed, 4 Nov 2026 22:36:57 -0800 Subject: [PATCH 3/7] =?UTF-8?q?feat(lsp):=20#1662=20LSP-04=20=E4=B8=A4?= =?UTF-8?q?=E7=AB=AF=E6=8E=A5=E5=85=A5=20lsp.classifyNavigationTarget?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS 删除本地路径前缀和语言判断;Windows 外部同语言源码只读打开,被拒绝的目标不读取。 Co-Authored-By: Claude Opus 5.5 --- .../LanguageNavigationCoordinator.swift | 63 ++++++++++--------- .../LanguageNavigationCoordinatorTests.swift | 33 ---------- shared/contracts/rust-core-api.md | 33 ---------- .../editor/engines/monaco/lsp-providers.ts | 4 +- .../editor/lsp/navigation-classification.ts | 39 ++++++++++++ .../editor/lsp/navigation-target.test.ts | 24 +++++++ .../features/editor/lsp/navigation-target.ts | 9 +++ .../commands/navigation-command-actions.ts | 2 + 8 files changed, 109 insertions(+), 98 deletions(-) create mode 100644 windows/tauri/src/features/editor/lsp/navigation-classification.ts diff --git a/macos/Sources/Lithe/Application/Features/LanguageNavigationCoordinator.swift b/macos/Sources/Lithe/Application/Features/LanguageNavigationCoordinator.swift index 3951eab5f2..e0274d56a6 100644 --- a/macos/Sources/Lithe/Application/Features/LanguageNavigationCoordinator.swift +++ b/macos/Sources/Lithe/Application/Features/LanguageNavigationCoordinator.swift @@ -212,13 +212,10 @@ final class LanguageNavigationCoordinator: ObservableObject { } } - /// Classifies a jump target by both the path it is opened under and its - /// real location. Only a target whose path and resolved path are both - /// inside the workspace is a workspace file: an in-workspace symlink to - /// the outside must not skip the source-file check or the read-only rule, - /// and an outside symlink to a workspace file is opened under its outside - /// path, which the document layer only accepts as an external read-only - /// document. + /// Classifies a jump target through Core (`lsp.classifyNavigationTarget`). + /// This function only gathers the facts Core cannot know: the real paths + /// after symlink resolution and the language the catalog assigns to each + /// path. The decision is fail-closed: a Core failure rejects the jump. nonisolated static func externalNavigationDecision( for url: URL, workspaceURL: URL?, @@ -226,31 +223,35 @@ final class LanguageNavigationCoordinator: ObservableObject { languageOf: (URL) -> String? ) -> ExternalNavigationDecision { guard url.isFileURL, let workspaceURL else { return .workspace } - let roots = [workspaceURL.standardizedFileURL, workspaceURL.resolvingSymlinksInPath()] - .map(\.path) - let opensInside = roots.contains { url.standardizedFileURL.path.hasPrefix($0 + "/") } - let resolvesInside = url.resolvingSymlinksInPath().path - .hasPrefix(workspaceURL.resolvingSymlinksInPath().path + "/") - if opensInside && resolvesInside { - return .workspace + struct Request: Encodable { + let platform = "posix" + let workspaceRoot: String + let resolvedWorkspaceRoot: String + let path: String + let resolvedPath: String + let sourceLanguageID: String? + let languageID: String? + let resolvedLanguageID: String? + } + struct Response: Decodable { let classification: String } + let resolved = url.resolvingSymlinksInPath() + let request = Request( + workspaceRoot: workspaceURL.standardizedFileURL.path, + resolvedWorkspaceRoot: workspaceURL.resolvingSymlinksInPath().path, + path: url.standardizedFileURL.path, + resolvedPath: resolved.path, + sourceLanguageID: languageID, + languageID: languageOf(url), + resolvedLanguageID: languageOf(resolved) + ) + let result: Result = RustCoreBridge().executeResult( + command: "lsp.classifyNavigationTarget", payload: request) + guard case .success(let response) = result else { return .rejected } + switch response.classification { + case "workspace": return .workspace + case "externalReadOnly": return .externalReadOnly + default: return .rejected } - return allowsExternalSource(url, languageID: languageID, languageOf: languageOf) - ? .externalReadOnly - : .rejected - } - - /// A jump may leave the workspace only into a source file of the language - /// that produced it: a Go definition opens `.go` files, a PHP definition - /// `.php` files. Configuration, credentials, and other files a server - /// might point to stay closed, and a symlink must resolve to a file that - /// passes the same check. Such files are always opened read-only. - nonisolated static func allowsExternalSource( - _ url: URL, - languageID: String?, - languageOf: (URL) -> String? - ) -> Bool { - guard let languageID, url.isFileURL else { return false } - return [url, url.resolvingSymlinksInPath()].allSatisfy { languageOf($0) == languageID } } func editorLocation( diff --git a/macos/Tests/LitheTests/LanguageNavigationCoordinatorTests.swift b/macos/Tests/LitheTests/LanguageNavigationCoordinatorTests.swift index 0ba3862f80..73f83f5f55 100644 --- a/macos/Tests/LitheTests/LanguageNavigationCoordinatorTests.swift +++ b/macos/Tests/LitheTests/LanguageNavigationCoordinatorTests.swift @@ -82,39 +82,6 @@ struct LanguageNavigationCoordinatorTests { #expect(decision(main, language: "go") == .workspace) } - @Test("external jumps open only source files of the originating language") - func externalJumpsOnlyOpenSameLanguageSources() throws { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("lithe-external-source-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } - let source = root.appendingPathComponent("print.go") - let secret = root.appendingPathComponent("id_rsa") - let disguised = root.appendingPathComponent("secret.go") - try Data("package fmt\n".utf8).write(to: source) - try Data("key\n".utf8).write(to: secret) - try FileManager.default.createSymbolicLink(at: disguised, withDestinationURL: secret) - let languageOf: (URL) -> String? = { url in - switch url.pathExtension { - case "go": "go" - case "json": "json" - default: nil - } - } - - #expect(LanguageNavigationCoordinator.allowsExternalSource(source, languageID: "go", languageOf: languageOf)) - #expect(!LanguageNavigationCoordinator.allowsExternalSource(source, languageID: "php", languageOf: languageOf)) - #expect(!LanguageNavigationCoordinator.allowsExternalSource(source, languageID: nil, languageOf: languageOf)) - #expect(!LanguageNavigationCoordinator.allowsExternalSource(secret, languageID: "go", languageOf: languageOf)) - #expect(!LanguageNavigationCoordinator.allowsExternalSource(disguised, languageID: "go", languageOf: languageOf)) - #expect(!LanguageNavigationCoordinator.allowsExternalSource( - root.appendingPathComponent("credentials.json"), languageID: "go", languageOf: languageOf - )) - #expect(!LanguageNavigationCoordinator.allowsExternalSource( - URL(string: "jdt://contents/rt.jar/java.lang/String.class")!, languageID: "go", languageOf: languageOf - )) - } - @Test("projects language-server locations") @MainActor func projectsLocations() { diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index d78e6c7856..7df6ac6e88 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -1156,7 +1156,6 @@ package manager owns the download and Lithe does not infer bytes from logs. | `editor.parseNavigationInput` | Parse a bounded line or line:column input into zero-based editor coordinates | | `editor.navigationHistory.reduce` | Reduce one bounded back/forward navigation history event and return the next state plus the location to open | | `editor.lineCommentToken` | Resolve the line comment token for a file extension or language id | -| `editor.workbenchState.reduce` | Reduce editor tab group membership, split/collapse, selection and focus for one tab action | | `workspace.releaseIndexing` | Retire a policy and clear index/cache data at project close; accepts `root`, optional `ownerId`, and that owner's last observed `revision`, returns `released`; an old close must not retire a new generation | | `workspace.refreshIndexing` | Refresh external Git decisions for a live `root`, `ownerId`, `revision` lease without changing preferences or release tokens; returns lazy `IndexingStatus` | | `workspace.configureIndexing` | Install a process-local, canonical-root indexing policy before workspace consumers run; returns a monotonic revision, effective directory reasons and Git failure state; see `shared/fixtures/workspace/indexing-policy-v1.json` | @@ -2781,38 +2780,6 @@ to adopt the same contract in a follow-up change; the fixture pins the canonical behavior for that migration. Deterministic cases are pinned by `shared/fixtures/editor/line-edit-v1.json`. -`editor.workbenchState.reduce` accepts `{ "state": WorkbenchState, "action": -Action }` and returns `{ "applied": boolean, "state": WorkbenchState }`. -`WorkbenchState` is `{ "layout": Layout, "tabs": [{ "id", "group" }], -"selections": [{ "group", "tab" }], "focusedGroup": string | null }` where -`Layout` is `{ "group": id }` or `{ "split": { "id", "orientation": -"horizontal" | "vertical", "first": Layout, "second": Layout } }` -(`horizontal` places children side by side, `vertical` stacks them). `tabs` -is the host's presentation order; a group presents its tabs in that relative -order. All IDs are opaque host strings. Actions are `sync { tabs }` (the new -set and order of open tabs), `drop { tab, group, side, newGroup?, newSplit? }`, -`canDrop { tab, group, side }`, `insert { tab, group | null, side, newGroup?, -newSplit? }`, `move { tab, target, position: "before" | "after" }` and -`focus { tab }`, with `side` one of `center`, `left`, `right`, `top` and -`bottom`. Edge sides require host-generated `newGroup` and `newSplit` IDs that -are not in the layout. After every action Core removes duplicate tabs (first -wins), places tabs without a valid group in the focused group (else the first -group), removes empty groups and collapses their splits (keeping one empty -group when nothing is open), moves a vanished focus to the first remaining -group, and keeps each group's selected tab while it stays in the group, -otherwise selecting the tab that now occupies the former selection's index -(or the new last tab). `drop` and `insert` then focus the tab. Rejected or -no-op actions, and `canDrop`, return `applied: false` (`canDrop` returns -`true` when the matching drop would apply) with the normalized input state. -Malformed payloads, layouts that repeat a group or split ID, more than 10,000 -tabs, and edge sides without fresh identities return `invalid_request`. Split -ratios, pixel geometry and drag hit testing stay in the host. macOS consumes -the command; Windows still keeps its pane tree in TypeScript -(`windows/tauri/src/features/panes/utils/pane-tree.ts`), whose synchronous -store, buffer sharing across panes, preview, pinned and locked panes and the -bottom pane tree must move together in a follow-up. Cases are pinned by -`shared/fixtures/editor/workbench-state-v1.json`. - `network.proxyPolicy` normalizes the stored proxy settings into the policy a network stack must apply. It accepts `{ "mode": "system" | "direct" | "custom", "proxyType"?: "http" | "socks5", "host"?: string, "port"?: number, diff --git a/windows/tauri/src/features/editor/engines/monaco/lsp-providers.ts b/windows/tauri/src/features/editor/engines/monaco/lsp-providers.ts index c3bbbc3e08..ec1a071c29 100644 --- a/windows/tauri/src/features/editor/engines/monaco/lsp-providers.ts +++ b/windows/tauri/src/features/editor/engines/monaco/lsp-providers.ts @@ -338,8 +338,9 @@ export function registerMonacoLspProviders() { : { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 }; void (async () => { - const [{ openLspNavigationLocation }, { readFileContent }] = await Promise.all([ + const [{ openLspNavigationLocation }, { classifyLspNavigationTarget }, { readFileContent }] = await Promise.all([ import("@/features/editor/lsp/navigation-target"), + import("@/features/editor/lsp/navigation-classification"), import("@/features/file-system/controllers/file-operations"), ]); const bufferStore = useBufferStore.getState(); @@ -357,6 +358,7 @@ export function registerMonacoLspProviders() { getVirtualDocument: (filePath, virtualUri) => lspClient.getVirtualDocument(filePath, virtualUri), readFileContent, + classifyTarget: classifyLspNavigationTarget, }); window.dispatchEvent( new CustomEvent("menu-go-to-line", { diff --git a/windows/tauri/src/features/editor/lsp/navigation-classification.ts b/windows/tauri/src/features/editor/lsp/navigation-classification.ts new file mode 100644 index 0000000000..d235097500 --- /dev/null +++ b/windows/tauri/src/features/editor/lsp/navigation-classification.ts @@ -0,0 +1,39 @@ +import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; +import { getNativeWorkspaceRootPaths } from "@/features/file-search/utils/file-search-paths"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { closestWorkspaceRoot } from "@/features/workspace/types/workspace-root"; +import { invoke } from "@/platform/tauri-core"; +import { detectLanguageFromPath } from "../utils/language-detection"; + +export type NavigationTargetClassification = "workspace" | "externalReadOnly" | "rejected"; + +export interface NavigationTargetDecision { + classification: NavigationTargetClassification; + isReadOnly: boolean; +} + +/** + * Asks Core (`lsp.classifyNavigationTarget`) whether a language-server jump may + * open `path`. The host only supplies facts: the workspace root that contains + * the file (or the primary root), and the language of the file and of the + * document that started the jump. A Core failure rejects the jump. + */ +export async function classifyLspNavigationTarget( + path: string, + sourceFilePath: string, +): Promise { + const state = useFileSystemStore.getStore(workspaceRuntimeRegistry.getActiveWorkspaceId()).getState(); + const roots = getNativeWorkspaceRootPaths(state.rootFolderPath, state.workspaceFolders); + const workspaceRoot = closestWorkspaceRoot(path, roots) ?? state.rootFolderPath ?? undefined; + try { + return await invoke("lsp.classifyNavigationTarget", { + platform: "windows", + workspaceRoot, + path, + sourceLanguageID: detectLanguageFromPath(sourceFilePath), + languageID: detectLanguageFromPath(path), + }); + } catch { + return { classification: "rejected", isReadOnly: true }; + } +} diff --git a/windows/tauri/src/features/editor/lsp/navigation-target.test.ts b/windows/tauri/src/features/editor/lsp/navigation-target.test.ts index b865c5fabb..764ec28beb 100644 --- a/windows/tauri/src/features/editor/lsp/navigation-target.test.ts +++ b/windows/tauri/src/features/editor/lsp/navigation-target.test.ts @@ -1,5 +1,6 @@ import { describe, expect, mock, test } from "bun:test"; import type { LspLocation } from "./lsp-client"; +import type { NavigationTargetDecision } from "./navigation-classification"; import { openLspNavigationLocation } from "./navigation-target"; import type { OpenContentSpec, PaneContent } from "@/features/panes/types/pane-content.types"; @@ -16,6 +17,9 @@ function createOptions(location: LspLocation, buffers: PaneContent[] = []) { async (): Promise => "public final class String {}", ); const readFileContent = mock(async () => "class Main {}"); + const classifyTarget = mock( + async (): Promise => ({ classification: "workspace", isReadOnly: false }), + ); return { options: { @@ -25,16 +29,36 @@ function createOptions(location: LspLocation, buffers: PaneContent[] = []) { actions: { openContent, setActiveBuffer, updateBuffer }, getVirtualDocument, readFileContent, + classifyTarget, }, openContent, setActiveBuffer, updateBuffer, getVirtualDocument, readFileContent, + classifyTarget, }; } describe("LSP navigation targets", () => { + test("a target Core rejects is never read or opened", async () => { + const context = createOptions({ uri: "file:///D:/secret/id.txt", filePath: "D:/secret/id.txt", range }); + context.classifyTarget.mockResolvedValueOnce({ classification: "rejected", isReadOnly: true }); + + expect(await openLspNavigationLocation(context.options)).toBeNull(); + expect(context.classifyTarget).toHaveBeenCalledWith("D:/secret/id.txt", context.options.sourceFilePath); + expect(context.readFileContent).not.toHaveBeenCalled(); + expect(context.openContent).not.toHaveBeenCalled(); + }); + + test("an external source file opens read-only", async () => { + const context = createOptions({ uri: "file:///D:/lib/Util.go", filePath: "D:/lib/Util.go", range }); + context.classifyTarget.mockResolvedValueOnce({ classification: "externalReadOnly", isReadOnly: true }); + + expect(await openLspNavigationLocation(context.options)).toBe("opened-buffer"); + expect(context.openContent).toHaveBeenCalledWith(expect.objectContaining({ path: "D:/lib/Util.go", readOnly: true })); + }); + test("opens physical file locations with their Core-projected path", async () => { const context = createOptions({ uri: "file:///C:/work/src/Service.java", diff --git a/windows/tauri/src/features/editor/lsp/navigation-target.ts b/windows/tauri/src/features/editor/lsp/navigation-target.ts index fd30a21c61..e8b1d37f81 100644 --- a/windows/tauri/src/features/editor/lsp/navigation-target.ts +++ b/windows/tauri/src/features/editor/lsp/navigation-target.ts @@ -1,4 +1,5 @@ import type { LspLocation } from "./lsp-client"; +import type { NavigationTargetDecision } from "./navigation-classification"; import { filePathFromUri } from "./workspace-edit"; import { getBufferByPath } from "@/features/editor/utils/buffer-index"; import type { OpenContentSpec, PaneContent } from "@/features/panes/types/pane-content.types"; @@ -17,6 +18,8 @@ interface OpenLspNavigationLocationOptions { actions: NavigationBufferActions; getVirtualDocument: (sourceFilePath: string, virtualUri: string) => Promise; readFileContent: (filePath: string) => Promise; + /** Core decision for a file outside the buffers already open. */ + classifyTarget: (filePath: string, sourceFilePath: string) => Promise; } function physicalPath(location: LspLocation): string | null { @@ -85,6 +88,7 @@ export async function openLspNavigationLocation({ actions, getVirtualDocument, readFileContent, + classifyTarget, }: OpenLspNavigationLocationOptions): Promise { const filePath = physicalPath(location); const targetPath = filePath ?? location.uri; @@ -119,12 +123,17 @@ export async function openLspNavigationLocation({ } if (filePath) { + // Core refuses targets outside the workspace unless they are sources of + // the originating language, and those open read-only. + const decision = await classifyTarget(filePath, sourceFilePath); + if (decision.classification === "rejected") return null; const content = await readFileContent(filePath); const bufferId = actions.openContent({ type: "editor", path: filePath, name: getBaseName(filePath), content, + ...(decision.isReadOnly ? { readOnly: true } : {}), }); actions.setActiveBuffer(bufferId); return bufferId; diff --git a/windows/tauri/src/features/keymaps/commands/navigation-command-actions.ts b/windows/tauri/src/features/keymaps/commands/navigation-command-actions.ts index 8b4dab761e..406551c91b 100644 --- a/windows/tauri/src/features/keymaps/commands/navigation-command-actions.ts +++ b/windows/tauri/src/features/keymaps/commands/navigation-command-actions.ts @@ -18,6 +18,7 @@ import { } from "@/features/editor/lsp/language-server-navigation"; import { resolveJavaSyntaxDefinitions } from "@/features/editor/lsp/java-syntax-definition"; import { resolveLombokAccessorDefinition } from "@/features/editor/lsp/lombok-accessor-navigation"; +import { classifyLspNavigationTarget } from "@/features/editor/lsp/navigation-classification"; import { openLspNavigationLocation } from "@/features/editor/lsp/navigation-target"; import { lspDocumentTargetForEditor, @@ -565,6 +566,7 @@ async function goToActiveLspLocation( getVirtualDocument: (filePath, virtualUri) => lspClient.getVirtualDocument(filePath, virtualUri), readFileContent, + classifyTarget: classifyLspNavigationTarget, }); if (!openedBufferId) { toast.info( From 8837a2485f85c1a77a96424034cc771f38e7f932 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Wed, 4 Nov 2026 22:36:57 -0800 Subject: [PATCH 4/7] =?UTF-8?q?feat(lsp):=20#1662=20LSP-03=20=E4=B8=A4?= =?UTF-8?q?=E7=AB=AF=E6=8E=A5=E5=85=A5=20lsp.planWorkspaceEdit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 工作区边界、只读检查和提交顺序由 Core 决定;Windows 补上版本检查和失败回滚,带保护的写入仍在平台执行。 Co-Authored-By: Claude Opus 5.5 --- macos/Resources/en.lproj/Localizable.strings | 2 + .../zh-Hans.lproj/Localizable.strings | 2 + .../LanguageWorkspaceEditService.swift | 84 +++++++++--- shared/i18n/locales/en-US.json | 4 + shared/i18n/locales/zh-CN.json | 4 + shared/i18n/messages.json | 35 +++++ .../editor/lsp/workspace-edit-races.test.ts | 9 +- .../src/features/editor/lsp/workspace-edit.ts | 123 ++++++++++++++---- windows/tauri/src/i18n/shared.generated.ts | 6 + 9 files changed, 229 insertions(+), 40 deletions(-) diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 9208c56723..f0152bc30a 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -2123,6 +2123,7 @@ "Language Server Settings..." = "Language Server Settings..."; "Language Service" = "Language Service"; "Language Services" = "Language Services"; +"Language edit could not be planned." = "Language edit could not be planned."; "Language edit targets a file outside the workspace." = "Language edit targets a file outside the workspace."; "Language edit targets an unreadable file." = "Language edit targets an unreadable file."; "Language server activation failed" = "Language server activation failed"; @@ -7350,6 +7351,7 @@ "native.kotlinSupport" = "Kotlin Support"; "native.labels" = "Labels"; "native.labelsAndAssignees" = "Labels and assignees"; +"native.languageEditCouldNotBePlanned" = "Language edit could not be planned."; "native.languageEditTargetsAFileOutsideTheWorkspace" = "Language edit targets a file outside the workspace."; "native.languageEditTargetsAnUnreadableFile" = "Language edit targets an unreadable file."; "native.languageServer" = "Language Server"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 201cb94fa9..9b769f68ae 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -2123,6 +2123,7 @@ "Language Server Settings..." = "语言服务器设置..."; "Language Service" = "语言服务"; "Language Services" = "语言服务"; +"Language edit could not be planned." = "无法规划语言编辑。"; "Language edit targets a file outside the workspace." = "语言编辑指向工作区之外的文件。"; "Language edit targets an unreadable file." = "语言编辑指向无法读取的文件。"; "Language server activation failed" = "语言服务器激活失败"; @@ -7350,6 +7351,7 @@ "native.kotlinSupport" = "Kotlin 支持"; "native.labels" = "标签"; "native.labelsAndAssignees" = "标签和负责人"; +"native.languageEditCouldNotBePlanned" = "无法规划语言编辑。"; "native.languageEditTargetsAFileOutsideTheWorkspace" = "语言编辑指向工作区之外的文件。"; "native.languageEditTargetsAnUnreadableFile" = "语言编辑指向无法读取的文件。"; "native.languageServer" = "语言服务器"; diff --git a/macos/Sources/Lithe/Application/Features/LanguageWorkspaceEditService.swift b/macos/Sources/Lithe/Application/Features/LanguageWorkspaceEditService.swift index fe092c0927..d91d2d4a28 100644 --- a/macos/Sources/Lithe/Application/Features/LanguageWorkspaceEditService.swift +++ b/macos/Sources/Lithe/Application/Features/LanguageWorkspaceEditService.swift @@ -19,6 +19,7 @@ final class LanguageWorkspaceEditService { enum EditError: LocalizedError { case outsideWorkspace case unreadableFile + case planUnavailable var errorDescription: String? { switch self { @@ -26,6 +27,8 @@ final class LanguageWorkspaceEditService { return L10n.string("Language edit targets a file outside the workspace.") case .unreadableFile: return L10n.string("Language edit targets an unreadable file.") + case .planUnavailable: + return L10n.string("Language edit could not be planned.") } } } @@ -38,21 +41,17 @@ final class LanguageWorkspaceEditService { fileOperations: any WorkspaceFileOperations, updateDocument: @MainActor (EditorDocument, String) -> Void ) -> Bool { - let root = workspaceURL.standardizedFileURL.path var sources: [URL: String] = [:] var documents: [URL: EditorDocument] = [:] do { - for rawURL in edit.changes.keys { - let url = rawURL.standardizedFileURL - guard url.path == root || url.path.hasPrefix(root + "/") else { - throw EditError.outsideWorkspace - } + // Core validates every target against the workspace and the open + // documents before anything is read, and orders the commit. + let plan = try Self.plan(edit, workspaceURL: workspaceURL, openDocuments: openDocuments) + for target in plan { + let url = URL(fileURLWithPath: target.path).standardizedFileURL if let document = openDocuments.first(where: { $0.url.standardizedFileURL == url }) { - guard !document.isReadOnly else { - throw EditorDocument.DocumentError.readOnly - } documents[url] = document sources[url] = document.text } else { @@ -63,17 +62,17 @@ final class LanguageWorkspaceEditService { } } var replacements: [URL: String] = [:] - for (url, edits) in edit.changes { - let normalized = url.standardizedFileURL - guard let source = sources[normalized] else { continue } - replacements[normalized] = try LanguageServerTextEditApplicator.apply( - edits, - to: source - ) + for target in plan { + let url = URL(fileURLWithPath: target.path).standardizedFileURL + guard let source = sources[url] else { continue } + let edits = edit.changes.filter { $0.key.standardizedFileURL == url }.flatMap(\.value) + replacements[url] = try LanguageServerTextEditApplicator.apply(edits, to: source) } var originals: [URL: String] = [:] do { - for (url, replacement) in replacements where documents[url] == nil { + for target in plan where target.effect == "writeFile" { + let url = URL(fileURLWithPath: target.path).standardizedFileURL + guard let replacement = replacements[url] else { continue } originals[url] = sources[url] try fileOperations.writeText(replacement, to: url) } @@ -98,4 +97,55 @@ final class LanguageWorkspaceEditService { return false } } + + struct PlannedTarget: Decodable { + let path: String + let effect: String + } + + /// Asks Core (`lsp.planWorkspaceEdit`) which targets may be edited and in + /// what order they commit. A rejection maps to the matching edit error; + /// a Core failure refuses the whole edit. + static func plan( + _ edit: LanguageServerWorkspaceEdit, + workspaceURL: URL, + openDocuments: [EditorDocument] + ) throws -> [PlannedTarget] { + struct Position: Encodable { let line: Int; let utf16Column: Int } + struct Range: Encodable { let start: Position; let end: Position } + struct Edit: Encodable { let range: Range; let newText: String } + struct Open: Encodable { let path: String; let isReadOnly: Bool } + struct Request: Encodable { + let platform = "posix" + let workspaceRoot: String + let changes: [String: [Edit]] + let openDocuments: [Open] + } + struct Rejection: Decodable { let code: String } + struct Response: Decodable { let rejection: Rejection?; let targets: [PlannedTarget] } + var changes: [String: [Edit]] = [:] + for (url, edits) in edit.changes { + changes[url.standardizedFileURL.path, default: []] += edits.map { + Edit( + range: Range( + start: Position(line: $0.range.start.line, utf16Column: $0.range.start.utf16Column), + end: Position(line: $0.range.end.line, utf16Column: $0.range.end.utf16Column)), + newText: $0.newText) + } + } + let request = Request( + workspaceRoot: workspaceURL.standardizedFileURL.path, + changes: changes, + openDocuments: openDocuments.map { + Open(path: $0.url.standardizedFileURL.path, isReadOnly: $0.isReadOnly) + }) + let result: Result = RustCoreBridge().executeResult( + command: "lsp.planWorkspaceEdit", payload: request) + guard case .success(let response) = result else { throw EditError.planUnavailable } + switch response.rejection?.code { + case nil: return response.targets + case "readOnlyDocument": throw EditorDocument.DocumentError.readOnly + default: throw EditError.outsideWorkspace + } + } } diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index c60c58ae64..6a6e656f5a 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -1422,6 +1422,9 @@ "editor.toggleComment": "Toggle Comment", "editor.triggerSuggest": "Trigger Suggest", "editor.unknownFile": "Unknown", + "editor.workspaceEditOutsideWorkspace": "The edit targets a file outside the workspace.", + "editor.workspaceEditReadOnlyDocument": "The edit targets a read-only document.", + "editor.workspaceEditRollbackFailed": "The edit failed and some files could not be restored: {paths}", "editor.workspaceEditStale": "The document changed while preparing edits. Request the action again.", "editorCommands.appliedAction": "Applied: {title}", "editorCommands.chooseQuickFixPrompt": "Choose a quick fix:", @@ -5688,6 +5691,7 @@ "native.kotlinSupport": "Kotlin Support", "native.labels": "Labels", "native.labelsAndAssignees": "Labels and assignees", + "native.languageEditCouldNotBePlanned": "Language edit could not be planned.", "native.languageEditTargetsAFileOutsideTheWorkspace": "Language edit targets a file outside the workspace.", "native.languageEditTargetsAnUnreadableFile": "Language edit targets an unreadable file.", "native.languageServer": "Language Server", diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index fcbb6773bf..720817ad0e 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -1422,6 +1422,9 @@ "editor.toggleComment": "切换注释", "editor.triggerSuggest": "触发建议", "editor.unknownFile": "未知", + "editor.workspaceEditOutsideWorkspace": "修改指向工作区之外的文件。", + "editor.workspaceEditReadOnlyDocument": "修改指向只读文档。", + "editor.workspaceEditRollbackFailed": "修改失败,部分文件未能恢复:{paths}", "editor.workspaceEditStale": "准备修改时文档已发生变化,请重新执行操作。", "editorCommands.appliedAction": "已应用:{title}", "editorCommands.chooseQuickFixPrompt": "选择快速修复:", @@ -5688,6 +5691,7 @@ "native.kotlinSupport": "Kotlin 支持", "native.labels": "标签", "native.labelsAndAssignees": "标签和负责人", + "native.languageEditCouldNotBePlanned": "无法规划语言编辑。", "native.languageEditTargetsAFileOutsideTheWorkspace": "语言编辑指向工作区之外的文件。", "native.languageEditTargetsAnUnreadableFile": "语言编辑指向无法读取的文件。", "native.languageServer": "语言服务器", diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index 1cbc55fdfb..384460d88a 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -98305,6 +98305,41 @@ "windows" ] }, + { + "id": "editor.workspaceEditOutsideWorkspace", + "description": "Workspace edit rejected because a target is outside the workspace.", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.workspaceEditReadOnlyDocument", + "description": "Workspace edit rejected because a target document is read-only.", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.workspaceEditRollbackFailed", + "description": "Workspace edit failed and some files could not be restored.", + "platforms": [ + "windows" + ], + "parameters": { + "paths": "string" + } + }, + { + "id": "native.languageEditCouldNotBePlanned", + "description": "Native UI: Language edit could not be planned.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Language edit could not be planned.", + "arguments": [] + } + }, { "id": "workspace.appStore", "description": "Native project file Open With menu", diff --git a/windows/tauri/src/features/editor/lsp/workspace-edit-races.test.ts b/windows/tauri/src/features/editor/lsp/workspace-edit-races.test.ts index 8f3442d631..94b79f135c 100644 --- a/windows/tauri/src/features/editor/lsp/workspace-edit-races.test.ts +++ b/windows/tauri/src/features/editor/lsp/workspace-edit-races.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; import * as native from "@/platform/tauri-core"; import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; import { useBufferStore } from "../stores/buffer.store"; +import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; import { applyWorkspaceEdit } from "./workspace-edit"; const workspace = { id: "edit-race", name: "Edit race" }; let restore: () => void; @@ -16,10 +17,16 @@ for (const mutation of ["typing", "closed", "workspace"] as const) { const store = useBufferStore.getStore(workspace.id); const buffer: EditorContent = { id: "one", type: "editor", path: "C:/work/a.ts", name: "a.ts", content: "abc", savedContent: "abc", isVirtual: false, isDirty: false, isPinned: false, isPreview: false, isActive: true, tokens: [], contentRevision: 1 }; store.setState({ buffers: [buffer] }); + useFileSystemStore.getStore(workspace.id).setState({ rootFolderPath: "C:/work" }); let release!: (value: {text:string}) => void; let entered!: () => void; const started = new Promise(r => { entered = r; }); const pending = new Promise<{text:string}>(r => { release = r; }); - const invoke = spyOn(native, "invoke").mockImplementation(async command => { if(command === "lsp.applyTextEdits") { entered(); return pending as never; } return undefined as never; }); + const invoke = spyOn(native, "invoke").mockImplementation(async (command, args) => { + if(command === "lsp.planWorkspaceEdit") { + const changes = (args as { changes: Record }).changes; + return { rejection: null, targets: Object.entries(changes).map(([path, edits]) => ({ path, effect: "updateDocument", edits })) } as never; + } + if(command === "lsp.applyTextEdits") { entered(); return pending as never; } return undefined as never; }); const operation = applyWorkspaceEdit({ changes: { "C:/work/a.ts": [{ range: { start: {line:0,character:0}, end: {line:0,character:1} }, newText:"X" }] } }); const outcome = operation.catch(error => error); try { diff --git a/windows/tauri/src/features/editor/lsp/workspace-edit.ts b/windows/tauri/src/features/editor/lsp/workspace-edit.ts index 59b8e914b3..66553b4471 100644 --- a/windows/tauri/src/features/editor/lsp/workspace-edit.ts +++ b/windows/tauri/src/features/editor/lsp/workspace-edit.ts @@ -2,6 +2,10 @@ import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace import { createTranslator } from "@/i18n/locale"; import { useSettingsStore } from "@/features/settings/stores/settings.store"; import { invoke } from "@/platform/tauri-core"; +import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; +import { getNativeWorkspaceRootPaths } from "@/features/file-search/utils/file-search-paths"; +import { closestWorkspaceRoot } from "@/features/workspace/types/workspace-root"; +import { getBufferByPath } from "../utils/buffer-index"; import { requireLoadedBufferContent } from "../utils/buffer-load-state"; export interface LspPosition { @@ -136,38 +140,113 @@ export function collectWorkspaceTextEdits(edit: WorkspaceEdit): Map, + openDocuments: Array<{ path: string; isReadOnly: boolean }>, + translate: ReturnType, +): Promise { + const state = useFileSystemStore.getStore(workspaceRuntimeRegistry.getActiveWorkspaceId()).getState(); + const roots = getNativeWorkspaceRootPaths(state.rootFolderPath, state.workspaceFolders); + const groups = new Map>(); + for (const [filePath, edits] of editsByFile) { + const root = closestWorkspaceRoot(filePath, roots) ?? state.rootFolderPath ?? undefined; + if (!root) throw new Error(translate("editor.workspaceEditOutsideWorkspace")); + groups.set(root, { ...groups.get(root), [filePath]: edits }); + } + const targets: PlannedTarget[] = []; + for (const [workspaceRoot, changes] of groups) { + const plan = await invoke("lsp.planWorkspaceEdit", { + platform: "windows", + workspaceRoot, + changes, + openDocuments, + }); + if (plan.rejection) { + throw new Error(translate(plan.rejection.code === "readOnlyDocument" + ? "editor.workspaceEditReadOnlyDocument" + : "editor.workspaceEditOutsideWorkspace")); + } + targets.push(...plan.targets); + } + return targets; +} + export async function applyWorkspaceEdit(edit: WorkspaceEdit): Promise { const workspaceId = workspaceRuntimeRegistry.getActiveWorkspaceId(); const [{ useBufferStore }, { trackImmediateBufferHistoryChange }, { readFile, writeFile }] = await Promise.all([ import("../stores/buffer.store"), import("../stores/buffer-history-tracking"), import("@/features/file-system/controllers/platform"), ]); const store = useBufferStore.getStore(workspaceId); - const editsByFile = collectWorkspaceTextEdits(edit); - const prepared = await Promise.all(Array.from(editsByFile, async ([filePath, edits]) => { - const buffer = store.getState().buffers.find((entry) => entry.type === "editor" && !entry.isVirtual && entry.path === filePath); + const translate = createTranslator(useSettingsStore.getState().settings.displayLanguage); + const openEditors = () => store.getState().buffers.filter((entry) => entry.type === "editor" && !entry.isVirtual); + const bufferFor = (filePath: string) => getBufferByPath(openEditors(), filePath); + const targets = await planWorkspaceEdit( + collectWorkspaceTextEdits(edit), + openEditors().map((entry) => ({ path: entry.path, isReadOnly: entry.type === "editor" && !!entry.readOnly })), + translate, + ); + const prepared = await Promise.all(targets.map(async (target) => { + const buffer = target.effect === "updateDocument" ? bufferFor(target.path) : undefined; if (buffer?.type === "editor") requireLoadedBufferContent(buffer); - const content = buffer?.type === "editor" ? buffer.content : await readFile(filePath); + const content = buffer?.type === "editor" ? buffer.content : await readFile(target.path); const revision = buffer?.type === "editor" ? buffer.contentRevision : undefined; - const nextContent = await applyTextEditsToContent(content, edits); - return { filePath, bufferId: buffer?.id, content, revision, nextContent }; + const nextContent = await applyTextEditsToContent(content, target.edits); + return { filePath: target.path, bufferId: buffer?.id, content, revision, nextContent }; })); - const stale = () => new Error(createTranslator(useSettingsStore.getState().settings.displayLanguage)("editor.workspaceEditStale")); - if (workspaceRuntimeRegistry.getActiveWorkspaceId() !== workspaceId || useBufferStore.getStore(workspaceId) !== store) throw stale(); - // Check every prepared target before any buffer is changed. No await occurs - // between this barrier and the in-memory writes, so user input cannot interleave. - for (const source of prepared) { - const buffer = store.getState().buffers.find((entry) => entry.type === "editor" && !entry.isVirtual && entry.path === source.filePath); - if (source.bufferId ? (!buffer || buffer.type !== "editor" || buffer.id !== source.bufferId || buffer.content !== source.content || buffer.contentRevision !== source.revision) : !!buffer) throw stale(); + const stale = () => new Error(translate("editor.workspaceEditStale")); + // Every prepared target must still match what the edits were computed from. + const verifyUnchanged = () => { + if (workspaceRuntimeRegistry.getActiveWorkspaceId() !== workspaceId || useBufferStore.getStore(workspaceId) !== store) throw stale(); + for (const source of prepared) { + const buffer = bufferFor(source.filePath); + if (source.bufferId ? (!buffer || buffer.type !== "editor" || buffer.id !== source.bufferId || buffer.content !== source.content || buffer.contentRevision !== source.revision) : !!buffer) throw stale(); + } + }; + verifyUnchanged(); + // Closed files are written first, sequentially, so a failure can restore the + // ones already written and leave the workspace as it was. + const written: typeof prepared = []; + const rollback = async (cause: unknown): Promise => { + const failed: string[] = []; + for (const source of written.reverse()) { + try { await writeFile(source.filePath, source.content); } catch { failed.push(source.filePath); } + } + if (failed.length === 0) throw cause; + throw Object.assign(new Error(translate("editor.workspaceEditRollbackFailed", { paths: failed.join(", ") })), { cause }); + }; + try { + for (const source of prepared) { + if (source.bufferId) continue; + written.push(source); + await writeFile(source.filePath, source.nextContent); + } + // Input may have arrived while the files were written; nothing in memory has changed yet. + verifyUnchanged(); + } catch (error) { + return rollback(error); } - const diskWrites: Promise[] = []; + // No await occurs between this point and the in-memory writes, so user input cannot interleave. for (const source of prepared) { - if (source.bufferId) { - trackImmediateBufferHistoryChange({ bufferId: source.bufferId, currentContent: source.content, nextContent: source.nextContent }); - store.getState().actions.updateBufferContent(source.bufferId, source.nextContent, true); - } else { - diskWrites.push(writeFile(source.filePath, source.nextContent)); - } + if (!source.bufferId) continue; + trackImmediateBufferHistoryChange({ bufferId: source.bufferId, currentContent: source.content, nextContent: source.nextContent }); + store.getState().actions.updateBufferContent(source.bufferId, source.nextContent, true); } - await Promise.all(diskWrites); - return { editedFiles: editsByFile.size }; + return { editedFiles: prepared.length }; } diff --git a/windows/tauri/src/i18n/shared.generated.ts b/windows/tauri/src/i18n/shared.generated.ts index 898bc16ec1..df62945b13 100644 --- a/windows/tauri/src/i18n/shared.generated.ts +++ b/windows/tauri/src/i18n/shared.generated.ts @@ -1343,6 +1343,9 @@ export const sharedCatalogs = { "editor.toggleComment": "Toggle Comment", "editor.triggerSuggest": "Trigger Suggest", "editor.unknownFile": "Unknown", + "editor.workspaceEditOutsideWorkspace": "The edit targets a file outside the workspace.", + "editor.workspaceEditReadOnlyDocument": "The edit targets a read-only document.", + "editor.workspaceEditRollbackFailed": "The edit failed and some files could not be restored: {paths}", "editor.workspaceEditStale": "The document changed while preparing edits. Request the action again.", "editorCommands.appliedAction": "Applied: {title}", "editorCommands.chooseQuickFixPrompt": "Choose a quick fix:", @@ -6399,6 +6402,9 @@ export const sharedCatalogs = { "editor.toggleComment": "切换注释", "editor.triggerSuggest": "触发建议", "editor.unknownFile": "未知", + "editor.workspaceEditOutsideWorkspace": "修改指向工作区之外的文件。", + "editor.workspaceEditReadOnlyDocument": "修改指向只读文档。", + "editor.workspaceEditRollbackFailed": "修改失败,部分文件未能恢复:{paths}", "editor.workspaceEditStale": "准备修改时文档已发生变化,请重新执行操作。", "editorCommands.appliedAction": "已应用:{title}", "editorCommands.chooseQuickFixPrompt": "选择快速修复:", From 3d15d53d3f8f4bebe98ce2afa6aa56340361e4c8 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Wed, 4 Nov 2026 22:36:57 -0800 Subject: [PATCH 5/7] =?UTF-8?q?feat(documents):=20#1662=20LSP-14=20?= =?UTF-8?q?=E4=B8=A4=E7=AB=AF=E6=8E=A5=E5=85=A5=E6=89=B9=E9=87=8F=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=20plan/reduce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS 不再在第一个失败处停止;Windows 的 Save All 改由 Core 选择成员并归并结果。 Co-Authored-By: Claude Opus 5.5 --- .../Features/DocumentFeatureModel.swift | 13 ++++- .../Features/DocumentSaveBatch.swift | 54 ++++++++++++++++++ .../features/editor/services/save-batch.ts | 48 ++++++++++++++++ .../editor/stores/editor-app.store.test.ts | 55 ++++++++++++++++++- .../editor/stores/editor-app.store.ts | 33 +++++------ 5 files changed, 179 insertions(+), 24 deletions(-) create mode 100644 windows/tauri/src/features/editor/services/save-batch.ts diff --git a/macos/Sources/Lithe/Application/Features/DocumentFeatureModel.swift b/macos/Sources/Lithe/Application/Features/DocumentFeatureModel.swift index ceb1af425e..3aff08de56 100644 --- a/macos/Sources/Lithe/Application/Features/DocumentFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/DocumentFeatureModel.swift @@ -1015,17 +1015,24 @@ final class DocumentFeatureModel: ObservableObject { @discardableResult func saveAllDocuments() async -> Bool { - for document in openDocuments where !document.isReadOnly { + let members = DocumentSaveBatchCore.members(of: openDocuments) + var outcomes: [DocumentSaveBatchCore.Outcome] = [] + for document in members { + if Task.isCancelled { + outcomes.append(.cancelled) + continue + } do { let previousText = document.savedText try await saveDocument(document) if document.savedText != previousText { onRecordSave?(document, previousText) } + outcomes.append(.saved) // log-exempt: 同一次失败已经记录过:saveDocument 在保存处理边界记录 editor.document.save_failed } catch { - return false + outcomes.append(error is CancellationError ? .cancelled : .failed) } } - return !Task.isCancelled && !openDocuments.contains(where: \.isDirty) + return DocumentSaveBatchCore.isComplete(members: members, outcomes: outcomes, live: openDocuments) } @discardableResult diff --git a/macos/Sources/Lithe/Application/Features/DocumentSaveBatch.swift b/macos/Sources/Lithe/Application/Features/DocumentSaveBatch.swift index 8b0c94bf19..0f16222d88 100644 --- a/macos/Sources/Lithe/Application/Features/DocumentSaveBatch.swift +++ b/macos/Sources/Lithe/Application/Features/DocumentSaveBatch.swift @@ -14,3 +14,57 @@ enum DocumentSaveBatch { } } } + +/// Core-owned membership and result rules for one owner's Save All +/// (`document.planSaveBatch` / `document.reduceSaveBatch`). Writes, dialogs and +/// notifications stay with the caller. +@MainActor +enum DocumentSaveBatchCore { + enum Outcome: String, Encodable { case saved, failed, cancelled } + + private struct Document: Encodable { + let id: String + let dirty: Bool + let readOnly: Bool + let contentSynchronized = true + + @MainActor + init(_ document: EditorDocument) { + id = document.id.uuidString + dirty = document.isDirty + readOnly = document.isReadOnly + } + } + + /// Documents the batch writes, in request order. Core failure plans nothing. + static func members(of documents: [EditorDocument]) -> [EditorDocument] { + struct Request: Encodable { let documents: [Document] } + struct Response: Decodable { let members: [String] } + let result: Result = RustCoreBridge().executeResult( + command: "document.planSaveBatch", payload: Request(documents: documents.map { Document($0) })) + guard case .success(let response) = result else { return [] } + let selected = Set(response.members) + return documents.filter { selected.contains($0.id.uuidString) } + } + + /// True only when every member saved and no live document is dirty. + static func isComplete( + members: [EditorDocument], outcomes: [Outcome], live: [EditorDocument] + ) -> Bool { + struct Result_: Encodable { let id: String; let outcome: Outcome } + struct Request: Encodable { + let members: [String] + let results: [Result_] + let documents: [Document] + } + struct Response: Decodable { let status: String } + let request = Request( + members: members.map { $0.id.uuidString }, + results: zip(members, outcomes).map { Result_(id: $0.id.uuidString, outcome: $1) }, + documents: live.map { Document($0) }) + let result: Result = RustCoreBridge().executeResult( + command: "document.reduceSaveBatch", payload: request) + guard case .success(let response) = result else { return false } + return response.status == "saved" + } +} diff --git a/windows/tauri/src/features/editor/services/save-batch.ts b/windows/tauri/src/features/editor/services/save-batch.ts new file mode 100644 index 0000000000..b0ca5dde21 --- /dev/null +++ b/windows/tauri/src/features/editor/services/save-batch.ts @@ -0,0 +1,48 @@ +import { invoke } from "@/platform/tauri-core"; +import { isEditorContent, type PaneContent } from "@/features/panes/types/pane-content.types"; + +export type SaveBatchOutcome = "saved" | "failed" | "cancelled"; + +interface BatchDocument { + id: string; + dirty: boolean; + readOnly: boolean; + contentSynchronized: boolean; +} + +export interface SaveBatchResult { + status: "cancelled" | "failed" | "incomplete" | "saved"; + savedCount: number; +} + +// Windows buffers are the source of truth for text, so a clean buffer never +// hides input that the model has not received. +function batchDocuments(buffers: readonly PaneContent[]): BatchDocument[] { + return buffers.filter(isEditorContent).map((buffer) => ({ + id: buffer.id, + dirty: buffer.isDirty, + readOnly: !!buffer.readOnly, + contentSynchronized: true, + })); +} + +/** Ids of the open documents a Save All writes (`document.planSaveBatch`). */ +export async function planSaveBatch(buffers: readonly PaneContent[]): Promise { + const plan = await invoke<{ members: string[] }>("document.planSaveBatch", { + documents: batchDocuments(buffers), + }); + return plan.members; +} + +/** Reduces member outcomes and the live buffers into one batch status (`document.reduceSaveBatch`). */ +export async function reduceSaveBatch( + members: readonly string[], + outcomes: readonly SaveBatchOutcome[], + liveBuffers: readonly PaneContent[], +): Promise { + return invoke("document.reduceSaveBatch", { + members, + results: members.map((id, index) => ({ id, outcome: outcomes[index] })), + documents: batchDocuments(liveBuffers), + }); +} diff --git a/windows/tauri/src/features/editor/stores/editor-app.store.test.ts b/windows/tauri/src/features/editor/stores/editor-app.store.test.ts index e5dd2603f3..1b32fb5ecc 100644 --- a/windows/tauri/src/features/editor/stores/editor-app.store.test.ts +++ b/windows/tauri/src/features/editor/stores/editor-app.store.test.ts @@ -1,3 +1,4 @@ +import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { toast } from "sonner"; import * as native from "@/platform/tauri-core"; @@ -11,6 +12,7 @@ import type { EditorContent } from "@/features/panes/types/pane-content.types"; import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; import { saveWorkspaceBeforeLaunch } from "../services/save-workspace-before-launch"; import { LspClient } from "../lsp/lsp-client"; +import * as saveBatch from "../services/save-batch"; import { getBufferById } from "../utils/buffer-index"; import { useBufferStore } from "./buffer.store"; import { createEditorAppStore, useEditorAppStore } from "./editor-app.store"; @@ -58,17 +60,40 @@ function getEditorBuffer(workspaceId: string, bufferId: string): EditorContent { return buffer; } +const saveBatchSpies: Array<{ mockRestore(): void }> = []; + beforeEach(() => { + // The webview has no Core in unit tests; these fakes follow the Core rules + // pinned by shared/fixtures/documents/save-batch-v1.json. + saveBatchSpies.push( + spyOn(saveBatch, "planSaveBatch").mockImplementation(async (buffers) => + buffers.filter((b) => b.type === "editor" && b.isDirty && !b.readOnly).map((b) => b.id)), + spyOn(saveBatch, "reduceSaveBatch").mockImplementation(async (_members, outcomes) => ({ + status: outcomes.includes("failed") ? "failed" : "saved", + savedCount: outcomes.filter((o) => o === "saved").length, + })), + ); workspaceRuntimeRegistry.resetForTests(); workspaceRuntimeRegistry.ensureWorkspace({ id: WORKSPACE_A, name: "Workspace A" }, "ready"); workspaceRuntimeRegistry.ensureWorkspace({ id: WORKSPACE_B, name: "Workspace B" }, "ready"); }); afterEach(() => { + for (const spy of saveBatchSpies.splice(0)) spy.mockRestore(); useEditorAppStore.getStore(WORKSPACE_A).getState().actions.cleanup(); useEditorAppStore.getStore(WORKSPACE_B).getState().actions.cleanup(); }); +/** Mirrors Core's `lsp.planWorkspaceEdit` for open documents inside the workspace. */ +function fakeWorkspaceEditPlan(args: unknown) { + const changes = (args as { changes: Record }).changes; + return { rejection: null, targets: Object.entries(changes).map(([path, edits]) => ({ path, effect: "updateDocument", edits })) }; +} + +function useFixtureWorkspaceRoot() { + useFileSystemStore.getStore(WORKSPACE_A).setState({ rootFolderPath: "C:/fixture" }); +} + describe("workspace-scoped editor actions", () => { test.each(["edited", "saved", "closed"] as const)( "launch save rechecks a later buffer after it is %s during another notification", @@ -121,12 +146,15 @@ describe("workspace-scoped editor actions", () => { workspaceRuntimeRegistry.activateWorkspace({ id: WORKSPACE_A, name: "Workspace A" }, "ready"); const options = { caseSensitive: true, wholeWord: false, useRegex: false }; const edit = { range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } }, newText: "new" }; + useFixtureWorkspaceRoot(); + const planner = spyOn(native, "invoke").mockImplementation(async (command: string, args?: unknown): Promise => + (command === "lsp.planWorkspaceEdit" ? fakeWorkspaceEditPlan(args) : undefined) as T); const task = operation === "rename" ? applyWorkspaceEdit({ changes: { "file:///C:/fixture/a.txt": [edit], "file:///C:/fixture/b.txt": [edit] } }) : operation === "replace-all" ? replaceAllInSources([a.path, b.path], "old", "new", options) : replaceNextInSource({ filePath: b.path, line: 1, column: 1 }, "old", "new", options); - await expect(task).rejects.toThrow("b.txt"); + try { await expect(task).rejects.toThrow("b.txt"); } finally { planner.mockRestore(); } expect(getEditorBuffer(WORKSPACE_A, a.id).content).toBe("old name"); expect(getEditorBuffer(WORKSPACE_A, a.id).isDirty).toBe(false); expect(getEditorBuffer(WORKSPACE_A, b.id).content).toBe(""); @@ -160,7 +188,9 @@ describe("workspace-scoped editor actions", () => { setWorkspaceBuffers(WORKSPACE_A, [a, b], a.id); workspaceRuntimeRegistry.activateWorkspace({ id: WORKSPACE_A, name: "Workspace A" }, "ready"); const failure = Object.assign(new Error("overlappingEdits"), { code: "invalid_request" }); + useFixtureWorkspaceRoot(); const invoke = spyOn(native, "invoke").mockImplementation(async (command: string, args?: unknown): Promise => { + if (command === "lsp.planWorkspaceEdit") return fakeWorkspaceEditPlan(args) as T; expect(command).toBe("lsp.applyTextEdits"); if ((args as { text?: string } | undefined)?.text === "second") throw failure; return { text: "changed" } as T; @@ -173,7 +203,7 @@ describe("workspace-scoped editor actions", () => { expect(getEditorBuffer(WORKSPACE_A, a.id).content).toBe("first"); expect(getEditorBuffer(WORKSPACE_A, b.id).content).toBe("second"); expect(getEditorBuffer(WORKSPACE_A, a.id).isDirty).toBe(false); - expect(invoke).toHaveBeenCalledTimes(2); + expect(invoke).toHaveBeenCalledTimes(3); } finally { invoke.mockRestore(); } }); @@ -181,7 +211,9 @@ describe("workspace-scoped editor actions", () => { const a = editorBuffer("a", "中文😀\r\n", { path: "C:/fixture/a.txt", isVirtual: false }); setWorkspaceBuffers(WORKSPACE_A, [a], a.id); workspaceRuntimeRegistry.activateWorkspace({ id: WORKSPACE_A, name: "Workspace A" }, "ready"); - const invoke = spyOn(native, "invoke").mockResolvedValue({ text: "中文🚀\r\n" }); + useFixtureWorkspaceRoot(); + const invoke = spyOn(native, "invoke").mockImplementation(async (command: string, args?: unknown): Promise => + (command === "lsp.planWorkspaceEdit" ? fakeWorkspaceEditPlan(args) : { text: "中文🚀\r\n" }) as T); try { expect(await applyWorkspaceEdit({ changes: { "file:///C:/fixture/a.txt": [{ range: { start: { line: 0, character: 2 }, end: { line: 0, character: 4 } }, newText: "🚀", @@ -191,6 +223,23 @@ describe("workspace-scoped editor actions", () => { } finally { invoke.mockRestore(); } }); + test("workspace edits refuse a target Core rejects before touching any buffer", async () => { + const a = editorBuffer("a", "first", { path: "C:/fixture/a.txt", isVirtual: false }); + setWorkspaceBuffers(WORKSPACE_A, [a], a.id); + workspaceRuntimeRegistry.activateWorkspace({ id: WORKSPACE_A, name: "Workspace A" }, "ready"); + useFixtureWorkspaceRoot(); + const invoke = spyOn(native, "invoke").mockResolvedValue({ + rejection: { code: "outsideWorkspace", path: "D:/elsewhere/b.txt" }, targets: [], + }); + const edit = { range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } }, newText: "new" }; + try { + await expect(applyWorkspaceEdit({ changes: { "file:///C:/fixture/a.txt": [edit], "file:///D:/elsewhere/b.txt": [edit] } })) + .rejects.toThrow(); + expect(invoke).toHaveBeenCalledTimes(1); + expect(getEditorBuffer(WORKSPACE_A, a.id).content).toBe("first"); + } finally { invoke.mockRestore(); } + }); + test("unrelated loading tabs do not block replacement or lose later edits", async () => { const a = editorBuffer("a", "old name", { path: "C:/fixture/a.txt", isVirtual: false }); const b = editorBuffer("b", "", { path: "C:/fixture/b.txt", isVirtual: false }); diff --git a/windows/tauri/src/features/editor/stores/editor-app.store.ts b/windows/tauri/src/features/editor/stores/editor-app.store.ts index c9dac23eed..c9eb144e6e 100644 --- a/windows/tauri/src/features/editor/stores/editor-app.store.ts +++ b/windows/tauri/src/features/editor/stores/editor-app.store.ts @@ -1,3 +1,4 @@ +import { planSaveBatch, reduceSaveBatch, type SaveBatchOutcome } from "../services/save-batch"; import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; import { bufferNotLoadedMessage, isBufferContentLoaded } from "../utils/buffer-load-state"; import { isLocalDocumentPath, readDocumentFile, saveDocumentFile } from "@/platform/document-files"; @@ -470,13 +471,6 @@ async function saveEditorBufferById( } } -function getDirtyEditorBuffers(buffers: PaneContent[]): EditorContent[] { - return buffers.filter( - (buffer): buffer is EditorContent => - isEditorContent(buffer) && buffer.isDirty && !buffer.readOnly, - ); -} - /** One queued autosave debounce, optionally continuing an in-flight write. */ interface AutoSaveTask { timeoutId: ReturnType; @@ -839,21 +833,24 @@ export const createEditorAppStore = ( handleSaveAll: async () => { const bufferStore = useBufferStore.getStore(workspaceId); - const dirtyBufferIds = getDirtyEditorBuffers(bufferStore.getState().buffers).map( - (buffer) => buffer.id, - ); - const saveResults = await Promise.all( - dirtyBufferIds.map(async (bufferId) => { + // Core selects the members and reduces their outcomes; the writes + // run concurrently here and every member is attempted. + const members = await planSaveBatch(bufferStore.getState().buffers); + const settled = await Promise.allSettled( + members.map(async (bufferId): Promise => { const result = await saveEditorBufferById(workspaceId, bufferId); const nextBuffer = getBufferById(bufferStore.getState().buffers, bufferId); - return ( - result === "saved" && - (!nextBuffer || !isEditorContent(nextBuffer) || !nextBuffer.isDirty) - ); + const clean = !nextBuffer || !isEditorContent(nextBuffer) || !nextBuffer.isDirty; + return result === "saved" && clean ? "saved" : "failed"; }), ); - - return saveResults.filter(Boolean).length; + const outcomes = settled.map((entry): SaveBatchOutcome => + entry.status === "fulfilled" ? entry.value : "failed"); + const batch = await reduceSaveBatch(members, outcomes, bufferStore.getState().buffers); + // A thrown save is still reported to the caller after every member ran. + const thrown = settled.find((entry) => entry.status === "rejected"); + if (thrown?.status === "rejected") throw thrown.reason; + return batch.savedCount; }, openQuickEdit: (params) => { From 2d24fb2fa093b8b0aec4e70d75b8d9f9771bb740 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Wed, 4 Nov 2026 22:36:57 -0800 Subject: [PATCH 6/7] =?UTF-8?q?docs(matrix):=20#1662=20LSP-09=20=E8=AF=B4?= =?UTF-8?q?=E6=98=8E=20Windows=20=E6=B2=A1=E6=9C=89=E8=A1=A5=E5=85=A8?= =?UTF-8?q?=E5=90=88=E5=B9=B6=E6=97=A7=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../platform-feature-matrix/features/lsp-completion-hover.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/shared/platform-feature-matrix/features/lsp-completion-hover.json b/shared/platform-feature-matrix/features/lsp-completion-hover.json index a9c18890ba..096f060b31 100644 --- a/shared/platform-feature-matrix/features/lsp-completion-hover.json +++ b/shared/platform-feature-matrix/features/lsp-completion-hover.json @@ -33,5 +33,6 @@ "verificationStatus": "pending" }, "owner": "Language Tooling", - "verification": "在相同 Java fixture 中验证补全、Hover、排序、超时和空结果。Java 构造器和方法补全使用 JDT LS 的 guessMethodArguments=off:输入 new No 后接受 Node 候选应得到 Node(),光标在括号内;方法调用同样不插入参数名,构造器重载候选由 JDT LS 合并为类型候选。验证 initialize、didChangeConfiguration 与 workspace/configuration 三个配置入口一致,父级和叶级 section 均保留插件 favoriteStaticMembers、filteredTypes 等其他补全配置,其他语言 snippet、Java 方法补全签名展示及自动导入仍正常。 连续输入时保留 isIncomplete 并通过 LSP triggerKind=3 重新请求候选;验证空未完整列表、完整/空响应、触发字符、快速修改及文档关闭/会话替换后的过期结果。 #1662 LSP-09:多个补全来源的合并由 Core 的 lsp.mergeCompletions 决定:同一来源的候选全部保留(同名不同签名的重载不再被丢弃),低优先级来源中与高优先级来源同 label 的候选被遮蔽。Windows 只有一个补全来源,原样显示服务器列表,不调用该命令。macOS 删除私有的 Go/Swift/Rust/Python/JS/TS 关键词表,关键词改由语言服务器或插件提供,与 Windows 一致。在 macOS 打开有重载方法的 Java/TS 文件,确认同名重载全部出现;未运行语言服务器的 Go 文件不再出现内置关键词候选,当前文件标识符仍会出现。" + "verification": "在相同 Java fixture 中验证补全、Hover、排序、超时和空结果。Java 构造器和方法补全使用 JDT LS 的 guessMethodArguments=off:输入 new No 后接受 Node 候选应得到 Node(),光标在括号内;方法调用同样不插入参数名,构造器重载候选由 JDT LS 合并为类型候选。验证 initialize、didChangeConfiguration 与 workspace/configuration 三个配置入口一致,父级和叶级 section 均保留插件 favoriteStaticMembers、filteredTypes 等其他补全配置,其他语言 snippet、Java 方法补全签名展示及自动导入仍正常。 连续输入时保留 isIncomplete 并通过 LSP triggerKind=3 重新请求候选;验证空未完整列表、完整/空响应、触发字符、快速修改及文档关闭/会话替换后的过期结果。 #1662 LSP-09:多个补全来源的合并由 Core 的 lsp.mergeCompletions 决定:同一来源的候选全部保留(同名不同签名的重载不再被丢弃),低优先级来源中与高优先级来源同 label 的候选被遮蔽。Windows 只有一个补全来源,原样显示服务器列表,不调用该命令。macOS 删除私有的 Go/Swift/Rust/Python/JS/TS 关键词表,关键词改由语言服务器或插件提供,与 Windows 一致。在 macOS 打开有重载方法的 Java/TS 文件,确认同名重载全部出现;未运行语言服务器的 Go 文件不再出现内置关键词候选,当前文件标识符仍会出现。", + "notes": "补全多 provider 合并(lsp.mergeCompletions)只有 macOS 存在多 provider 与按 label 去重;Windows 只有单一 LSP 补全来源,没有对应的 TS 合并/去重实现可删除,未来出现多来源时直接调用同一命令与 fixture。" } From af85c358186216a4ab27f804683e51a0544ff7f2 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Thu, 5 Nov 2026 01:43:00 -0800 Subject: [PATCH 7/7] =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E5=8C=96=20lithe-core?= =?UTF-8?q?=EF=BC=88cargo=20fmt=EF=BC=8C=E4=BB=85=E6=9C=AC=20PR=20?= =?UTF-8?q?=E6=94=B9=E5=8A=A8=E8=BF=87=E7=9A=84=E6=96=87=E4=BB=B6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- rust/lithe-core/src/editor/tests.rs | 7 ++++++- rust/lithe-core/src/editor/text_edit_plan.rs | 7 +++++-- .../src/lsp/host_policy/completion_merge.rs | 7 +++++-- rust/lithe-core/src/lsp/host_policy/tests.rs | 19 +++++++++++++++---- rust/lithe-core/src/lsp/navigation_target.rs | 3 ++- .../lithe-core/src/lsp/workspace_edit_plan.rs | 10 ++++++---- 6 files changed, 39 insertions(+), 14 deletions(-) diff --git a/rust/lithe-core/src/editor/tests.rs b/rust/lithe-core/src/editor/tests.rs index 9cb2db86f9..977945533a 100644 --- a/rust/lithe-core/src/editor/tests.rs +++ b/rust/lithe-core/src/editor/tests.rs @@ -54,7 +54,12 @@ fn text_edit_plan_matches_shared_fixture() { let replacement: Vec = edit["text"].as_str().unwrap().encode_utf16().collect(); units.splice(offset..offset + length, replacement); } - assert_eq!(String::from_utf16(&units).unwrap(), expected, "{}", case["name"]); + assert_eq!( + String::from_utf16(&units).unwrap(), + expected, + "{}", + case["name"] + ); } } } diff --git a/rust/lithe-core/src/editor/text_edit_plan.rs b/rust/lithe-core/src/editor/text_edit_plan.rs index 6797718844..920eaaf034 100644 --- a/rust/lithe-core/src/editor/text_edit_plan.rs +++ b/rust/lithe-core/src/editor/text_edit_plan.rs @@ -78,8 +78,11 @@ fn reason_name(reason: EditRejectionReason) -> &'static str { /// the document's line ending. pub(crate) fn plan_command(payload: Value) -> Result { let request: Request = serde_json::from_value(payload).map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid editor text edit plan request") - .with_details(error.to_string()) + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid editor text edit plan request", + ) + .with_details(error.to_string()) })?; let index = TextLineIndex::new(&request.text); let line_ending = index.line_ending(); diff --git a/rust/lithe-core/src/lsp/host_policy/completion_merge.rs b/rust/lithe-core/src/lsp/host_policy/completion_merge.rs index 2b10d6f31e..141c661688 100644 --- a/rust/lithe-core/src/lsp/host_policy/completion_merge.rs +++ b/rust/lithe-core/src/lsp/host_policy/completion_merge.rs @@ -47,8 +47,11 @@ struct Response { /// Executes `lsp.mergeCompletions`. pub(crate) fn merge_command(payload: Value) -> Result { let request: Request = serde_json::from_value(payload).map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid completion merge request") - .with_details(error.to_string()) + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid completion merge request", + ) + .with_details(error.to_string()) })?; Ok(serde_json::to_value(merge(&request)).expect("completion merge response should encode")) } diff --git a/rust/lithe-core/src/lsp/host_policy/tests.rs b/rust/lithe-core/src/lsp/host_policy/tests.rs index edf84f64d6..12b6b46aca 100644 --- a/rust/lithe-core/src/lsp/host_policy/tests.rs +++ b/rust/lithe-core/src/lsp/host_policy/tests.rs @@ -5,7 +5,9 @@ use serde_json::{json, Value}; fn run_fixture(command: &str, fixture: &str) { let fixture: Value = serde_json::from_str(fixture).expect("fixture should be JSON"); - let cases = fixture["cases"].as_array().expect("fixture should list cases"); + let cases = fixture["cases"] + .as_array() + .expect("fixture should list cases"); assert!(!cases.is_empty(), "{command} fixture has no cases"); for case in cases { let response: Value = serde_json::from_str(&crate::execute_json( @@ -57,10 +59,19 @@ fn completion_merge_matches_shared_fixture() { #[test] fn malformed_requests_fail_with_invalid_request() { for (command, payload) in [ - ("lsp.problems.query", json!({"diagnostics": [{"fileKey": "a"}]})), - ("lsp.problems.query", json!({"diagnostics": [], "groupBy": "type"})), + ( + "lsp.problems.query", + json!({"diagnostics": [{"fileKey": "a"}]}), + ), + ( + "lsp.problems.query", + json!({"diagnostics": [], "groupBy": "type"}), + ), ("lsp.activationPolicy", json!({"event": "automaticStart"})), - ("lsp.activationPolicy", json!({"event": "crashed", "nowMs": 1})), + ( + "lsp.activationPolicy", + json!({"event": "crashed", "nowMs": 1}), + ), ("lsp.mergeCompletions", json!({"lists": [{"items": []}]})), ] { let response: Value = serde_json::from_str(&crate::execute_json( diff --git a/rust/lithe-core/src/lsp/navigation_target.rs b/rust/lithe-core/src/lsp/navigation_target.rs index d5ad00241e..a442b04a34 100644 --- a/rust/lithe-core/src/lsp/navigation_target.rs +++ b/rust/lithe-core/src/lsp/navigation_target.rs @@ -107,7 +107,8 @@ mod tests { .unwrap(); for case in fixture["cases"].as_array().unwrap() { let response: Value = serde_json::from_str(&crate::execute_json( - &json!({"command": "lsp.classifyNavigationTarget", "payload": case["request"]}).to_string(), + &json!({"command": "lsp.classifyNavigationTarget", "payload": case["request"]}) + .to_string(), )) .unwrap(); if let Some(code) = case.get("expectedError") { diff --git a/rust/lithe-core/src/lsp/workspace_edit_plan.rs b/rust/lithe-core/src/lsp/workspace_edit_plan.rs index 3f6abfd961..c3c75d56d4 100644 --- a/rust/lithe-core/src/lsp/workspace_edit_plan.rs +++ b/rust/lithe-core/src/lsp/workspace_edit_plan.rs @@ -74,8 +74,7 @@ fn native_path(key: &str) -> Option { _ => key.to_string(), }; let bytes = path.as_bytes(); - if bytes.len() >= 3 && bytes[0] == b'/' && bytes[1].is_ascii_alphabetic() && bytes[2] == b':' - { + if bytes.len() >= 3 && bytes[0] == b'/' && bytes[1].is_ascii_alphabetic() && bytes[2] == b':' { return Some(path[1..].to_string()); } Some(path) @@ -104,7 +103,9 @@ pub(crate) fn plan_command(payload: Value) -> Result { .with_details(error.to_string()) })?; let scope = WorkspaceScope::new(&request.workspace_root, request.platform)?; - let reject = |code: &str, path: &str| Ok(json!({"rejection": {"code": code, "path": path}, "targets": []})); + let reject = |code: &str, path: &str| { + Ok(json!({"rejection": {"code": code, "path": path}, "targets": []})) + }; let mut targets: Vec = Vec::new(); // serde_json maps iterate in key order, which keeps rejection and merge // order deterministic for the same edit. @@ -171,7 +172,8 @@ mod tests { .unwrap(); for case in fixture["cases"].as_array().unwrap() { let response: Value = serde_json::from_str(&crate::execute_json( - &json!({"command": "lsp.planWorkspaceEdit", "payload": case["request"]}).to_string(), + &json!({"command": "lsp.planWorkspaceEdit", "payload": case["request"]}) + .to_string(), )) .unwrap(); if let Some(code) = case.get("expectedError") {