From 3bc33398033e3522981b5ceb1ad0f25e39bf53fa Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Thu, 13 Aug 2026 19:23:07 +0800 Subject: [PATCH 01/10] fix: fix the LSP is breaking but the Symbol Provider can't still work problem --- .../LanguageToolingSessionManager.swift | 92 +++++++++- .../LanguageFeatureProviderTests.swift | 173 ++++++++++++++++++ .../RunConfigurationIntegrationTests.swift | 12 +- 3 files changed, 263 insertions(+), 14 deletions(-) diff --git a/Sources/Lithe/Services/LanguageToolingSessionManager.swift b/Sources/Lithe/Services/LanguageToolingSessionManager.swift index b13e6ad41..0b94c7c72 100644 --- a/Sources/Lithe/Services/LanguageToolingSessionManager.swift +++ b/Sources/Lithe/Services/LanguageToolingSessionManager.swift @@ -775,11 +775,35 @@ final class LanguageToolingSessionManager: ObservableObject { completion: completion ) case .failure(let error): - completion(.failure(error)) + recordFeatureProviderFailure( + providers[index], + feature: "completion", + error: error + ) + routeCompletions( + providers: providers, + index: index + 1, + context: context, + items: items, + seenLabels: seenLabels, + completion: completion + ) } } } catch { - completion(.failure(error)) + recordFeatureProviderFailure( + providers[index], + feature: "completion", + error: error + ) + routeCompletions( + providers: providers, + index: index + 1, + context: context, + items: items, + seenLabels: seenLabels, + completion: completion + ) } } @@ -806,11 +830,31 @@ final class LanguageToolingSessionManager: ObservableObject { completion: completion ) case .failure(let error): - completion(.failure(error)) + recordFeatureProviderFailure( + providers[index], + feature: "hover", + error: error + ) + routeHover( + providers: providers, + index: index + 1, + context: context, + completion: completion + ) } } } catch { - completion(.failure(error)) + recordFeatureProviderFailure( + providers[index], + feature: "hover", + error: error + ) + routeHover( + providers: providers, + index: index + 1, + context: context, + completion: completion + ) } } @@ -841,14 +885,50 @@ final class LanguageToolingSessionManager: ObservableObject { completion(.success(locations)) } case .failure(let error): - completion(.failure(error)) + recordFeatureProviderFailure( + providers[index], + feature: method, + error: error + ) + routeNavigation( + providers: providers, + index: index + 1, + method: method, + context: context, + completion: completion + ) } } } catch { - completion(.failure(error)) + recordFeatureProviderFailure( + providers[index], + feature: method, + error: error + ) + routeNavigation( + providers: providers, + index: index + 1, + method: method, + context: context, + completion: completion + ) } } + private func recordFeatureProviderFailure( + _ provider: any LanguageFeatureProvider, + feature: String, + error: Error + ) { + guard provider.id.hasPrefix("lsp:") else { return } + recordLanguageServerLog( + providerID: String(provider.id.dropFirst("lsp:".count)), + level: .warning, + message: "Language server feature failed; using fallback", + detail: "\(feature): \(error.localizedDescription)" + ) + } + private func configureLanguageServerCallbacks( _ session: any LanguageServerSession, providerID: String, diff --git a/Tests/LitheTests/LanguageFeatureProviderTests.swift b/Tests/LitheTests/LanguageFeatureProviderTests.swift index 414f338cd..670b92994 100644 --- a/Tests/LitheTests/LanguageFeatureProviderTests.swift +++ b/Tests/LitheTests/LanguageFeatureProviderTests.swift @@ -52,6 +52,69 @@ struct LanguageFeatureProviderTests { #expect(items.contains { $0.label == "for" && $0.detail == "Go keyword" }) } + @Test + func managerContinuesCompletionAfterProviderFailure() throws { + let manager = LanguageToolingSessionManager(languageFeatureProviders: [ + FailingFeatureProvider(mode: .callback), + FallbackFeatureProvider() + ]) + let fileURL = URL(fileURLWithPath: "/tmp/main.go") + var result: Result<[LanguageServerCompletionItem], Error>? + + try manager.completions( + fileURL: fileURL, + text: "fa", + position: LanguageServerPosition(line: 0, utf16Column: 2), + rootURL: fileURL.deletingLastPathComponent() + ) { result = $0 } + + let items = try #require(result).get() + #expect(items.contains { $0.label == "fallbackCompletion" }) + #expect(manager.languageServerLogs.first?.providerID == "test") + #expect(manager.languageServerLogs.first?.level == .warning) + } + + @Test + func managerContinuesHoverAfterProviderThrow() throws { + let manager = LanguageToolingSessionManager(languageFeatureProviders: [ + FailingFeatureProvider(mode: .throwing), + FallbackFeatureProvider() + ]) + let fileURL = URL(fileURLWithPath: "/tmp/main.go") + var result: Result? + + try manager.hover( + fileURL: fileURL, + text: "fallbackSymbol", + position: LanguageServerPosition(line: 0, utf16Column: 3), + rootURL: fileURL.deletingLastPathComponent() + ) { result = $0 } + + let hover = try #require(result).get() + #expect(hover?.contents == "fallback hover") + } + + @Test + func managerContinuesNavigationAfterProviderFailure() throws { + let manager = LanguageToolingSessionManager(languageFeatureProviders: [ + FailingFeatureProvider(mode: .callback), + FallbackFeatureProvider() + ]) + let fileURL = URL(fileURLWithPath: "/tmp/main.go") + var result: Result<[LanguageServerLocation], Error>? + + try manager.navigate( + method: "textDocument/definition", + fileURL: fileURL, + text: "fallbackSymbol", + position: LanguageServerPosition(line: 0, utf16Column: 3), + rootURL: fileURL.deletingLastPathComponent() + ) { result = $0 } + + let locations = try #require(result).get() + #expect(locations.map(\.url) == [fileURL]) + } + private static func item(label: String, detail: String) -> LanguageServerCompletionItem { LanguageServerCompletionItem( label: label, @@ -68,6 +131,116 @@ struct LanguageFeatureProviderTests { } } +private enum FeatureProviderTestError: LocalizedError { + case failed + + var errorDescription: String? { "Test provider failed" } +} + +@MainActor +private final class FailingFeatureProvider: LanguageFeatureProvider { + enum Mode { + case callback + case throwing + } + + let id = "lsp:test" + let priority: LanguageFeatureProviderPriority = .languageServer + private let mode: Mode + + init(mode: Mode) { + self.mode = mode + } + + func supports(_: LanguageFeature, in _: LanguageFeatureRequestContext) -> Bool { true } + + func completions( + in _: LanguageFeatureRequestContext, + completion: @escaping (Result<[LanguageServerCompletionItem], Error>) -> Void + ) throws { + try fail(completion) + } + + func hover( + in _: LanguageFeatureRequestContext, + completion: @escaping (Result) -> Void + ) throws { + try fail(completion) + } + + func navigate( + method _: String, + in _: LanguageFeatureRequestContext, + completion: @escaping (Result<[LanguageServerLocation], Error>) -> Void + ) throws { + try fail(completion) + } + + private func fail( + _ completion: @escaping (Result) -> Void + ) throws { + switch mode { + case .callback: + completion(.failure(FeatureProviderTestError.failed)) + case .throwing: + throw FeatureProviderTestError.failed + } + } +} + +@MainActor +private final class FallbackFeatureProvider: LanguageFeatureProvider { + let id = "test.fallback" + let priority: LanguageFeatureProviderPriority = .projectSymbols + + func supports(_: LanguageFeature, in _: LanguageFeatureRequestContext) -> Bool { true } + + func completions( + in _: LanguageFeatureRequestContext, + completion: @escaping (Result<[LanguageServerCompletionItem], Error>) -> Void + ) throws { + completion(.success([ + LanguageServerCompletionItem( + label: "fallbackCompletion", + detail: "Project symbols", + documentation: nil, + insertText: "fallbackCompletion", + sortText: nil, + filterText: nil, + kind: nil, + textEdit: nil, + additionalTextEdits: [], + data: nil + ) + ])) + } + + func hover( + in _: LanguageFeatureRequestContext, + completion: @escaping (Result) -> Void + ) throws { + completion(.success(LanguageServerHover( + contents: "fallback hover", + isMarkdown: false, + range: nil + ))) + } + + func navigate( + method _: String, + in context: LanguageFeatureRequestContext, + completion: @escaping (Result<[LanguageServerLocation], Error>) -> Void + ) throws { + let position = LanguageServerPosition(line: 0, utf16Column: 0) + completion(.success([ + LanguageServerLocation( + url: context.fileURL, + range: LanguageServerRange(start: position, end: position) + ) + ])) + } +} + @MainActor private final class CompletionFeatureProvider: LanguageFeatureProvider { let id = "test.remote" diff --git a/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/Tests/LitheTests/RunConfigurationIntegrationTests.swift index b9437bd7b..c9c39e434 100644 --- a/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -1236,11 +1236,9 @@ struct RunConfigurationIntegrationTests { var completionResult: Result<[LanguageServerCompletionItem], Error>? var completionCount = 0 - try harness.manager.completions( + try harness.session.completions( fileURL: harness.source, - text: "struct App { let ti = 1 }\n", - position: LanguageServerPosition(line: 0, utf16Column: 19), - rootURL: harness.root + position: LanguageServerPosition(line: 0, utf16Column: 19) ) { result in completionCount += 1 completionResult = result @@ -1285,11 +1283,9 @@ struct RunConfigurationIntegrationTests { #expect(harness.core.startCalls.first?.requestTimeout == 0.002) var completionResult: Result<[LanguageServerCompletionItem], Error>? - try harness.manager.completions( + try harness.session.completions( fileURL: harness.source, - text: "struct App { let ti = 1 }\n", - position: LanguageServerPosition(line: 0, utf16Column: 19), - rootURL: harness.root + position: LanguageServerPosition(line: 0, utf16Column: 19) ) { completionResult = $0 } harness.core.enqueueRequestFailure( From 4f2c47ee37c3b18f6ee35aab55d2f61d14c7e2b9 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Thu, 13 Aug 2026 20:22:50 +0800 Subject: [PATCH 02/10] fix: make editor navigation transactional and non-blocking --- .../Application/DocumentFeatureModel.swift | 61 ++-- .../EditorNavigationFeatureModel.swift | 51 ++++ .../Lithe/Core/Ports/LanguageTooling.swift | 4 +- .../Lithe/Models/AppModel+Development.swift | 37 +-- Sources/Lithe/Models/AppModel.swift | 50 ++- .../Lithe/Models/JavaNavigationModels.swift | 53 +++- Sources/Lithe/Views/CodeEditorView.swift | 285 ++++++++++++++++-- Tests/LitheTests/EditorNavigationTests.swift | 167 ++++++++++ Tests/LitheTests/LitheCoreLogicTests.swift | 68 ++++- 9 files changed, 684 insertions(+), 92 deletions(-) create mode 100644 Sources/Lithe/Application/EditorNavigationFeatureModel.swift create mode 100644 Tests/LitheTests/EditorNavigationTests.swift diff --git a/Sources/Lithe/Application/DocumentFeatureModel.swift b/Sources/Lithe/Application/DocumentFeatureModel.swift index 208153181..13df87261 100644 --- a/Sources/Lithe/Application/DocumentFeatureModel.swift +++ b/Sources/Lithe/Application/DocumentFeatureModel.swift @@ -27,7 +27,12 @@ final class DocumentFeatureModel: ObservableObject { private var onDocumentCollectionChanged: (@MainActor () -> Void)? private var onProjectCloseReady: (@MainActor () -> Void)? private var autoSaveTasks: [UUID: Task] = [:] - private var pendingFileOpenRequests: [String: UUID] = [:] + private struct PendingFileRead { + let id: UUID + let task: Task + } + + private var pendingFileReads: [String: PendingFileRead] = [:] private var latestFileOpenRequestID: UUID? private var pendingCloseQueue: [EditorDocument] = [] private var pendingClosePreferredDocumentID: UUID? @@ -84,7 +89,8 @@ final class DocumentFeatureModel: ObservableObject { func reset() { autoSaveTasks.values.forEach { $0.cancel() } autoSaveTasks.removeAll() - pendingFileOpenRequests.removeAll() + pendingFileReads.values.forEach { $0.task.cancel() } + pendingFileReads.removeAll() latestFileOpenRequestID = nil pendingCloseDocument = nil pendingCloseQueue = [] @@ -120,12 +126,13 @@ final class DocumentFeatureModel: ObservableObject { ) } } + @discardableResult func openFileAsync( _ normalizedURL: URL, isReadOnly: Bool, displayPath: String?, activateWhenReady: Bool - ) async { + ) async -> EditorDocument? { if let existing = openDocuments.first(where: { $0.url == normalizedURL }) { if activateWhenReady { let requestID = UUID() @@ -135,32 +142,39 @@ final class DocumentFeatureModel: ObservableObject { if !isReadOnly { onDocumentOpened?(existing) } - return + return existing } let requestID = UUID() - guard pendingFileOpenRequests[normalizedURL.path] == nil else { return } - pendingFileOpenRequests[normalizedURL.path] = requestID if activateWhenReady { latestFileOpenRequestID = requestID } - defer { - if pendingFileOpenRequests[normalizedURL.path] == requestID { - pendingFileOpenRequests[normalizedURL.path] = nil - } - } guard let workspaceURLProvider, let openingWorkspaceURL = workspaceURLProvider(), let relativePath = workspaceRelativePath(for: normalizedURL, root: openingWorkspaceURL) else { notify?("This file is outside the current workspace") - return + return nil } - let operations = self.operations - let text = await Task.detached(priority: .userInitiated) { - operations.readFile(at: openingWorkspaceURL, relativePath: relativePath) - }.value + let path = normalizedURL.path + let pendingRead: PendingFileRead + if let existingRead = pendingFileReads[path] { + pendingRead = existingRead + } else { + let operations = self.operations + pendingRead = PendingFileRead( + id: UUID(), + task: Task.detached(priority: .userInitiated) { + operations.readFile(at: openingWorkspaceURL, relativePath: relativePath) + } + ) + pendingFileReads[path] = pendingRead + } + let text = await pendingRead.task.value + if pendingFileReads[path]?.id == pendingRead.id { + pendingFileReads[path] = nil + } guard let text else { // `file.read` accepts plain text regardless of suffix and rejects // binary content. Only after that path fails do we probe a small @@ -178,12 +192,19 @@ final class DocumentFeatureModel: ObservableObject { url: normalizedURL, header: header ) { - return + return nil } notify?("This file cannot be displayed as text") - return + return nil + } + guard workspaceURLProvider() == openingWorkspaceURL else { return nil } + + if let existing = openDocuments.first(where: { $0.url == normalizedURL }) { + if activateWhenReady, latestFileOpenRequestID == requestID { + activeDocumentID = existing.id + } + return existing } - guard workspaceURLProvider() == openingWorkspaceURL else { return } let document = EditorDocument( url: normalizedURL, @@ -192,13 +213,13 @@ final class DocumentFeatureModel: ObservableObject { isReadOnly: isReadOnly, displayPath: displayPath ) - guard !openDocuments.contains(where: { $0.url == normalizedURL }) else { return } openDocuments.append(document) if activateWhenReady, latestFileOpenRequestID == requestID { activeDocumentID = document.id } onDocumentCollectionChanged?() onDocumentOpened?(document) + return document } func openVirtualDocument( diff --git a/Sources/Lithe/Application/EditorNavigationFeatureModel.swift b/Sources/Lithe/Application/EditorNavigationFeatureModel.swift new file mode 100644 index 000000000..511756fac --- /dev/null +++ b/Sources/Lithe/Application/EditorNavigationFeatureModel.swift @@ -0,0 +1,51 @@ +import Combine +import Foundation + +/// Coordinates source navigation as a transaction. A target is published only +/// after its document is active, so the editor never tries to reveal a range in +/// a view that is about to be replaced. +@MainActor +final class EditorNavigationFeatureModel: ObservableObject { + @Published private(set) var target: EditorNavigationTarget? + @Published private(set) var isNavigating = false + + private var transactionTask: Task? + private var transactionRevision: UInt64 = 0 + + func navigate( + to target: EditorNavigationTarget, + activateDocument: @escaping @MainActor () async -> Bool + ) { + transactionTask?.cancel() + transactionRevision &+= 1 + let revision = transactionRevision + isNavigating = true + + transactionTask = Task { @MainActor [weak self] in + let didActivate = await activateDocument() + guard let self, + !Task.isCancelled, + self.transactionRevision == revision else { return } + self.isNavigating = false + guard didActivate else { return } + self.target = target + } + } + + /// Used for virtual documents that are resolved through a provider callback + /// and are already active by the time their content reaches the application. + func reveal(_ target: EditorNavigationTarget) { + transactionTask?.cancel() + transactionRevision &+= 1 + isNavigating = false + self.target = target + } + + func reset() { + transactionTask?.cancel() + transactionTask = nil + transactionRevision &+= 1 + isNavigating = false + target = nil + } +} diff --git a/Sources/Lithe/Core/Ports/LanguageTooling.swift b/Sources/Lithe/Core/Ports/LanguageTooling.swift index 9c40b58c7..383abf702 100644 --- a/Sources/Lithe/Core/Ports/LanguageTooling.swift +++ b/Sources/Lithe/Core/Ports/LanguageTooling.swift @@ -194,12 +194,12 @@ struct LanguageProviderCatalog: Sendable { } } -struct LanguageServerPosition: Equatable, Sendable { +struct LanguageServerPosition: Equatable, Hashable, Sendable { let line: Int let utf16Column: Int } -struct LanguageServerRange: Equatable, Sendable { +struct LanguageServerRange: Equatable, Hashable, Sendable { let start: LanguageServerPosition let end: LanguageServerPosition } diff --git a/Sources/Lithe/Models/AppModel+Development.swift b/Sources/Lithe/Models/AppModel+Development.swift index f0a880f07..6c793a3ec 100644 --- a/Sources/Lithe/Models/AppModel+Development.swift +++ b/Sources/Lithe/Models/AppModel+Development.swift @@ -60,23 +60,21 @@ extension AppModel { func openMavenIssue(_ issue: MavenBuildIssue) { guard let fileURL = issue.fileURL, workspaceFeature.fileExists(at: fileURL) else { return } - openFile(fileURL) - editorNavigationTarget = EditorNavigationTarget( + navigateEditor(to: EditorNavigationTarget( url: fileURL.standardizedFileURL, line: max(0, (issue.line ?? 1) - 1), utf16Column: max(0, (issue.column ?? 1) - 1) - ) + )) } /// 打开源码文件并定位到指定行/列(供构建输出、运行堆栈等可点击文本跳转)。 func openSourceLocation(url: URL, line: Int, column: Int?) { guard workspaceFeature.fileExists(at: url) else { return } - openFile(url) - editorNavigationTarget = EditorNavigationTarget( + navigateEditor(to: EditorNavigationTarget( url: url.standardizedFileURL, line: max(0, line - 1), utf16Column: max(0, (column ?? 1) - 1) - ) + )) } func toggleProblems() { @@ -93,12 +91,11 @@ extension AppModel { func openDiagnostic(_ diagnostic: EditorDiagnostic) { guard workspaceFeature.fileExists(at: diagnostic.fileURL) else { return } - openFile(diagnostic.fileURL) - editorNavigationTarget = EditorNavigationTarget( + navigateEditor(to: EditorNavigationTarget( url: diagnostic.fileURL.standardizedFileURL, line: diagnostic.line, utf16Column: diagnostic.utf16Column - ) + )) } func selectRunConfiguration(_ configuration: RunConfiguration) { @@ -437,11 +434,10 @@ extension AppModel { text: text, displayPath: location.displayPath ) - self.editorNavigationTarget = EditorNavigationTarget( + self.editorNavigationFeature.reveal(EditorNavigationTarget( url: location.url, - line: location.line, - utf16Column: location.utf16Column - ) + range: location.range + )) case .failure(let error): self.showNotification(error.localizedDescription) } @@ -452,16 +448,14 @@ extension AppModel { } return } - openFile( - location.url, + navigateEditor( + to: EditorNavigationTarget( + url: location.url.standardizedFileURL, + range: location.range + ), isReadOnly: location.isReadOnly, displayPath: location.displayPath ) - editorNavigationTarget = EditorNavigationTarget( - url: location.url.standardizedFileURL, - line: location.line, - utf16Column: location.utf16Column - ) } func closeLanguageNavigationResults() { @@ -897,8 +891,7 @@ extension AppModel { let locations = values.map { LanguageNavigationLocation( url: $0.url, - line: $0.range.start.line, - utf16Column: $0.range.start.utf16Column, + range: $0.range, isReadOnly: $0.isReadOnly, displayPath: $0.displayPath ) diff --git a/Sources/Lithe/Models/AppModel.swift b/Sources/Lithe/Models/AppModel.swift index 0e7bb258c..37ef8342b 100644 --- a/Sources/Lithe/Models/AppModel.swift +++ b/Sources/Lithe/Models/AppModel.swift @@ -82,7 +82,7 @@ final class AppModel: ObservableObject, Identifiable { @Published var languageNavigationResultKind: LanguageNavigationResultKind = .definitions @Published var isLoadingLanguageNavigation = false @Published var editorCaret: EditorCaret? - @Published var editorNavigationTarget: EditorNavigationTarget? + var editorNavigationTarget: EditorNavigationTarget? { editorNavigationFeature.target } var javaCodeVisionHints: [URL: [JavaCodeVisionHint]] { javaFeature.javaCodeVisionHints } @@ -114,6 +114,7 @@ final class AppModel: ObservableObject, Identifiable { let projectHistoryFeature: ProjectHistoryFeatureModel let gitFeature: GitFeatureModel let documentFeature: DocumentFeatureModel + let editorNavigationFeature: EditorNavigationFeatureModel let javaFeature: JavaFeatureModel let databaseFeature: DatabaseFeatureModel var workspaceFileOperations: any WorkspaceFileOperations { services.fileOperations } @@ -133,6 +134,7 @@ final class AppModel: ObservableObject, Identifiable { private var terminalFeatureObservation: AnyCancellable? private var projectHistoryFeatureObservation: AnyCancellable? private var databaseFeatureObservation: AnyCancellable? + private var editorNavigationFeatureObservation: AnyCancellable? var detectedCodexConfiguration: CodexConfigurationSnapshot? { detectedAIConfigurations.first { $0.source == .codex } @@ -284,6 +286,7 @@ final class AppModel: ObservableObject, Identifiable { fileStorage: services.fileStorage, binaryFileViewerRegistry: services.binaryFileViewerRegistry ) + editorNavigationFeature = EditorNavigationFeatureModel() javaFeature = JavaFeatureModel( operations: services.javaMavenOperations, workspaceOperations: services.workspaceOperations @@ -467,6 +470,9 @@ final class AppModel: ObservableObject, Identifiable { documentFeatureObservation = documentFeature.objectWillChange.sink { [weak self] _ in self?.scheduleObjectWillChangeRelay() } + editorNavigationFeatureObservation = editorNavigationFeature.objectWillChange.sink { [weak self] _ in + self?.scheduleObjectWillChangeRelay() + } javaFeature.configure( documentProvider: { [weak self] in self?.activeDocument }, caretProvider: { [weak self] in self?.editorCaret }, @@ -745,7 +751,7 @@ final class AppModel: ObservableObject, Identifiable { isTestsVisible = false isDebugVisible = false editorCaret = nil - editorNavigationTarget = nil + editorNavigationFeature.reset() blameVisibleURL = nil gitFeature.reset() documentFeature.reset() @@ -824,7 +830,7 @@ final class AppModel: ObservableObject, Identifiable { genericDebugFeature.reset() javaFeature.stop() editorCaret = nil - editorNavigationTarget = nil + editorNavigationFeature.reset() blameVisibleURL = nil gitLogSearchQuery = "" projectItemEditRequest = nil @@ -869,6 +875,29 @@ final class AppModel: ObservableObject, Identifiable { documentFeature.openFile(url, isReadOnly: isReadOnly, displayPath: displayPath) } + /// Opens and activates the destination before publishing its reveal range. + /// Newer requests supersede older file reads without letting stale results + /// pull focus back to an earlier destination. + func navigateEditor( + to target: EditorNavigationTarget, + isReadOnly: Bool = false, + displayPath: String? = nil + ) { + selectedChange = nil + closeBranchComparison() + let normalizedURL = target.url.standardizedFileURL + let normalizedTarget = EditorNavigationTarget(url: normalizedURL, range: target.range) + editorNavigationFeature.navigate(to: normalizedTarget) { [documentFeature] in + guard let document = await documentFeature.openFileAsync( + normalizedURL, + isReadOnly: isReadOnly, + displayPath: displayPath, + activateWhenReady: true + ) else { return false } + return documentFeature.activeDocumentID == document.id + } + } + func javaIconKind(for url: URL) async -> LitheIconKind? { await workspaceFeature.javaIconKind(for: url) } @@ -1223,13 +1252,14 @@ final class AppModel: ObservableObject, Identifiable { } func openSearchResult(_ result: FileSearchResult) { - openFile(result.url) if let line = result.line { - editorNavigationTarget = EditorNavigationTarget( + navigateEditor(to: EditorNavigationTarget( url: result.url, line: line - 1, utf16Column: 0 - ) + )) + } else { + openFile(result.url) } } @@ -1272,8 +1302,12 @@ final class AppModel: ObservableObject, Identifiable { } func updateFindState(currentIndex: Int, count: Int) { - findMatchCount = count - currentFindMatchIndex = currentIndex + if findMatchCount != count { + findMatchCount = count + } + if currentFindMatchIndex != currentIndex { + currentFindMatchIndex = currentIndex + } } func selectChange(_ change: GitChange) { diff --git a/Sources/Lithe/Models/JavaNavigationModels.swift b/Sources/Lithe/Models/JavaNavigationModels.swift index 1d33cfd6b..e5a616d35 100644 --- a/Sources/Lithe/Models/JavaNavigationModels.swift +++ b/Sources/Lithe/Models/JavaNavigationModels.swift @@ -9,14 +9,31 @@ struct EditorCaret: Equatable { struct EditorNavigationTarget: Equatable, Identifiable { let id = UUID() let url: URL - let line: Int - let utf16Column: Int + let range: LanguageServerRange + + init(url: URL, range: LanguageServerRange) { + self.url = url + self.range = range + } + + init(url: URL, line: Int, utf16Column: Int) { + let position = LanguageServerPosition( + line: max(0, line), + utf16Column: max(0, utf16Column) + ) + self.init( + url: url, + range: LanguageServerRange(start: position, end: position) + ) + } + + var line: Int { range.start.line } + var utf16Column: Int { range.start.utf16Column } } struct LanguageNavigationLocation: Identifiable, Hashable, Sendable { let url: URL - let line: Int - let utf16Column: Int + let range: LanguageServerRange let isReadOnly: Bool let displayPath: String? @@ -26,15 +43,37 @@ struct LanguageNavigationLocation: Identifiable, Hashable, Sendable { utf16Column: Int, isReadOnly: Bool = false, displayPath: String? = nil + ) { + let position = LanguageServerPosition( + line: max(0, line), + utf16Column: max(0, utf16Column) + ) + self.init( + url: url, + range: LanguageServerRange(start: position, end: position), + isReadOnly: isReadOnly, + displayPath: displayPath + ) + } + + init( + url: URL, + range: LanguageServerRange, + isReadOnly: Bool = false, + displayPath: String? = nil ) { self.url = url - self.line = line - self.utf16Column = utf16Column + self.range = range self.isReadOnly = isReadOnly self.displayPath = displayPath } - var id: String { "\(url.path):\(line):\(utf16Column)" } + var line: Int { range.start.line } + var utf16Column: Int { range.start.utf16Column } + + var id: String { + "\(url.path):\(range.start.line):\(range.start.utf16Column):\(range.end.line):\(range.end.utf16Column)" + } var displayName: String { displayPath?.split(separator: "/").last.map(String.init) ?? url.lastPathComponent } } diff --git a/Sources/Lithe/Views/CodeEditorView.swift b/Sources/Lithe/Views/CodeEditorView.swift index 8564442da..23a698de3 100644 --- a/Sources/Lithe/Views/CodeEditorView.swift +++ b/Sources/Lithe/Views/CodeEditorView.swift @@ -16,6 +16,7 @@ fileprivate struct CodeEditorPalette { var currentLine: NSColor { color(light: (0, 0, 0, 0.035), dark: (1, 1, 1, 0.035)) } var bracket: NSColor { color(light: (0.18, 0.43, 0.79, 0.19), dark: (0.72, 0.72, 0.72, 0.22)) } var symbol: NSColor { color(light: (0.18, 0.43, 0.79, 0.11), dark: (0.68, 0.68, 0.68, 0.14)) } + var navigationHighlight: NSColor { themeColor(.accent).withAlphaComponent(isDark ? 0.24 : 0.16) } var guide: NSColor { themeColor(.guide) } var activeGuide: NSColor { themeColor(.activeGuide) } var unusedCode: NSColor { color(light: (0.48, 0.49, 0.52, 1), dark: (0.48, 0.48, 0.48, 1)) } @@ -51,6 +52,32 @@ fileprivate struct CodeEditorPalette { } } +/// Pure viewport policy used by source navigation. Targets already comfortably +/// visible stay put; off-screen targets settle slightly above center so the +/// declaration body remains visible below the caret. +struct EditorNavigationViewport { + static func destinationY( + currentY: CGFloat, + viewportHeight: CGFloat, + contentHeight: CGFloat, + targetMinY: CGFloat, + targetHeight: CGFloat, + edgePadding: CGFloat = 36, + anchorRatio: CGFloat = 0.38 + ) -> CGFloat { + guard viewportHeight > 0 else { return max(0, currentY) } + let targetMaxY = targetMinY + max(1, targetHeight) + let visibleMinY = currentY + edgePadding + let visibleMaxY = currentY + viewportHeight - edgePadding + if targetMinY >= visibleMinY, targetMaxY <= visibleMaxY { + return currentY + } + + let desired = targetMinY + max(1, targetHeight) / 2 - viewportHeight * anchorRatio + return min(max(0, desired), max(0, contentHeight - viewportHeight)) + } +} + struct CodeEditorView: NSViewRepresentable { @Environment(\.colorScheme) private var colorScheme @EnvironmentObject private var model: AppModel @@ -140,8 +167,8 @@ struct CodeEditorView: NSViewRepresentable { textView.onFindRequested = { [weak model] in model?.showFindBar() } textView.onFindNextRequested = { [weak model] in model?.navigateFind(offset: 1) } textView.onFindPreviousRequested = { [weak model] in model?.navigateFind(offset: -1) } - textView.onFindStateChange = { [weak model] index, count in - model?.updateFindState(currentIndex: index, count: count) + textView.onFindStateChange = { [weak coordinator = context.coordinator] index, count in + coordinator?.scheduleFindStateUpdate(currentIndex: index, count: count) } textView.isLanguageIntelligenceEnabled = !textView.languageServerFeatures.intersection([ .hover, .completion, .rename, .formatting, .codeActions @@ -193,7 +220,7 @@ struct CodeEditorView: NSViewRepresentable { context.coordinator.highlight() textView.updateEditorDecorations() context.coordinator.refreshFoldRegions(useDefaultImportFold: true) - context.coordinator.updateCaret() + context.coordinator.scheduleCaretUpdate() container.scrollView = scrollView container.gutter = gutter container.gutterWidthConstraint = gutterWidthConstraint @@ -255,7 +282,7 @@ struct CodeEditorView: NSViewRepresentable { } context.coordinator.updateCodeVisionAndBlame() context.coordinator.updateDiagnostics() - context.coordinator.applyNavigationTargetIfNeeded() + context.coordinator.scheduleNavigationTargetIfNeeded() if let codeTextView = textView as? CodeTextView { codeTextView.syncFindState(isVisible: model.isFindBarVisible, query: model.findBarQuery) } @@ -286,6 +313,12 @@ struct CodeEditorView: NSViewRepresentable { private var markdownScrollObserver: NSObjectProtocol? private var isApplyingSynchronizedMarkdownScroll = false private var lastObservedMarkdownScrollRevision: UInt64? + private var navigationHighlightTask: Task? + private var navigationApplicationTask: Task? + private var scheduledNavigationTargetID: UUID? + private var caretUpdateTask: Task? + private var findStateUpdateTask: Task? + private var pendingFindState: (currentIndex: Int, count: Int)? init( document: EditorDocument, @@ -301,6 +334,10 @@ struct CodeEditorView: NSViewRepresentable { } deinit { + navigationHighlightTask?.cancel() + navigationApplicationTask?.cancel() + caretUpdateTask?.cancel() + findStateUpdateTask?.cancel() if let markdownImagePasteMonitor { NSEvent.removeMonitor(markdownImagePasteMonitor) } @@ -478,14 +515,14 @@ struct CodeEditorView: NSViewRepresentable { refreshFoldRegions(useDefaultImportFold: false) gutter?.needsDisplay = true isApplyingEditorChange = false - updateCaret() + scheduleCaretUpdate() } func textViewDidChangeSelection(_ notification: Notification) { (textView as? CodeTextView)?.updateEditorDecorations() textView?.needsDisplay = true gutter?.needsDisplay = true - updateCaret() + scheduleCaretUpdate() } func highlight() { @@ -598,60 +635,179 @@ struct CodeEditorView: NSViewRepresentable { ) } - func applyNavigationTargetIfNeeded() { - guard let textView, let document, let target = model?.editorNavigationTarget, + func scheduleNavigationTargetIfNeeded() { + guard let document, + let target = model?.editorNavigationTarget, target.url.standardizedFileURL == document.url.standardizedFileURL, - appliedNavigationTargetID != target.id else { return } + appliedNavigationTargetID != target.id, + scheduledNavigationTargetID != target.id else { return } + + navigationApplicationTask?.cancel() + scheduledNavigationTargetID = target.id + navigationApplicationTask = Task { @MainActor [weak self] in + await Task.yield() + guard let self, !Task.isCancelled else { return } + guard self.model?.editorNavigationTarget?.id == target.id, + self.document?.url.standardizedFileURL == target.url.standardizedFileURL else { + if self.scheduledNavigationTargetID == target.id { + self.scheduledNavigationTargetID = nil + } + return + } + self.scheduledNavigationTargetID = nil + self.applyNavigationTarget(target) + } + } + + private func applyNavigationTarget(_ target: EditorNavigationTarget) { + guard let textView = textView as? CodeTextView else { return } appliedNavigationTargetID = target.id let text = textView.string as NSString - var lineStart = 0 - var currentLine = 0 - while currentLine < target.line, lineStart < text.length { - let range = text.lineRange(for: NSRange(location: lineStart, length: 0)) - lineStart = NSMaxRange(range) - currentLine += 1 - } - let lineRange = text.lineRange(for: NSRange(location: min(lineStart, text.length), length: 0)) - let location = min(NSMaxRange(lineRange), lineStart + target.utf16Column) + let targetRange = textView.navigationCharacterRange(for: target.range, in: text) + let location = min(targetRange.location, text.length) + let revealRange = navigationRevealRange(targetRange, in: text) textView.setSelectedRange(NSRange(location: location, length: 0)) - textView.scrollRangeToVisible(NSRange(location: location, length: 0)) + revealNavigationRange(revealRange, in: textView) + showNavigationHighlight(revealRange, in: textView) textView.window?.makeFirstResponder(textView) - updateCaret() + scheduleCaretUpdate() + } + + private func navigationRevealRange(_ range: NSRange, in text: NSString) -> NSRange { + guard text.length > 0 else { return NSRange(location: 0, length: 0) } + if range.length > 0 { return range } + let location = min(range.location, text.length - 1) + let lineRange = text.lineRange(for: NSRange(location: location, length: 0)) + var end = NSMaxRange(lineRange) + while end > lineRange.location, [10, 13].contains(text.character(at: end - 1)) { + end -= 1 + } + return NSRange( + location: lineRange.location, + length: max(1, end - lineRange.location) + ) + } + + private func revealNavigationRange(_ range: NSRange, in textView: CodeTextView) { + guard let layoutManager = textView.layoutManager, + let textContainer = textView.textContainer, + let scrollView = textView.enclosingScrollView else { + textView.scrollRangeToVisible(range) + return + } + + layoutManager.ensureLayout(forCharacterRange: range) + let glyphRange = layoutManager.glyphRange( + forCharacterRange: range, + actualCharacterRange: nil + ) + var targetRect = layoutManager.boundingRect( + forGlyphRange: glyphRange, + in: textContainer + ) + targetRect.origin.x += textView.textContainerOrigin.x + targetRect.origin.y += textView.textContainerOrigin.y + + let clipView = scrollView.contentView + let destinationY = EditorNavigationViewport.destinationY( + currentY: clipView.bounds.origin.y, + viewportHeight: clipView.bounds.height, + contentHeight: textView.bounds.height, + targetMinY: targetRect.minY, + targetHeight: targetRect.height + ) + guard abs(destinationY - clipView.bounds.origin.y) > 0.5 else { return } + let destination = NSPoint(x: clipView.bounds.origin.x, y: destinationY) + guard !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion else { + clipView.setBoundsOrigin(destination) + scrollView.reflectScrolledClipView(clipView) + return + } + + let distance = abs(destinationY - clipView.bounds.origin.y) + NSAnimationContext.runAnimationGroup { context in + context.duration = distance > clipView.bounds.height * 6 ? 0.12 : 0.18 + context.allowsImplicitAnimation = true + clipView.animator().setBoundsOrigin(destination) + } + } + + private func showNavigationHighlight(_ range: NSRange, in textView: CodeTextView) { + navigationHighlightTask?.cancel() + let palette = CodeEditorPalette(isDark: isDarkAppearance, theme: colorTheme) + textView.showNavigationHighlight(range, color: palette.navigationHighlight) + navigationHighlightTask = Task { @MainActor [weak textView] in + try? await Task.sleep(nanoseconds: 700_000_000) + guard !Task.isCancelled else { return } + textView?.clearNavigationHighlight() + } + } + + func scheduleCaretUpdate() { + caretUpdateTask?.cancel() + caretUpdateTask = Task { @MainActor [weak self] in + await Task.yield() + guard let self, !Task.isCancelled else { return } + self.caretUpdateTask = nil + self.updateCaret() + } + } + + func scheduleFindStateUpdate(currentIndex: Int, count: Int) { + pendingFindState = (currentIndex, count) + guard findStateUpdateTask == nil else { return } + findStateUpdateTask = Task { @MainActor [weak self] in + await Task.yield() + guard let self, !Task.isCancelled else { return } + self.findStateUpdateTask = nil + guard let state = self.pendingFindState else { return } + self.pendingFindState = nil + self.model?.updateFindState( + currentIndex: state.currentIndex, + count: state.count + ) + } } - func updateCaret() { + private func updateCaret() { guard let textView, let document else { return } let text = textView.string as NSString updateSelectedText(in: text, range: textView.selectedRange()) let location = min(textView.selectedRange().location, text.length) - let prefix = text.substring(to: location) as NSString - var line = 0 - var lineStart = 0 - for index in 0.. 0, NSMaxRange(range) <= text.length else { - model?.editorSelectedText = "" + if model?.editorSelectedText != "" { + model?.editorSelectedText = "" + } return } let selected = text.substring(with: range) guard !selected.contains("\n"), !selected.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - model?.editorSelectedText = "" + if model?.editorSelectedText != "" { + model?.editorSelectedText = "" + } return } - model?.editorSelectedText = selected + updatedText = selected + if model?.editorSelectedText != updatedText { + model?.editorSelectedText = updatedText + } } } } @@ -754,6 +910,12 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { private var trackingArea: NSTrackingArea? private var hoveredFoldID: String? private var lineIndex = TextLineIndex(source: "" as NSString) + private var navigationHighlightRange: NSRange? + private var navigationHighlightColor = CodeEditorPalette.dark.navigationHighlight + private var contentRevision: UInt64 = 0 + private var lastSyncedFindVisibility: Bool? + private var lastSyncedFindQuery: String? + private var lastSyncedFindContentRevision: UInt64? nonisolated(unsafe) private var windowResignObserver: NSObjectProtocol? fileprivate func applyAppearance(_ palette: CodeEditorPalette) { @@ -807,6 +969,7 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { func rebuildLineIndex() { lineIndex = TextLineIndex(source: string as NSString) + contentRevision &+= 1 } func characterOffset(forLine targetLine: Int, in _: NSString) -> Int { @@ -821,6 +984,35 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { lineIndex.lineRange(forLine: line) } + func navigationCharacterRange( + for range: LanguageServerRange, + in source: NSString + ) -> NSRange { + let start = characterOffset( + forLine: range.start.line, + column: range.start.utf16Column, + in: source + ) + let end = characterOffset( + forLine: max(range.start.line, range.end.line), + column: range.end.utf16Column, + in: source + ) + return NSRange(location: start, length: max(0, end - start)) + } + + func showNavigationHighlight(_ range: NSRange, color: NSColor) { + navigationHighlightRange = range + navigationHighlightColor = color + needsDisplay = true + } + + func clearNavigationHighlight() { + guard navigationHighlightRange != nil else { return } + navigationHighlightRange = nil + needsDisplay = true + } + func updateDiagnostics(_ diagnostics: [EditorDiagnostic]) { self.diagnostics = diagnostics updateEditorDecorations() @@ -963,6 +1155,12 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { /// 文档或查询变化时同步 Find Bar 状态;Find Bar 关闭时仅清理已有高亮。 func syncFindState(isVisible: Bool, query: String) { + guard lastSyncedFindVisibility != isVisible + || lastSyncedFindQuery != query + || lastSyncedFindContentRevision != contentRevision else { return } + lastSyncedFindVisibility = isVisible + lastSyncedFindQuery = query + lastSyncedFindContentRevision = contentRevision if isVisible { updateFindMatches(query: query) } else if !findMatchRanges.isEmpty { @@ -1249,9 +1447,32 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { override func drawBackground(in rect: NSRect) { super.drawBackground(in: rect) + drawNavigationHighlight(in: rect) drawIndentGuides(in: rect) } + private func drawNavigationHighlight(in dirtyRect: NSRect) { + guard let range = navigationHighlightRange, + range.length > 0, + let layoutManager, + let textContainer else { return } + let glyphRange = layoutManager.glyphRange( + forCharacterRange: range, + actualCharacterRange: nil + ) + var highlightRect = layoutManager.boundingRect( + forGlyphRange: glyphRange, + in: textContainer + ) + highlightRect.origin.x += textContainerOrigin.x - 3 + highlightRect.origin.y += textContainerOrigin.y - 1 + highlightRect.size.width += 6 + highlightRect.size.height += 2 + guard highlightRect.intersects(dirtyRect) else { return } + navigationHighlightColor.setFill() + NSBezierPath(roundedRect: highlightRect, xRadius: 3, yRadius: 3).fill() + } + private func lineFragmentRect( forLine line: Int, in source: NSString, diff --git a/Tests/LitheTests/EditorNavigationTests.swift b/Tests/LitheTests/EditorNavigationTests.swift new file mode 100644 index 000000000..01769c844 --- /dev/null +++ b/Tests/LitheTests/EditorNavigationTests.swift @@ -0,0 +1,167 @@ +import AppKit +import Foundation +import Testing +@testable import Lithe + +@Suite("Editor navigation") +struct EditorNavigationTests { + @Test + @MainActor + func targetIsPublishedOnlyAfterItsDocumentActivates() async { + let feature = EditorNavigationFeatureModel() + let gate = NavigationActivationGate() + let target = EditorNavigationTarget( + url: URL(fileURLWithPath: "/workspace/Target.swift"), + line: 12, + utf16Column: 4 + ) + + feature.navigate(to: target) { + await gate.wait() + return true + } + await Task.yield() + + #expect(feature.isNavigating) + #expect(feature.target == nil) + + await gate.open() + await waitUntil { feature.target != nil } + + #expect(!feature.isNavigating) + #expect(feature.target?.url == target.url) + #expect(feature.target?.range == target.range) + } + + @Test + @MainActor + func newerNavigationCannotBeOverwrittenByAStaleCompletion() async { + let feature = EditorNavigationFeatureModel() + let staleGate = NavigationActivationGate() + let staleTarget = EditorNavigationTarget( + url: URL(fileURLWithPath: "/workspace/Stale.swift"), + line: 1, + utf16Column: 0 + ) + let latestTarget = EditorNavigationTarget( + url: URL(fileURLWithPath: "/workspace/Latest.swift"), + line: 8, + utf16Column: 2 + ) + + feature.navigate(to: staleTarget) { + await staleGate.wait() + return true + } + feature.navigate(to: latestTarget) { true } + await waitUntil { feature.target?.url == latestTarget.url } + + await staleGate.open() + for _ in 0..<10 { await Task.yield() } + + #expect(feature.target?.url == latestTarget.url) + #expect(feature.target?.line == 8) + } + + @Test + func viewportKeepsComfortablyVisibleTargetsStable() { + let destination = EditorNavigationViewport.destinationY( + currentY: 400, + viewportHeight: 600, + contentHeight: 3_000, + targetMinY: 650, + targetHeight: 20 + ) + + #expect(destination == 400) + } + + @Test + func viewportPositionsOffscreenTargetsAboveCenterAndClampsEdges() { + #expect(EditorNavigationViewport.destinationY( + currentY: 0, + viewportHeight: 500, + contentHeight: 3_000, + targetMinY: 1_500, + targetHeight: 20 + ) == 1_320) + + #expect(EditorNavigationViewport.destinationY( + currentY: 0, + viewportHeight: 500, + contentHeight: 2_200, + targetMinY: 2_180, + targetHeight: 20 + ) == 1_700) + } + + @Test + @MainActor + func lspRangeUsesUTF16OffsetsWithoutScanningFromTheDocumentStart() { + let textView = CodeTextView(frame: .zero) + textView.string = "zero\n func café😀() {}\n" + textView.rebuildLineIndex() + let range = textView.navigationCharacterRange( + for: LanguageServerRange( + start: LanguageServerPosition(line: 1, utf16Column: 2), + end: LanguageServerPosition(line: 1, utf16Column: 6) + ), + in: textView.string as NSString + ) + + #expect(range == NSRange(location: 7, length: 4)) + #expect((textView.string as NSString).substring(with: range) == "func") + } + + @Test + @MainActor + func repeatedViewUpdatesDoNotRepublishUnchangedFindState() { + let textView = CodeTextView(frame: .zero) + textView.string = "alpha beta alpha" + textView.rebuildLineIndex() + var reports: [(Int, Int)] = [] + textView.onFindStateChange = { reports.append(($0, $1)) } + + textView.syncFindState(isVisible: true, query: "alpha") + textView.syncFindState(isVisible: true, query: "alpha") + textView.syncFindState(isVisible: true, query: "alpha") + + #expect(reports.count == 1) + #expect(reports.first?.0 == 0) + #expect(reports.first?.1 == 2) + + textView.string = "alpha" + textView.rebuildLineIndex() + textView.syncFindState(isVisible: true, query: "alpha") + + #expect(reports.count == 2) + #expect(reports.last?.1 == 1) + } + + @MainActor + private func waitUntil( + _ predicate: @escaping @MainActor () -> Bool + ) async { + for _ in 0..<100 { + if predicate() { return } + await Task.yield() + } + Issue.record("Timed out waiting for navigation state") + } +} + +private actor NavigationActivationGate { + private var isOpen = false + private var continuation: CheckedContinuation? + + func wait() async { + guard !isOpen else { return } + await withCheckedContinuation { continuation = $0 } + } + + func open() { + isOpen = true + continuation?.resume() + continuation = nil + } +} diff --git a/Tests/LitheTests/LitheCoreLogicTests.swift b/Tests/LitheTests/LitheCoreLogicTests.swift index 059eac988..fbeb2209e 100644 --- a/Tests/LitheTests/LitheCoreLogicTests.swift +++ b/Tests/LitheTests/LitheCoreLogicTests.swift @@ -3231,9 +3231,67 @@ struct EditorDocumentTests { #expect(model.activeDocumentID == documentB.id) operations.releaseA() - await pendingA.value + _ = await pendingA.value #expect(model.activeDocumentID == documentB.id) } + + @Test + @MainActor + func concurrentNavigationToTheSameFileSharesOneRead() async { + let workspace = URL(fileURLWithPath: "/tmp/lithe-shared-open-tests") + let fileA = workspace.appendingPathComponent("A.swift") + let operations = BlockingWorkspaceOperations() + let model = DocumentFeatureModel( + operations: operations, + fileOperations: EmptyWorkspaceFileOperations(), + fileStorage: InMemoryFileStorage(), + binaryFileViewerRegistry: BinaryFileViewerRegistry() + ) + model.configure( + workspaceURLProvider: { workspace }, + autoSaveEnabledProvider: { false }, + autoSaveDelayProvider: { 0 }, + notify: { _ in }, + onDocumentOpened: { _ in }, + onDocumentChanged: { _ in }, + onDocumentClosed: { _ in }, + onRecordSave: { _, _ in }, + onRecordDiscard: { _ in }, + onRecordExternalChanges: { _ in }, + onDocumentCollectionChanged: {}, + onProjectCloseReady: {} + ) + + let first = Task { @MainActor in + await model.openFileAsync( + fileA, + isReadOnly: false, + displayPath: nil, + activateWhenReady: true + ) + } + for _ in 0..<100 where !operations.didStartReadingA { + await Task.yield() + } + let second = Task { @MainActor in + await model.openFileAsync( + fileA, + isReadOnly: false, + displayPath: nil, + activateWhenReady: true + ) + } + for _ in 0..<10 { await Task.yield() } + + #expect(operations.readACount == 1) + operations.releaseA() + let firstDocument = await first.value + let secondDocument = await second.value + + #expect(firstDocument?.id == secondDocument?.id) + #expect(model.openDocuments.count == 1) + #expect(model.activeDocumentID == secondDocument?.id) + } } @MainActor @@ -3507,6 +3565,7 @@ private final class BlockingWorkspaceOperations: WorkspaceOperations, @unchecked private let lock = NSLock() private let releaseASemaphore = DispatchSemaphore(value: 0) private var startedA = false + private var aReadCount = 0 var didStartReadingA: Bool { lock.lock() @@ -3514,6 +3573,12 @@ private final class BlockingWorkspaceOperations: WorkspaceOperations, @unchecked return startedA } + var readACount: Int { + lock.lock() + defer { lock.unlock() } + return aReadCount + } + func releaseA() { releaseASemaphore.signal() } @@ -3548,6 +3613,7 @@ private final class BlockingWorkspaceOperations: WorkspaceOperations, @unchecked if relativePath == "A.swift" { lock.lock() startedA = true + aReadCount += 1 lock.unlock() releaseASemaphore.wait() return "A" From 1d2c7e388c96bb879e58bc976822d204e551d4ca Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Thu, 13 Aug 2026 20:51:26 +0800 Subject: [PATCH 03/10] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=85=B6?= =?UTF-8?q?=E4=BB=96=20LSP=20=E7=BC=BA=E5=B0=91=E7=9B=B8=E5=85=B3=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E9=85=8D=E7=BD=AE=E5=8A=9F=E8=83=BD=E7=9A=84=E5=9C=B0?= =?UTF-8?q?=E6=96=B9=EF=BC=8C=E5=90=8C=E6=97=B6=E6=B7=BB=E5=8A=A0=E4=B8=80?= =?UTF-8?q?=E9=94=AE=20brew=20=E5=AE=89=E8=A3=85=E7=9A=84=E6=96=B9?= =?UTF-8?q?=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../LSPControlCenterPresentation.swift | 7 ++ .../Lithe/Views/LSPControlCenterView.swift | 90 +++++++++++++++-- .../Lithe/Views/LanguageServerSetupView.swift | 99 +++++++++++++++++-- .../LSPControlCenterPresentationTests.swift | 25 +++++ 4 files changed, 201 insertions(+), 20 deletions(-) diff --git a/Sources/Lithe/Application/LSPControlCenterPresentation.swift b/Sources/Lithe/Application/LSPControlCenterPresentation.swift index 96d72f8c6..a1924d0e2 100644 --- a/Sources/Lithe/Application/LSPControlCenterPresentation.swift +++ b/Sources/Lithe/Application/LSPControlCenterPresentation.swift @@ -18,6 +18,13 @@ enum LSPCapabilityPresentationState: Equatable, Sendable { } enum LSPControlCenterPresenter { + static func supportsToolConfiguration( + _ descriptor: LanguageProviderDescriptor + ) -> Bool { + descriptor.capabilities.contains(.languageServer) + && descriptor.languageServerLaunch != nil + } + static func serverStatus( isDisabled: Bool, sessionState: LanguageServerSessionState? diff --git a/Sources/Lithe/Views/LSPControlCenterView.swift b/Sources/Lithe/Views/LSPControlCenterView.swift index 4068c64d4..861bc7aff 100644 --- a/Sources/Lithe/Views/LSPControlCenterView.swift +++ b/Sources/Lithe/Views/LSPControlCenterView.swift @@ -13,10 +13,11 @@ struct LSPControlCenterView: View { ScrollView { VStack(alignment: .leading, spacing: 16) { projectSummary - if projectLanguageServers.isEmpty { + providerConfigurationSummary + if configurableLanguageServers.isEmpty { emptyState } else { - ForEach(projectLanguageServers) { descriptor in + ForEach(configurableLanguageServers) { descriptor in languageRow(descriptor) } } @@ -50,14 +51,49 @@ struct LSPControlCenterView: View { .font(.system(size: 14, weight: .semibold)) .foregroundStyle(LitheTheme.primaryText) Text(usesChinese - ? "仅显示当前项目使用的语言。关闭后会停止对应语言服务器并释放资源。" - : "Only languages used by this project are shown. Turning one off stops its language server and releases its resources.") + ? "内置 Provider 与项目配置合并显示。每个 LSP 都可以自动探测、指定可执行文件或通过 Brew 安装。" + : "Built-in providers are merged with project configuration. Every LSP supports automatic discovery, a custom executable, and Brew installation.") .font(.system(size: 11.5)) .foregroundStyle(LitheTheme.secondaryText) .fixedSize(horizontal: false, vertical: true) } } + private var providerConfigurationSummary: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 7) { + Image(systemName: "doc.badge.gearshape") + .foregroundStyle(LitheTheme.accent) + Text(usesChinese ? "项目 Provider 配置" : "Project provider configuration") + .font(.system(size: 11.5, weight: .semibold)) + .foregroundStyle(LitheTheme.primaryText) + Spacer(minLength: 0) + Text(catalogOriginLabel) + .font(.system(size: 9.5, weight: .medium)) + .foregroundStyle(LitheTheme.secondaryText) + .padding(.horizontal, 7) + .frame(height: 20) + .background(Capsule().fill(LitheTheme.raised)) + } + Text(".lithe/lsp/language-providers.json") + .font(.system(size: 10.5, design: .monospaced)) + .foregroundStyle(LitheTheme.primaryText) + .textSelection(.enabled) + Text(usesChinese + ? "可按 Provider ID 覆盖或新增语言,配置文件匹配、启动命令、参数、环境变量、验证参数和 Brew formula。" + : "Override providers by ID or add languages with file matching, launch commands, arguments, environment, validation, and a Brew formula.") + .font(.system(size: 10.5)) + .foregroundStyle(LitheTheme.secondaryText) + .fixedSize(horizontal: false, vertical: true) + } + .padding(12) + .background(RoundedRectangle(cornerRadius: 7).fill(LitheTheme.sidebar)) + .overlay { + RoundedRectangle(cornerRadius: 7) + .stroke(LitheTheme.panelBorder, lineWidth: 1) + } + } + private func languageRow(_ descriptor: LanguageProviderDescriptor) -> some View { let status = serverStatus(for: descriptor) let isEnabled = !model.isLanguageServerDisabledInCurrentWorkspace(providerID: descriptor.id) @@ -105,6 +141,23 @@ struct LSPControlCenterView: View { : "Enable \(descriptor.displayName) language server")) } + if configuredProviderID == descriptor.id { + Divider().overlay(LitheTheme.divider) + LanguageServerSetupView( + tools: model.languageServerTools, + providers: [descriptor], + initialProviderID: descriptor.id, + language: settings.language, + chooseExecutable: { provider in + model.chooseLanguageServerExecutable(providerName: provider.displayName) + }, + openOfficialDownload: model.openLanguageServerDownload, + configurationChanged: model.languageServerToolConfigurationDidChange, + isEmbedded: true, + showsProviderPicker: false + ) + } + if configuredProviderID == descriptor.id, descriptor.id == "java" { Divider().overlay(LitheTheme.divider) VStack(alignment: .leading, spacing: 7) { @@ -208,16 +261,28 @@ struct LSPControlCenterView: View { .foregroundStyle(LitheTheme.warning) } - private var projectLanguageServers: [LanguageProviderDescriptor] { + private var configurableLanguageServers: [LanguageProviderDescriptor] { model.languageProviderCatalog.descriptors - .filter { $0.capabilities.contains(.languageServer) && $0.languageServerLaunch != nil } - .filter { descriptor in - model.projectFiles.contains { descriptor.handles(fileURL: $0) } - } + .filter(LSPControlCenterPresenter.supportsToolConfiguration) } private func hasConfiguration(for descriptor: LanguageProviderDescriptor) -> Bool { - descriptor.id == "java" + LSPControlCenterPresenter.supportsToolConfiguration(descriptor) + } + + private func projectUses(_ descriptor: LanguageProviderDescriptor) -> Bool { + model.projectFiles.contains { descriptor.handles(fileURL: $0) } + } + + private var catalogOriginLabel: String { + switch model.languageProviderCatalogSnapshot.origin { + case .builtin: + usesChinese ? "内置默认" : "Built-in defaults" + case .workspaceOverride: + usesChinese ? "项目覆盖已加载" : "Project override loaded" + case .compatibilityFallback: + usesChinese ? "兼容回退" : "Compatibility fallback" + } } private var javaJDKDisplayPath: String { @@ -259,6 +324,11 @@ struct LSPControlCenterView: View { case .stopping: return usesChinese ? "正在停止" : "Stopping" case .disabled: return usesChinese ? "已关闭" : "Off" case .stopped: + if !projectUses(descriptor) { + return usesChinese + ? "当前项目没有匹配文件;配置仍会保存" + : "No matching project files; configuration is still saved" + } return usesChinese ? "按需启动,打开对应文件时运行" : "Starts on demand when a matching file is opened" case .error: if descriptor.id == "java" { diff --git a/Sources/Lithe/Views/LanguageServerSetupView.swift b/Sources/Lithe/Views/LanguageServerSetupView.swift index 9b937228f..5d451b240 100644 --- a/Sources/Lithe/Views/LanguageServerSetupView.swift +++ b/Sources/Lithe/Views/LanguageServerSetupView.swift @@ -9,6 +9,7 @@ struct LanguageServerSetupView: View { let openOfficialDownload: (URL) -> Void let configurationChanged: (String) -> Void let isEmbedded: Bool + let showsProviderPicker: Bool @State private var selectedProviderID: String @State private var executablePathDraft = "" @@ -22,7 +23,8 @@ struct LanguageServerSetupView: View { chooseExecutable: @escaping (LanguageProviderDescriptor) -> URL?, openOfficialDownload: @escaping (URL) -> Void, configurationChanged: @escaping (String) -> Void, - isEmbedded: Bool = false + isEmbedded: Bool = false, + showsProviderPicker: Bool = true ) { self.tools = tools self.providers = providers @@ -31,6 +33,7 @@ struct LanguageServerSetupView: View { self.openOfficialDownload = openOfficialDownload self.configurationChanged = configurationChanged self.isEmbedded = isEmbedded + self.showsProviderPicker = showsProviderPicker let initialID = initialProviderID.flatMap { id in providers.contains(where: { $0.id == id }) ? id : nil } ?? providers.first?.id ?? "" @@ -72,19 +75,19 @@ struct LanguageServerSetupView: View { setupHeader Divider().overlay(LitheTheme.divider) - ScrollView(.vertical) { - VStack(alignment: .leading, spacing: 14) { - providerPicker - detectionSection - pathSection - installSection - } + if isEmbedded { + setupSections .padding(14) + } else { + ScrollView(.vertical) { + setupSections + .padding(14) + } + .litheScrollViewChrome(hideHorizontal: true) } - .litheScrollViewChrome(hideHorizontal: true) } .frame(width: isEmbedded ? nil : 430, height: isEmbedded ? nil : 510) - .frame(maxWidth: isEmbedded ? .infinity : nil, minHeight: isEmbedded ? 430 : nil) + .frame(maxWidth: isEmbedded ? .infinity : nil) .background(LitheTheme.sidebar) .onChange(of: selectedProviderID) { providerID in executablePathDraft = tools.customExecutablePath(for: providerID) ?? "" @@ -96,6 +99,18 @@ struct LanguageServerSetupView: View { } } + private var setupSections: some View { + VStack(alignment: .leading, spacing: 14) { + if showsProviderPicker, providers.count > 1 { + providerPicker + } + launchConfigurationSection + detectionSection + pathSection + installSection + } + } + private var setupHeader: some View { HStack(spacing: 10) { Image(systemName: "wrench.and.screwdriver") @@ -182,6 +197,59 @@ struct LanguageServerSetupView: View { } } + private var launchConfigurationSection: some View { + VStack(alignment: .leading, spacing: 7) { + sectionTitle(copy.launchConfiguration) + VStack(alignment: .leading, spacing: 6) { + configurationValue( + label: copy.commands, + value: selectedDescriptor?.languageServerLaunch?.executableNames + .joined(separator: ", ") ?? copy.notConfigured + ) + configurationValue( + label: copy.arguments, + value: listValue( + selectedDescriptor?.languageServerLaunch?.arguments, + separator: " " + ) + ) + configurationValue( + label: copy.environment, + value: listValue( + selectedDescriptor?.languageServerLaunch?.environment.keys.sorted(), + separator: ", " + ) + ) + } + .padding(10) + .background(RoundedRectangle(cornerRadius: 7).fill(LitheTheme.raised.opacity(0.55))) + + Text(copy.providerConfigurationHint) + .font(.system(size: 10.5)) + .foregroundStyle(LitheTheme.secondaryText) + .fixedSize(horizontal: false, vertical: true) + } + } + + private func configurationValue(label: String, value: String) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(label) + .font(.system(size: 10.5, weight: .medium)) + .foregroundStyle(LitheTheme.secondaryText) + .frame(width: 72, alignment: .leading) + Text(value) + .font(.system(size: 10.5, design: .monospaced)) + .foregroundStyle(LitheTheme.primaryText) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private func listValue(_ values: [String]?, separator: String) -> String { + guard let values, !values.isEmpty else { return copy.none } + return values.joined(separator: separator) + } + private var pathSection: some View { VStack(alignment: .leading, spacing: 7) { HStack { @@ -386,6 +454,17 @@ private struct LanguageServerSetupCopy { var subtitle: String { usesChinese ? "安装、探测并指定 LSP 可执行文件" : "Install, detect, and select LSP executables" } var languageServer: String { usesChinese ? "语言服务器" : "Language server" } var detectedExecutable: String { usesChinese ? "当前解析结果" : "Resolved executable" } + var launchConfiguration: String { usesChinese ? "启动配置" : "Launch configuration" } + var commands: String { usesChinese ? "候选命令" : "Commands" } + var arguments: String { usesChinese ? "启动参数" : "Arguments" } + var environment: String { usesChinese ? "环境变量" : "Environment" } + var none: String { usesChinese ? "无" : "None" } + var notConfigured: String { usesChinese ? "未配置" : "Not configured" } + var providerConfigurationHint: String { + usesChinese + ? "命令、参数、环境变量和 Brew formula 可由项目的 .lithe/lsp/language-providers.json 覆盖。" + : "Commands, arguments, environment, and the Brew formula can be overridden in .lithe/lsp/language-providers.json." + } var executableFoundUnverified: String { usesChinese ? "已找到可执行文件(未验证)" : "Executable found (not verified)" } diff --git a/Tests/LitheTests/LSPControlCenterPresentationTests.swift b/Tests/LitheTests/LSPControlCenterPresentationTests.swift index 05936cb89..fdc6bb588 100644 --- a/Tests/LitheTests/LSPControlCenterPresentationTests.swift +++ b/Tests/LitheTests/LSPControlCenterPresentationTests.swift @@ -3,6 +3,31 @@ import Testing @Suite("LSP Control Center presentation") struct LSPControlCenterPresentationTests { + @Test + func everyLaunchableLanguageServerSupportsToolConfiguration() { + let configurable = LanguageProviderDescriptor( + id: "templ", + displayName: "Templ", + fileExtensions: ["templ"], + capabilities: [.languageServer], + activationPolicy: .onDemand, + languageServerLaunch: LanguageServerLaunchDescriptor( + executableNames: ["templ"], + arguments: ["lsp"] + ) + ) + let builtinOnly = LanguageProviderDescriptor( + id: "text", + displayName: "Text", + fileExtensions: ["txt"], + capabilities: [], + activationPolicy: .onDemand + ) + + #expect(LSPControlCenterPresenter.supportsToolConfiguration(configurable)) + #expect(!LSPControlCenterPresenter.supportsToolConfiguration(builtinOnly)) + } + @Test func runtimeStateAloneDeterminesServerStatus() { // Code diagnostics are deliberately unrelated to this resolver. A ready From 226da939845efbe196ed40d7089eb88f54afd611 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Thu, 13 Aug 2026 21:28:14 +0800 Subject: [PATCH 04/10] =?UTF-8?q?add:=E6=B7=BB=E5=8A=A0=20command=20+=20b?= =?UTF-8?q?=E7=9A=84=E6=90=9C=E7=B4=A2=E8=B7=B3=E8=BD=AC=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../LanguageNavigationPreview.swift | 46 ++++ .../Lithe/Models/AppModel+Development.swift | 87 +++++-- Sources/Lithe/Models/AppModel.swift | 5 +- .../Lithe/Models/JavaNavigationModels.swift | 16 ++ Sources/Lithe/Views/CodeEditorView.swift | 46 ++-- Sources/Lithe/Views/EditorAreaView.swift | 6 +- Sources/Lithe/Views/JavaReferencesView.swift | 233 ++++++++++++++---- Tests/LitheTests/EditorNavigationTests.swift | 22 ++ .../LanguageNavigationPreviewTests.swift | 37 +++ rust/lithe-core/src/lsp/interface/client.rs | 20 +- rust/lithe-core/src/lsp/tests.rs | 64 +++++ 11 files changed, 491 insertions(+), 91 deletions(-) create mode 100644 Sources/Lithe/Application/LanguageNavigationPreview.swift create mode 100644 Tests/LitheTests/LanguageNavigationPreviewTests.swift diff --git a/Sources/Lithe/Application/LanguageNavigationPreview.swift b/Sources/Lithe/Application/LanguageNavigationPreview.swift new file mode 100644 index 000000000..a82ad3ffa --- /dev/null +++ b/Sources/Lithe/Application/LanguageNavigationPreview.swift @@ -0,0 +1,46 @@ +import Foundation + +enum LanguageNavigationPreview { + private static let maximumLocationCount = 500 + + static func build( + locations: [LanguageNavigationLocation], + openSources: [URL: String], + readSource: (URL) -> String? + ) -> [String: String] { + let visibleLocations = Array(locations.prefix(maximumLocationCount)) + let grouped = Dictionary(grouping: visibleLocations) { $0.url.standardizedFileURL } + var result: [String: String] = [:] + + for url in grouped.keys.sorted(by: { $0.path < $1.path }) { + guard url.isFileURL, + WorkspaceTextFilePolicy.isReadableTextFile(url), + let source = openSources[url] ?? readSource(url), + WorkspaceTextFilePolicy.isPlainText(source) else { continue } + for location in grouped[url, default: []] { + if let preview = line(in: source, at: location.line) { + result[location.id] = preview + } + } + } + return result + } + + static func line(in source: String, at targetLine: Int) -> String? { + guard targetLine >= 0 else { return nil } + let text = source as NSString + var currentLine = 0 + var location = 0 + while location < text.length { + let range = text.lineRange(for: NSRange(location: location, length: 0)) + if currentLine == targetLine { + return text.substring(with: range) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + currentLine += 1 + location = NSMaxRange(range) + } + if targetLine == 0, text.length == 0 { return "" } + return nil + } +} diff --git a/Sources/Lithe/Models/AppModel+Development.swift b/Sources/Lithe/Models/AppModel+Development.swift index 6c793a3ec..1d1a0195e 100644 --- a/Sources/Lithe/Models/AppModel+Development.swift +++ b/Sources/Lithe/Models/AppModel+Development.swift @@ -353,7 +353,7 @@ extension AppModel { performGenericNavigation( method: "textDocument/references", kind: .references, - navigateToSingleResult: true + presentation: .chooser ) } @@ -391,7 +391,7 @@ extension AppModel { performGenericNavigation( method: "textDocument/references", kind: .references, - navigateToSingleResult: false + presentation: .toolWindow ) } @@ -408,12 +408,12 @@ extension AppModel { performGenericNavigation( method: "textDocument/implementation", kind: .implementations, - navigateToSingleResult: false + presentation: .chooser ) } func navigate(to location: LanguageNavigationLocation) { - isImplementationChooserVisible = false + isLanguageNavigationChooserVisible = false guard location.url.isFileURL else { guard let providerID = languageNavigationProviderID else { showNotification("The virtual source provider is no longer available") @@ -460,13 +460,16 @@ extension AppModel { func closeLanguageNavigationResults() { isReferencesVisible = false - isImplementationChooserVisible = false + isLanguageNavigationChooserVisible = false clearLanguageNavigationProjection() } func clearLanguageNavigationProjection() { languageNavigationProviderID = nil languageNavigationLocations = [] + languageNavigationPreviews = [:] + languageNavigationSubject = "" + languageNavigationPreviewRequestID = UUID() isLoadingLanguageNavigation = false } @@ -777,7 +780,7 @@ extension AppModel { private func performGenericNavigation( method: String, kind: LanguageNavigationResultKind, - navigateToSingleResult: Bool = true, + presentation: LanguageNavigationPresentation = .navigateSingle, fallbackToImplementationsIfSelf: Bool = false ) { guard !isLoadingLanguageNavigation, @@ -790,6 +793,12 @@ extension AppModel { return } isLoadingLanguageNavigation = true + if presentation == .chooser { + isLanguageNavigationChooserVisible = true + languageNavigationLocations = [] + languageNavigationPreviews = [:] + languageNavigationSubject = editorSelectedText + } languageNavigationProviderID = provider.id languageNavigationResultKind = kind do { @@ -808,6 +817,9 @@ extension AppModel { switch result { case .failure(let error): self.languageNavigationProviderID = nil + if presentation == .chooser { + self.isLanguageNavigationChooserVisible = false + } self.showNotification(error.localizedDescription) case .success(let values): if fallbackToImplementationsIfSelf, @@ -820,20 +832,23 @@ extension AppModel { caret: caret, workspaceURL: workspaceURL, originalValues: values, - navigateToSingleResult: navigateToSingleResult + presentation: presentation ) return } self.presentGenericNavigationValues( values, kind: kind, - navigateToSingleResult: navigateToSingleResult + presentation: presentation ) } } } catch { isLoadingLanguageNavigation = false languageNavigationProviderID = nil + if presentation == .chooser { + isLanguageNavigationChooserVisible = false + } showNotification(error.localizedDescription) } } @@ -843,7 +858,7 @@ extension AppModel { caret: EditorCaret, workspaceURL: URL, originalValues: [LanguageServerLocation], - navigateToSingleResult: Bool + presentation: LanguageNavigationPresentation ) { isLoadingLanguageNavigation = true do { @@ -863,13 +878,13 @@ extension AppModel { self.presentGenericNavigationValues( implementations, kind: .implementations, - navigateToSingleResult: navigateToSingleResult + presentation: presentation ) } else { self.presentGenericNavigationValues( originalValues, kind: .definitions, - navigateToSingleResult: navigateToSingleResult + presentation: presentation ) } } @@ -878,7 +893,7 @@ extension AppModel { presentGenericNavigationValues( originalValues, kind: .definitions, - navigateToSingleResult: navigateToSingleResult + presentation: presentation ) } } @@ -886,7 +901,7 @@ extension AppModel { private func presentGenericNavigationValues( _ values: [LanguageServerLocation], kind: LanguageNavigationResultKind, - navigateToSingleResult: Bool + presentation: LanguageNavigationPresentation ) { let locations = values.map { LanguageNavigationLocation( @@ -898,7 +913,9 @@ extension AppModel { } languageNavigationResultKind = kind languageNavigationLocations = locations + loadLanguageNavigationPreviews(for: locations) guard !locations.isEmpty else { + isLanguageNavigationChooserVisible = false switch kind { case .definitions: showNotification("Definition not found") case .references: showNotification("No usages found") @@ -906,21 +923,55 @@ extension AppModel { } return } - if navigateToSingleResult, locations.count == 1, let location = locations.first { + if presentation == .navigateSingle, locations.count == 1, let location = locations.first { navigate(to: location) } else { - presentLanguageNavigationResults(kind) + presentLanguageNavigationResults( + kind, + presentation: presentation == .navigateSingle ? .chooser : presentation + ) + } + } + + private func loadLanguageNavigationPreviews(for locations: [LanguageNavigationLocation]) { + let requestID = UUID() + languageNavigationPreviewRequestID = requestID + languageNavigationPreviews = [:] + let openSources = Dictionary(uniqueKeysWithValues: openDocuments.map { + ($0.url.standardizedFileURL, $0.text) + }) + let operations = workspaceFileOperations + + Task { [weak self] in + let previews = await Task.detached(priority: .userInitiated) { + LanguageNavigationPreview.build( + locations: locations, + openSources: openSources, + readSource: { try? operations.readText(from: $0) } + ) + }.value + guard let self, self.languageNavigationPreviewRequestID == requestID else { return } + self.languageNavigationPreviews = previews } } - func presentLanguageNavigationResults(_ kind: LanguageNavigationResultKind) { + private func presentLanguageNavigationResults( + _ kind: LanguageNavigationResultKind, + presentation: LanguageNavigationPresentation = .toolWindow + ) { isGitLogVisible = false isTerminalVisible = false isProblemsVisible = false isMavenVisible = false isRunVisible = false - isReferencesVisible = kind != .implementations - isImplementationChooserVisible = kind == .implementations + isReferencesVisible = presentation == .toolWindow + isLanguageNavigationChooserVisible = presentation == .chooser } } + +private enum LanguageNavigationPresentation { + case navigateSingle + case chooser + case toolWindow +} diff --git a/Sources/Lithe/Models/AppModel.swift b/Sources/Lithe/Models/AppModel.swift index 37ef8342b..8774a7079 100644 --- a/Sources/Lithe/Models/AppModel.swift +++ b/Sources/Lithe/Models/AppModel.swift @@ -74,13 +74,16 @@ final class AppModel: ObservableObject, Identifiable { @Published var isProblemsVisible = false @Published var isMavenVisible = false @Published var isDebugVisible = false - @Published var isImplementationChooserVisible = false + @Published var isLanguageNavigationChooserVisible = false var languageProviderCatalog: LanguageProviderCatalog { languageToolingFeature.catalog } var languageProviderCatalogSnapshot: LanguageProviderCatalogSnapshot { languageToolingFeature.catalogSnapshot } @Published var languageNavigationProviderID: String? @Published var languageNavigationLocations: [LanguageNavigationLocation] = [] + @Published var languageNavigationPreviews: [String: String] = [:] @Published var languageNavigationResultKind: LanguageNavigationResultKind = .definitions + @Published var languageNavigationSubject = "" @Published var isLoadingLanguageNavigation = false + var languageNavigationPreviewRequestID = UUID() @Published var editorCaret: EditorCaret? var editorNavigationTarget: EditorNavigationTarget? { editorNavigationFeature.target } var javaCodeVisionHints: [URL: [JavaCodeVisionHint]] { diff --git a/Sources/Lithe/Models/JavaNavigationModels.swift b/Sources/Lithe/Models/JavaNavigationModels.swift index e5a616d35..aac7f3134 100644 --- a/Sources/Lithe/Models/JavaNavigationModels.swift +++ b/Sources/Lithe/Models/JavaNavigationModels.swift @@ -183,4 +183,20 @@ enum LanguageNavigationResultKind { case .implementations: "Implementations" } } + + var countLabel: String { + switch self { + case .definitions: "definitions" + case .references: "usages" + case .implementations: "implementations" + } + } + + var systemImage: String { + switch self { + case .definitions: "arrow.turn.down.right" + case .references: "scope" + case .implementations: "arrow.triangle.branch" + } + } } diff --git a/Sources/Lithe/Views/CodeEditorView.swift b/Sources/Lithe/Views/CodeEditorView.swift index 23a698de3..972030ec1 100644 --- a/Sources/Lithe/Views/CodeEditorView.swift +++ b/Sources/Lithe/Views/CodeEditorView.swift @@ -78,6 +78,32 @@ struct EditorNavigationViewport { } } +/// Keeps source navigation focused on the declaration itself. Some language +/// servers return a container or even a whole file as a valid target range; +/// using that range for layout would center and highlight the entire container. +struct EditorNavigationPresentation { + static func revealRange(for targetRange: NSRange, in text: NSString) -> NSRange { + guard text.length > 0 else { return NSRange(location: 0, length: 0) } + + let location = min(targetRange.location, text.length - 1) + let lineRange = text.lineRange(for: NSRange(location: location, length: 0)) + var contentEnd = NSMaxRange(lineRange) + while contentEnd > lineRange.location, [10, 13].contains(text.character(at: contentEnd - 1)) { + contentEnd -= 1 + } + + let targetEnd = min(NSMaxRange(targetRange), text.length) + if targetRange.length > 0, targetEnd <= contentEnd { + return NSRange(location: location, length: targetEnd - location) + } + + return NSRange( + location: lineRange.location, + length: max(1, contentEnd - lineRange.location) + ) + } +} + struct CodeEditorView: NSViewRepresentable { @Environment(\.colorScheme) private var colorScheme @EnvironmentObject private var model: AppModel @@ -666,7 +692,10 @@ struct CodeEditorView: NSViewRepresentable { let text = textView.string as NSString let targetRange = textView.navigationCharacterRange(for: target.range, in: text) let location = min(targetRange.location, text.length) - let revealRange = navigationRevealRange(targetRange, in: text) + let revealRange = EditorNavigationPresentation.revealRange( + for: targetRange, + in: text + ) textView.setSelectedRange(NSRange(location: location, length: 0)) revealNavigationRange(revealRange, in: textView) showNavigationHighlight(revealRange, in: textView) @@ -674,21 +703,6 @@ struct CodeEditorView: NSViewRepresentable { scheduleCaretUpdate() } - private func navigationRevealRange(_ range: NSRange, in text: NSString) -> NSRange { - guard text.length > 0 else { return NSRange(location: 0, length: 0) } - if range.length > 0 { return range } - let location = min(range.location, text.length - 1) - let lineRange = text.lineRange(for: NSRange(location: location, length: 0)) - var end = NSMaxRange(lineRange) - while end > lineRange.location, [10, 13].contains(text.character(at: end - 1)) { - end -= 1 - } - return NSRange( - location: lineRange.location, - length: max(1, end - lineRange.location) - ) - } - private func revealNavigationRange(_ range: NSRange, in textView: CodeTextView) { guard let layoutManager = textView.layoutManager, let textContainer = textView.textContainer, diff --git a/Sources/Lithe/Views/EditorAreaView.swift b/Sources/Lithe/Views/EditorAreaView.swift index 15e4fcc25..c75cff305 100644 --- a/Sources/Lithe/Views/EditorAreaView.swift +++ b/Sources/Lithe/Views/EditorAreaView.swift @@ -58,10 +58,8 @@ struct EditorAreaView: View { } } - if model.isImplementationChooserVisible { - LanguageImplementationChooserView() - .padding(.top, 48) - .padding(.horizontal, 24) + if model.isLanguageNavigationChooserVisible { + LanguageNavigationChooserView() .transition(.opacity.combined(with: .scale(scale: 0.98, anchor: .top))) } } diff --git a/Sources/Lithe/Views/JavaReferencesView.swift b/Sources/Lithe/Views/JavaReferencesView.swift index 043a70278..c229e0221 100644 --- a/Sources/Lithe/Views/JavaReferencesView.swift +++ b/Sources/Lithe/Views/JavaReferencesView.swift @@ -75,64 +75,152 @@ struct LanguageReferencesView: View { } } -struct LanguageImplementationChooserView: View { +struct LanguageNavigationChooserView: View { @EnvironmentObject private var model: AppModel @State private var query = "" + @State private var selectedIndex = 0 + @State private var keyMonitor: Any? var body: some View { - VStack(spacing: 0) { - LitheToolWindowHeader( - title: "Choose Implementation", - systemImage: "arrow.triangle.branch", - subtitle: "\(filteredLocations.count) found", - onMinimize: { model.closeLanguageNavigationResults() } - ) - - HStack(spacing: 7) { - LitheSystemIcon(systemImage: "magnifyingglass") - .foregroundStyle(LitheTheme.secondaryText) - TextField("Search implementations", text: $query) - .textFieldStyle(.plain) - .font(.system(size: 12)) + ZStack(alignment: .top) { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { model.closeLanguageNavigationResults() } + + VStack(spacing: 0) { + header + toolbar + Rectangle().fill(LitheTheme.divider).frame(height: 1) + resultList + } + .frame(width: 900, height: 500) + .lithePopupChrome(cornerRadius: 8) + .padding(.top, 54) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .onAppear { installKeyMonitor() } + .onDisappear { removeKeyMonitor() } + .onChange(of: filteredLocations.count) { count in + selectedIndex = min(selectedIndex, max(0, count - 1)) + } + .onChange(of: query) { _ in selectedIndex = 0 } + } + + private var header: some View { + HStack(spacing: 8) { + Image(systemName: model.languageNavigationKind.systemImage) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(LitheTheme.accent) + Text(headerTitle) + .font(.system(size: 13.5, weight: .semibold)) + .foregroundStyle(LitheTheme.primaryText) + .lineLimit(1) + Spacer() + if model.isLoadingNavigation { + ProgressView().controlSize(.small) } - .padding(.horizontal, 10) - .frame(height: 34) - .background(LitheTheme.editor) - - ScrollView(.vertical) { - LazyVStack(spacing: 1) { - ForEach(filteredLocations) { location in - Button { - model.navigate(to: location) - } label: { - HStack(spacing: 9) { - LitheIcon(kind: LitheIcons.kind(for: location.url, isDirectory: false), size: 15) - Text(location.displayName) - .font(.system(size: 12.5, weight: .medium, design: .monospaced)) - .foregroundStyle(LitheTheme.primaryText) - Text(location.displayPath ?? model.relativePath(for: location.url)) - .font(.system(size: 10.5, design: .monospaced)) - .foregroundStyle(LitheTheme.secondaryText) - .lineLimit(1) - Spacer() - Text("\(location.line + 1):\(location.utf16Column + 1)") - .font(.system(size: 10.5, design: .monospaced)) - .foregroundStyle(LitheTheme.secondaryText) - } - .padding(.horizontal, 10) - .frame(maxWidth: .infinity) - .frame(height: 30) - .contentShape(Rectangle()) + Text("\(model.languageNavigationResults.count) \(model.languageNavigationKind.countLabel)") + .font(.system(size: 11.5)) + .foregroundStyle(LitheTheme.secondaryText) + Button { model.closeLanguageNavigationResults() } label: { + Image(systemName: "xmark") + } + .litheIconButton() + .help("Close (Esc)") + } + .padding(.horizontal, 12) + .frame(height: 42) + .background(LitheTheme.toolHeader) + } + + private var toolbar: some View { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.system(size: 11)) + .foregroundStyle(LitheTheme.secondaryText) + TextField("Filter results", text: $query) + .textFieldStyle(.plain) + .font(.system(size: 12)) + Spacer() + Text("↑↓ Select ↩ Jump esc Close") + .font(.system(size: 10.5, design: .monospaced)) + .foregroundStyle(LitheTheme.tertiaryText) + } + .padding(.horizontal, 12) + .frame(height: 34) + .background(LitheTheme.popupBackground) + } + + @ViewBuilder + private var resultList: some View { + if filteredLocations.isEmpty { + VStack(spacing: 10) { + if model.isLoadingNavigation { + ProgressView() + Text("Finding \(model.languageNavigationKind.countLabel)…") + } else { + Text("No matching results") + } + } + .font(.system(size: 12)) + .foregroundStyle(LitheTheme.secondaryText) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollViewReader { proxy in + ScrollView(.vertical) { + LazyVStack(spacing: 1) { + ForEach(Array(filteredLocations.enumerated()), id: \.element.id) { index, location in + resultRow(location, index: index) + .id(location.id) } - .buttonStyle(.plain) - .lithePointer() } + .padding(5) + } + .onChange(of: selectedIndex) { index in + guard filteredLocations.indices.contains(index) else { return } + proxy.scrollTo(filteredLocations[index].id, anchor: .center) } - .padding(5) } } - .frame(maxWidth: 760, minHeight: 220, maxHeight: 390) - .lithePopupChrome(cornerRadius: 7) + } + + private func resultRow(_ location: LanguageNavigationLocation, index: Int) -> some View { + Button { navigate(to: location) } label: { + HStack(spacing: 9) { + LitheIcon(kind: LitheIcons.kind(for: location.url, isDirectory: false), size: 15) + .frame(width: 18) + Text(location.displayName) + .font(.system(size: 12.5, weight: .medium)) + .foregroundStyle(LitheTheme.primaryText) + .lineLimit(1) + .frame(width: 170, alignment: .leading) + Text(parentPath(for: location)) + .font(.system(size: 10.5)) + .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(1) + .truncationMode(.middle) + .frame(width: 150, alignment: .leading) + Text("\(location.line + 1)") + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(LitheTheme.secondaryText) + .frame(width: 44, alignment: .trailing) + Text(model.languageNavigationPreviews[location.id] ?? "") + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(LitheTheme.primaryText) + .lineLimit(1) + Spacer(minLength: 0) + } + .padding(.horizontal, 9) + .frame(maxWidth: .infinity) + .frame(height: 29) + .background(index == selectedIndex ? LitheTheme.selection : .clear) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .lithePointer() + .onHover { hovering in + if hovering { selectedIndex = index } + } } private var filteredLocations: [LanguageNavigationLocation] { @@ -142,4 +230,53 @@ struct LanguageImplementationChooserView: View { ($0.displayPath ?? model.relativePath(for: $0.url)).localizedCaseInsensitiveContains(query) } } + + private var headerTitle: String { + let subject = model.languageNavigationSubject.trimmingCharacters(in: .whitespacesAndNewlines) + return subject.isEmpty + ? model.languageNavigationKind.title + : "\(model.languageNavigationKind.title) of \(subject)" + } + + private func parentPath(for location: LanguageNavigationLocation) -> String { + let path = location.displayPath ?? model.relativePath(for: location.url) + return (path as NSString).deletingLastPathComponent + } + + private func installKeyMonitor() { + guard keyMonitor == nil else { return } + keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in + guard model.isLanguageNavigationChooserVisible else { return event } + switch event.keyCode { + case 125: + if !filteredLocations.isEmpty { + selectedIndex = min(selectedIndex + 1, filteredLocations.count - 1) + } + return nil + case 126: + if !filteredLocations.isEmpty { selectedIndex = max(selectedIndex - 1, 0) } + return nil + case 36, 76: + guard filteredLocations.indices.contains(selectedIndex) else { return nil } + navigate(to: filteredLocations[selectedIndex]) + return nil + case 53: + model.closeLanguageNavigationResults() + return nil + default: + return event + } + } + } + + private func navigate(to location: LanguageNavigationLocation) { + model.navigate(to: location) + } + + private func removeKeyMonitor() { + if let keyMonitor { + NSEvent.removeMonitor(keyMonitor) + self.keyMonitor = nil + } + } } diff --git a/Tests/LitheTests/EditorNavigationTests.swift b/Tests/LitheTests/EditorNavigationTests.swift index 01769c844..c9cafd01c 100644 --- a/Tests/LitheTests/EditorNavigationTests.swift +++ b/Tests/LitheTests/EditorNavigationTests.swift @@ -95,6 +95,28 @@ struct EditorNavigationTests { ) == 1_700) } + @Test + func presentationUsesPreciseSingleLineNavigationRanges() { + let text = "struct Service {\n func run() {}\n}\n" as NSString + let range = NSRange(location: 21, length: 4) + + #expect(EditorNavigationPresentation.revealRange(for: range, in: text) == range) + } + + @Test + func presentationCollapsesContainerNavigationRangesToTheirStartingLine() { + let text = "mod adapter;\n\nfn connect() {}\nfn close() {}\n" as NSString + let wholeFileRange = NSRange(location: 0, length: text.length) + + let revealRange = EditorNavigationPresentation.revealRange( + for: wholeFileRange, + in: text + ) + + #expect(revealRange == NSRange(location: 0, length: 12)) + #expect(text.substring(with: revealRange) == "mod adapter;") + } + @Test @MainActor func lspRangeUsesUTF16OffsetsWithoutScanningFromTheDocumentStart() { diff --git a/Tests/LitheTests/LanguageNavigationPreviewTests.swift b/Tests/LitheTests/LanguageNavigationPreviewTests.swift new file mode 100644 index 000000000..77bf28a17 --- /dev/null +++ b/Tests/LitheTests/LanguageNavigationPreviewTests.swift @@ -0,0 +1,37 @@ +import Foundation +import Testing +@testable import Lithe + +@Suite("Language navigation preview") +struct LanguageNavigationPreviewTests { + @Test + func extractsTrimmedUTF16SafeSourceLines() { + let source = "type AgentEvent struct {\n Message string // 消息😀\n}\n" + + #expect( + LanguageNavigationPreview.line(in: source, at: 1) + == "Message string // 消息😀" + ) + } + + @Test + func buildsPreviewsOncePerFileAndKeysThemByLocation() { + let url = URL(fileURLWithPath: "/workspace/agent.go") + let first = LanguageNavigationLocation(url: url, line: 0, utf16Column: 5) + let second = LanguageNavigationLocation(url: url, line: 1, utf16Column: 2) + var readCount = 0 + + let previews = LanguageNavigationPreview.build( + locations: [first, second], + openSources: [:], + readSource: { _ in + readCount += 1 + return "type AgentEvent struct {\nlisteners map[int]func(AgentEvent)\n" + } + ) + + #expect(readCount == 1) + #expect(previews[first.id] == "type AgentEvent struct {") + #expect(previews[second.id] == "listeners map[int]func(AgentEvent)") + } +} diff --git a/rust/lithe-core/src/lsp/interface/client.rs b/rust/lithe-core/src/lsp/interface/client.rs index d67963e1c..ef1501797 100644 --- a/rust/lithe-core/src/lsp/interface/client.rs +++ b/rust/lithe-core/src/lsp/interface/client.rs @@ -43,10 +43,22 @@ pub fn client_initialize(request: ClientInitializeRequest) -> Result Date: Thu, 13 Aug 2026 21:45:56 +0800 Subject: [PATCH 05/10] fix: prevent editor structure parsing stalls --- .../Lithe/Application/JavaFeatureModel.swift | 17 +- Sources/Lithe/Models/AppModel.swift | 10 +- Sources/Lithe/Views/CodeEditorView.swift | 73 ++++-- .../JavaStructureSchedulingTests.swift | 114 ++++++++ rust/lithe-core/src/languages/java.rs | 247 ++++++++++++------ 5 files changed, 362 insertions(+), 99 deletions(-) create mode 100644 Tests/LitheTests/JavaStructureSchedulingTests.swift diff --git a/Sources/Lithe/Application/JavaFeatureModel.swift b/Sources/Lithe/Application/JavaFeatureModel.swift index 0bab15582..0cf3ff755 100644 --- a/Sources/Lithe/Application/JavaFeatureModel.swift +++ b/Sources/Lithe/Application/JavaFeatureModel.swift @@ -163,7 +163,7 @@ final class JavaFeatureModel: ObservableObject { workspaceRoot: workspaceRoot, blameLines: blame ) - let candidates = structure(source: document.text)?.implementationMarkers ?? [] + let candidates = await structureAsync(source: document.text)?.implementationMarkers ?? [] let implementationCounts = candidates.reduce(into: [Int: Int]()) { counts, marker in counts[marker.line] = max(counts[marker.line] ?? 0, marker.implementationCount) } @@ -233,7 +233,10 @@ final class JavaFeatureModel: ObservableObject { } else { sources = [] } - let fallback = structure(source: currentText, declarationSources: sources)?.inlayHints ?? [] + let fallback = await structureAsync( + source: currentText, + declarationSources: sources + )?.inlayHints ?? [] guard documentProvider?()?.id == document.id else { return } javaInlayHints[document.url.standardizedFileURL] = fallback } @@ -245,8 +248,14 @@ final class JavaFeatureModel: ObservableObject { return String(path.dropFirst(rootPath.count + 1)) } - func structure(source: String, declarationSources: [String] = []) -> JavaStructureResult? { - operations.structure(source: source, declarationSources: declarationSources) + func structureAsync( + source: String, + declarationSources: [String] = [] + ) async -> JavaStructureResult? { + let operations = self.operations + return await Task.detached(priority: .userInitiated) { + operations.structure(source: source, declarationSources: declarationSources) + }.value } func codeVision( diff --git a/Sources/Lithe/Models/AppModel.swift b/Sources/Lithe/Models/AppModel.swift index 8774a7079..7891dc1e1 100644 --- a/Sources/Lithe/Models/AppModel.swift +++ b/Sources/Lithe/Models/AppModel.swift @@ -649,8 +649,14 @@ final class AppModel: ObservableObject, Identifiable { showNotification(settings.language == .simplifiedChinese ? "语言服务器诊断已清空" : "Language server diagnostics cleared") } - func javaStructure(source: String, declarationSources: [String] = []) -> JavaStructureResult? { - javaFeature.structure(source: source, declarationSources: declarationSources) + func javaStructureAsync( + source: String, + declarationSources: [String] = [] + ) async -> JavaStructureResult? { + await javaFeature.structureAsync( + source: source, + declarationSources: declarationSources + ) } var activeDocument: EditorDocument? { diff --git a/Sources/Lithe/Views/CodeEditorView.swift b/Sources/Lithe/Views/CodeEditorView.swift index 972030ec1..b56bf4055 100644 --- a/Sources/Lithe/Views/CodeEditorView.swift +++ b/Sources/Lithe/Views/CodeEditorView.swift @@ -104,6 +104,12 @@ struct EditorNavigationPresentation { } } +struct EditorLocalStructurePolicy { + static func supports(fileExtension: String) -> Bool { + fileExtension.lowercased() == "java" + } +} + struct CodeEditorView: NSViewRepresentable { @Environment(\.colorScheme) private var colorScheme @EnvironmentObject private var model: AppModel @@ -333,6 +339,7 @@ struct CodeEditorView: NSViewRepresentable { var markdownScrollPosition: Binding? var appliedNavigationTargetID: UUID? var foldRegions: [JavaFoldRegion] = [] + var implementationMarkers: [JavaImplementationMarker] = [] var collapsedFoldIDs: Set = [] private var markdownImagePasteMonitor: Any? private weak var markdownScrollView: NSScrollView? @@ -341,6 +348,8 @@ struct CodeEditorView: NSViewRepresentable { private var lastObservedMarkdownScrollRevision: UInt64? private var navigationHighlightTask: Task? private var navigationApplicationTask: Task? + private var foldRefreshTask: Task? + private var foldRefreshGeneration: UInt64 = 0 private var scheduledNavigationTargetID: UUID? private var caretUpdateTask: Task? private var findStateUpdateTask: Task? @@ -362,6 +371,7 @@ struct CodeEditorView: NSViewRepresentable { deinit { navigationHighlightTask?.cancel() navigationApplicationTask?.cancel() + foldRefreshTask?.cancel() caretUpdateTask?.cancel() findStateUpdateTask?.cancel() if let markdownImagePasteMonitor { @@ -561,20 +571,51 @@ struct CodeEditorView: NSViewRepresentable { } func refreshFoldRegions(useDefaultImportFold: Bool) { - guard let textView = textView as? CodeTextView else { + foldRefreshTask?.cancel() + foldRefreshGeneration &+= 1 + let generation = foldRefreshGeneration + + guard EditorLocalStructurePolicy.supports(fileExtension: fileExtension), + let textView = textView as? CodeTextView, + let model else { foldRegions = [] + implementationMarkers = [] collapsedFoldIDs = [] + applyFoldState() return } - foldRegions = model?.javaStructure(source: textView.string)?.foldRegions ?? [] - let availableIDs = Set(foldRegions.map(\.id)) - collapsedFoldIDs.formIntersection(availableIDs) - if useDefaultImportFold, - fileExtension.lowercased() == "java", - let imports = foldRegions.first(where: { $0.kind == .imports }) { - collapsedFoldIDs.insert(imports.id) - } + + let source = textView.string + foldRegions = [] + implementationMarkers = [] applyFoldState() + + foldRefreshTask = Task { @MainActor [weak self, weak model] in + if !useDefaultImportFold { + do { + try await Task.sleep(for: .milliseconds(120)) + } catch { + return + } + } + guard !Task.isCancelled, let model else { return } + let result = await model.javaStructureAsync(source: source) + guard !Task.isCancelled, + let self, + self.foldRefreshGeneration == generation, + self.textView?.string == source else { return } + + self.foldRegions = result?.foldRegions ?? [] + self.implementationMarkers = result?.implementationMarkers ?? [] + let availableIDs = Set(self.foldRegions.map(\.id)) + self.collapsedFoldIDs.formIntersection(availableIDs) + if useDefaultImportFold, + let imports = self.foldRegions.first(where: { $0.kind == .imports }) { + self.collapsedFoldIDs.insert(imports.id) + } + self.applyFoldState() + self.foldRefreshTask = nil + } } func toggleFold(_ region: JavaFoldRegion) { @@ -597,17 +638,9 @@ struct CodeEditorView: NSViewRepresentable { collapsedIDs: collapsedFoldIDs, onToggle: { [weak self] region in self?.toggleFold(region) } ) - // `java.structure` is an explicit local editor fallback. It does - // not validate markers or own any language-server lifecycle. - let markers: [JavaImplementationMarker] - if let document, - fileExtension.lowercased() == "java", - let model { - markers = model.javaStructure(source: document.text)?.implementationMarkers ?? [] - } else { - markers = [] - } - gutter?.updateImplementationMarkers(markers) { [weak model, weak document] marker in + // `java.structure` is an explicit local editor fallback. Fold + // regions and markers come from the same background parse. + gutter?.updateImplementationMarkers(implementationMarkers) { [weak model, weak document] marker in guard let document else { return } model?.findJavaImplementations( line: marker.line, diff --git a/Tests/LitheTests/JavaStructureSchedulingTests.swift b/Tests/LitheTests/JavaStructureSchedulingTests.swift new file mode 100644 index 000000000..644d4568b --- /dev/null +++ b/Tests/LitheTests/JavaStructureSchedulingTests.swift @@ -0,0 +1,114 @@ +import Foundation +import Testing +@testable import Lithe + +@Suite("Java structure scheduling") +struct JavaStructureSchedulingTests { + @Test + func localStructureFallbackOnlyHandlesJavaFiles() { + #expect(EditorLocalStructurePolicy.supports(fileExtension: "java")) + #expect(EditorLocalStructurePolicy.supports(fileExtension: "JAVA")) + #expect(!EditorLocalStructurePolicy.supports(fileExtension: "rs")) + #expect(!EditorLocalStructurePolicy.supports(fileExtension: "go")) + #expect(!EditorLocalStructurePolicy.supports(fileExtension: "")) + } + + @Test + @MainActor + func structureParsingRunsOffTheMainThread() async { + let operations = StructureThreadRecordingJavaOperations() + let feature = JavaFeatureModel( + operations: operations, + workspaceOperations: StructureTestWorkspaceOperations() + ) + + let result = await feature.structureAsync(source: "class Demo {}") + + #expect(result?.foldRegions.count == 1) + #expect(operations.structureRanOnMainThread == false) + } +} + +private final class StructureThreadRecordingJavaOperations: JavaMavenOperations, @unchecked Sendable { + private let lock = NSLock() + private var recordedStructureRanOnMainThread: Bool? + + var structureRanOnMainThread: Bool? { + lock.withLock { recordedStructureRanOnMainThread } + } + + func scanMavenProject(at rootURL: URL) -> MavenProject? { nil } + func mavenDiagnostics(output: String, projectRoot: URL) -> [MavenBuildIssue] { [] } + func codeVision( + at rootURL: URL, + targetPath: String, + paths: [String] + ) -> [JavaCodeVisionValue] { [] } + func className(source: String, simpleName: String) -> String? { nil } + func sourceDefinition( + source: String, + declarationName: String, + memberName: String? + ) -> (line: Int, utf16Column: Int)? { nil } + func serverPort(content: String, fileExtension: String) -> Int? { nil } + func scanRunConfigurations( + at rootURL: URL, + files: [URL], + mavenProject: MavenProject? + ) -> [JavaRunConfiguration] { [] } + + func structure( + source: String, + declarationSources: [String] + ) -> JavaStructureResult? { + lock.withLock { + recordedStructureRanOnMainThread = Thread.isMainThread + } + return JavaStructureResult( + foldRegions: [ + JavaFoldRegion( + kind: .type, + startLine: 0, + endLine: 0, + hiddenRange: NSRange(location: 0, length: 0) + ) + ], + implementationMarkers: [], + inlayHints: [] + ) + } +} + +private struct StructureTestWorkspaceOperations: WorkspaceOperations { + func snapshot( + at rootURL: URL, + visibilityRules: FileVisibilityRules + ) -> WorkspaceSnapshot? { nil } + + func search( + at rootURL: URL, + query: String, + options: ProjectSearchOptions, + visibilityRules: FileVisibilityRules + ) -> [FileSearchResult]? { nil } + + func searchEverywhere( + at rootURL: URL, + query: String, + options: ProjectSearchOptions, + visibilityRules: FileVisibilityRules + ) -> SearchEverywhereResults? { nil } + + func previewReplacement( + at rootURL: URL, + query: String, + replacement: String, + options: ProjectSearchOptions, + paths: [String], + textOverrides: [String: String], + visibilityRules: FileVisibilityRules + ) -> [ProjectReplacementFile]? { nil } + + func readFile(at rootURL: URL, relativePath: String) -> String? { nil } + func writeFile(_ text: String, at rootURL: URL, relativePath: String) -> Bool { false } +} diff --git a/rust/lithe-core/src/languages/java.rs b/rust/lithe-core/src/languages/java.rs index aa0eb769c..f7e78ad1b 100644 --- a/rust/lithe-core/src/languages/java.rs +++ b/rust/lithe-core/src/languages/java.rs @@ -10,6 +10,7 @@ use serde::Deserialize; use std::collections::{HashMap, HashSet}; use std::fs; use std::path::{Component, Path, PathBuf}; +use std::sync::OnceLock; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -117,10 +118,11 @@ pub fn run_configurations( pub fn structure(request: JavaStructureRequest) -> Result { let source = request.source; + let positions = SourcePositionIndex::new(&source); Ok(JavaStructureResponse { - fold_regions: fold_regions(&source), - implementation_markers: implementation_markers(&source), - inlay_hints: parameter_hints(&source, &request.declaration_sources), + fold_regions: fold_regions(&source, &positions), + implementation_markers: implementation_markers(&source, &positions), + inlay_hints: parameter_hints(&source, &request.declaration_sources, &positions), }) } @@ -333,10 +335,12 @@ fn kind_order(kind: &str) -> usize { } } -fn fold_regions(source: &str) -> Vec { - let mut regions = import_region(source).into_iter().collect::>(); - regions.extend(comment_regions(source)); - regions.extend(brace_regions(source)); +fn fold_regions(source: &str, positions: &SourcePositionIndex) -> Vec { + let mut regions = import_region(source, positions) + .into_iter() + .collect::>(); + regions.extend(comment_regions(source, positions)); + regions.extend(brace_regions(source, positions)); regions.sort_by(|left, right| { left.start_line .cmp(&right.start_line) @@ -345,7 +349,7 @@ fn fold_regions(source: &str) -> Vec { regions } -fn import_region(source: &str) -> Option { +fn import_region(source: &str, positions: &SourcePositionIndex) -> Option { let expression = Regex::new(r"(?m)^[ \t]*import[ \t]+[^;]+;[ \t]*$").ok()?; let matches = expression.find_iter(source).collect::>(); let first = matches.first()?; @@ -355,36 +359,38 @@ fn import_region(source: &str) -> Option { } Some(JavaFoldRegionResponse { kind: "imports".to_string(), - start_line: line_number(source, first.start()), - end_line: line_number(source, last.start()), - hidden_start: utf16_offset(source, line_end(source, first.start())), - hidden_length: utf16_offset(source, line_end(source, last.start())) - .saturating_sub(utf16_offset(source, line_end(source, first.start()))), + start_line: positions.line_number(first.start()), + end_line: positions.line_number(last.start()), + hidden_start: positions.utf16_offset(positions.line_end(first.start())), + hidden_length: positions + .utf16_offset(positions.line_end(last.start())) + .saturating_sub(positions.utf16_offset(positions.line_end(first.start()))), }) } -fn comment_regions(source: &str) -> Vec { +fn comment_regions(source: &str, positions: &SourcePositionIndex) -> Vec { let Ok(expression) = Regex::new(r"/\*[\s\S]*?\*/") else { return Vec::new(); }; expression .find_iter(source) .filter_map(|matched| { - let start_line = line_number(source, matched.start()); - let end_line = line_number(source, matched.end()); + let start_line = positions.line_number(matched.start()); + let end_line = positions.line_number(matched.end()); (end_line > start_line).then(|| JavaFoldRegionResponse { kind: "comment".to_string(), start_line, end_line, - hidden_start: utf16_offset(source, line_end(source, matched.start())), - hidden_length: utf16_offset(source, matched.end()) - .saturating_sub(utf16_offset(source, line_end(source, matched.start()))), + hidden_start: positions.utf16_offset(positions.line_end(matched.start())), + hidden_length: positions + .utf16_offset(matched.end()) + .saturating_sub(positions.utf16_offset(positions.line_end(matched.start()))), }) }) .collect() } -fn brace_regions(source: &str) -> Vec { +fn brace_regions(source: &str, positions: &SourcePositionIndex) -> Vec { let bytes = source.as_bytes(); let mut stack: Vec<(usize, String)> = Vec::new(); let mut regions = Vec::new(); @@ -406,23 +412,24 @@ fn brace_regions(source: &str) -> Vec { index += 1; } b'{' => { - let start = line_start(source, index); + let start = positions.line_start(index); stack.push((index, source[start..index].to_string())); } b'}' => { if let Some((opening, prefix)) = stack.pop() { - let start_line = line_number(source, opening); - let end_line = line_number(source, index); - let hidden_start = line_end(source, opening); - let hidden_end = line_start(source, index); + let start_line = positions.line_number(opening); + let end_line = positions.line_number(index); + let hidden_start = positions.line_end(opening); + let hidden_end = positions.line_start(index); if end_line > start_line && hidden_end > hidden_start { regions.push(JavaFoldRegionResponse { kind: classify(&prefix), start_line, end_line, - hidden_start: utf16_offset(source, hidden_start), - hidden_length: utf16_offset(source, hidden_end) - .saturating_sub(utf16_offset(source, hidden_start)), + hidden_start: positions.utf16_offset(hidden_start), + hidden_length: positions + .utf16_offset(hidden_end) + .saturating_sub(positions.utf16_offset(hidden_start)), }); } } @@ -461,8 +468,12 @@ fn brace_regions(source: &str) -> Vec { } fn classify(prefix: &str) -> String { - if Regex::new(r"\b(class|interface|enum|record|struct|protocol|extension|actor)\b") - .expect("static Java type expression is valid") + static TYPE_EXPRESSION: OnceLock = OnceLock::new(); + if TYPE_EXPRESSION + .get_or_init(|| { + Regex::new(r"\b(class|interface|enum|record|struct|protocol|extension|actor)\b") + .expect("static Java type expression is valid") + }) .is_match(prefix) { "type".to_string() @@ -473,7 +484,10 @@ fn classify(prefix: &str) -> String { } } -fn implementation_markers(source: &str) -> Vec { +fn implementation_markers( + source: &str, + positions: &SourcePositionIndex, +) -> Vec { let mut markers = Vec::new(); if let Ok(expression) = Regex::new( r"(?m)^[ \t]*(?:(?:public|protected|private|abstract|sealed|non-sealed)\s+)*interface\s+[A-Za-z_$][A-Za-z0-9_$]*", @@ -484,9 +498,8 @@ fn implementation_markers(source: &str) -> Vec .map(|value| matched.start() + value) .unwrap_or(matched.start()); markers.push(JavaImplementationMarkerResponse { - line: line_number(source, location), - utf16_column: utf16_offset(source, location) - .saturating_sub(utf16_offset(source, line_start(source, location))), + line: positions.line_number(location), + utf16_column: positions.utf16_column(location), implementation_count: 0, direction: "down".to_string(), }); @@ -497,12 +510,12 @@ fn implementation_markers(source: &str) -> Vec ) { for matched in expression.find_iter(source) { let location = method_name_location(source, matched.start(), matched.end()); + let line = positions.line_number(location); markers.push(JavaImplementationMarkerResponse { - line: line_number(source, location), - utf16_column: utf16_offset(source, location) - .saturating_sub(utf16_offset(source, line_start(source, location))), + line, + utf16_column: positions.utf16_column(location), implementation_count: 0, - direction: if has_override_annotation(source, line_number(source, location)) { + direction: if has_override_annotation(source, positions, line) { "up" } else { "down" @@ -526,10 +539,7 @@ fn implementation_markers(source: &str) -> Vec } } } - let candidate_start = lines[..candidate_line] - .iter() - .map(|value| value.len() + 1) - .sum::(); + let candidate_start = positions.line_start_for_number(candidate_line); if let Some(matched) = method_expression.find(lines[candidate_line]) { let name_start = method_expression .captures(lines[candidate_line]) @@ -538,8 +548,7 @@ fn implementation_markers(source: &str) -> Vec let location = candidate_start + name_start; markers.push(JavaImplementationMarkerResponse { line: candidate_line, - utf16_column: utf16_offset(source, location) - .saturating_sub(utf16_offset(source, line_start(source, location))), + utf16_column: positions.utf16_column(location), implementation_count: 0, direction: "up".to_string(), }); @@ -566,7 +575,11 @@ fn deduplicate_markers( values } -fn parameter_hints(source: &str, declaration_sources: &[String]) -> Vec { +fn parameter_hints( + source: &str, + declaration_sources: &[String], + positions: &SourcePositionIndex, +) -> Vec { let declaration_pattern = Regex::new(r"\b([A-Za-z_$][A-Za-z0-9_$]*)[ \t]*\(([^(){};]*)\)[ \t]*(?:throws[^{]+)?\{") .expect("static Java declaration expression is valid"); @@ -621,9 +634,8 @@ fn parameter_hints(source: &str, declaration_sources: &[String]) -> Vec usize { .unwrap_or(start) } -fn has_override_annotation(source: &str, line: usize) -> bool { - let lines = source.split('\n').collect::>(); - let start = line.saturating_sub(3); - lines[start..line.min(lines.len())] - .iter() - .any(|value| value.contains("@Override")) +fn has_override_annotation(source: &str, positions: &SourcePositionIndex, line: usize) -> bool { + let start = positions.line_start_for_number(line.saturating_sub(3)); + let end = positions.line_start_for_number(line); + source[start..end].contains("@Override") } fn existing_root(value: &str) -> Result { @@ -813,29 +823,70 @@ fn normalize_relative(value: &str) -> Option { ) } -fn line_number(source: &str, byte: usize) -> usize { - source[..byte.min(source.len())] - .bytes() - .filter(|value| *value == b'\n') - .count() +struct SourcePositionIndex { + source_length: usize, + line_starts: Vec, + utf16_offsets: Vec, } -fn line_start(source: &str, byte: usize) -> usize { - source[..byte.min(source.len())] - .rfind('\n') - .map(|value| value + 1) - .unwrap_or(0) -} +impl SourcePositionIndex { + fn new(source: &str) -> Self { + let mut line_starts = vec![0]; + for (index, value) in source.bytes().enumerate() { + if value == b'\n' { + line_starts.push(index + 1); + } + } -fn line_end(source: &str, byte: usize) -> usize { - source[byte.min(source.len())..] - .find('\n') - .map(|value| byte.min(source.len()) + value + 1) - .unwrap_or(source.len()) -} + let mut utf16_offsets = vec![0; source.len() + 1]; + let mut utf16_offset = 0; + for (byte, character) in source.char_indices() { + let next = byte + character.len_utf8(); + utf16_offsets[byte..next].fill(utf16_offset); + utf16_offset += character.len_utf16(); + utf16_offsets[next] = utf16_offset; + } + + Self { + source_length: source.len(), + line_starts, + utf16_offsets, + } + } + + fn line_number(&self, byte: usize) -> usize { + let byte = byte.min(self.source_length); + self.line_starts + .partition_point(|line_start| *line_start <= byte) + .saturating_sub(1) + } + + fn line_start(&self, byte: usize) -> usize { + self.line_start_for_number(self.line_number(byte)) + } -fn utf16_offset(source: &str, byte: usize) -> usize { - source[..byte.min(source.len())].encode_utf16().count() + fn line_start_for_number(&self, line: usize) -> usize { + self.line_starts + .get(line) + .copied() + .unwrap_or(self.source_length) + } + + fn line_end(&self, byte: usize) -> usize { + self.line_starts + .get(self.line_number(byte) + 1) + .copied() + .unwrap_or(self.source_length) + } + + fn utf16_offset(&self, byte: usize) -> usize { + self.utf16_offsets[byte.min(self.source_length)] + } + + fn utf16_column(&self, byte: usize) -> usize { + self.utf16_offset(byte) + .saturating_sub(self.utf16_offset(self.line_start(byte))) + } } enum ScanState { @@ -845,3 +896,53 @@ enum ScanState { LineComment, BlockComment, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn source_position_index_maps_lines_and_utf16_offsets() { + let source = "class 示例 {\n String emoji = \"😀\";\n}\n"; + let positions = SourcePositionIndex::new(source); + let emoji = source.find('😀').expect("emoji should exist"); + let closing_brace = source.rfind('}').expect("closing brace should exist"); + + assert_eq!(positions.line_number(emoji), 1); + assert_eq!(positions.utf16_column(emoji), 20); + assert_eq!(positions.utf16_offset(emoji + '😀'.len_utf8()), 33); + assert_eq!(positions.line_number(closing_brace), 2); + assert_eq!(positions.line_start(closing_brace), closing_brace); + assert_eq!(positions.line_end(closing_brace), source.len()); + } + + #[test] + fn large_structure_source_uses_the_shared_position_index() { + let mut source = String::from("class Large {\n"); + for index in 0..1_000 { + source.push_str(&format!( + " void method{index}() {{\n call();\n }}\n" + )); + } + source.push_str("}\n"); + + let response = structure(JavaStructureRequest { + source, + declaration_sources: Vec::new(), + }) + .expect("large Java structure should parse"); + + assert_eq!(response.fold_regions.len(), 1_001); + assert_eq!( + response + .fold_regions + .first() + .map(|region| region.start_line), + Some(0) + ); + assert_eq!( + response.fold_regions.last().map(|region| region.end_line), + Some(3_000) + ); + } +} From fe06b923a6a8d6664cff416db949e797246b8ed7 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Thu, 13 Aug 2026 22:51:01 +0800 Subject: [PATCH 06/10] =?UTF-8?q?add:=E6=B7=BB=E5=8A=A0command=20+=20[=20?= =?UTF-8?q?=E5=92=8Ccommand=20+=20]=20=E6=9D=A5=E8=BF=9B=E8=A1=8C=E8=B7=B3?= =?UTF-8?q?=E8=BD=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../EditorNavigationFeatureModel.swift | 43 +++ Sources/Lithe/LitheApp.swift | 23 +- .../Lithe/Models/AppModel+Development.swift | 243 ++++++++++++++- Sources/Lithe/Models/AppModel.swift | 48 ++- .../Lithe/Models/JavaNavigationModels.swift | 30 ++ .../Platform/MacOS/MacServiceContainer.swift | 10 +- .../LanguageToolingSessionManager.swift | 282 +++++++++++++++++- .../ProjectSymbolFeatureProvider.swift | 276 +++++++++++++++++ Sources/Lithe/Views/CodeEditorView.swift | 27 +- Sources/Lithe/Views/JavaReferencesView.swift | 145 +++++---- .../Lithe/Views/LSPControlCenterView.swift | 72 +++++ Tests/LitheTests/EditorNavigationTests.swift | 49 +++ .../LanguageFeatureProviderTests.swift | 245 +++++++++++++++ .../RunConfigurationIntegrationTests.swift | 25 ++ docs/architecture/language-tooling.md | 17 +- 15 files changed, 1442 insertions(+), 93 deletions(-) create mode 100644 Sources/Lithe/Services/ProjectSymbolFeatureProvider.swift diff --git a/Sources/Lithe/Application/EditorNavigationFeatureModel.swift b/Sources/Lithe/Application/EditorNavigationFeatureModel.swift index 511756fac..17a11bebd 100644 --- a/Sources/Lithe/Application/EditorNavigationFeatureModel.swift +++ b/Sources/Lithe/Application/EditorNavigationFeatureModel.swift @@ -11,9 +11,17 @@ final class EditorNavigationFeatureModel: ObservableObject { private var transactionTask: Task? private var transactionRevision: UInt64 = 0 + private var backStack: [EditorNavigationTarget] = [] + private var forwardStack: [EditorNavigationTarget] = [] + private let historyLimit = 100 + + var canNavigateBack: Bool { !backStack.isEmpty } + var canNavigateForward: Bool { !forwardStack.isEmpty } func navigate( to target: EditorNavigationTarget, + from source: EditorNavigationTarget? = nil, + recordsHistory: Bool = true, activateDocument: @escaping @MainActor () async -> Bool ) { transactionTask?.cancel() @@ -28,10 +36,35 @@ final class EditorNavigationFeatureModel: ObservableObject { self.transactionRevision == revision else { return } self.isNavigating = false guard didActivate else { return } + if recordsHistory, let source, !Self.samePosition(source, target) { + self.backStack.append(source) + if self.backStack.count > self.historyLimit { + self.backStack.removeFirst(self.backStack.count - self.historyLimit) + } + self.forwardStack.removeAll(keepingCapacity: true) + } self.target = target } } + func takeBackDestination(from current: EditorNavigationTarget?) -> EditorNavigationTarget? { + guard let destination = backStack.popLast() else { return nil } + if let current, !Self.samePosition(current, destination) { + forwardStack.append(current) + } + objectWillChange.send() + return destination + } + + func takeForwardDestination(from current: EditorNavigationTarget?) -> EditorNavigationTarget? { + guard let destination = forwardStack.popLast() else { return nil } + if let current, !Self.samePosition(current, destination) { + backStack.append(current) + } + objectWillChange.send() + return destination + } + /// Used for virtual documents that are resolved through a provider callback /// and are already active by the time their content reaches the application. func reveal(_ target: EditorNavigationTarget) { @@ -47,5 +80,15 @@ final class EditorNavigationFeatureModel: ObservableObject { transactionRevision &+= 1 isNavigating = false target = nil + backStack = [] + forwardStack = [] + } + + private static func samePosition( + _ lhs: EditorNavigationTarget, + _ rhs: EditorNavigationTarget + ) -> Bool { + lhs.url.standardizedFileURL == rhs.url.standardizedFileURL + && lhs.range.start == rhs.range.start } } diff --git a/Sources/Lithe/LitheApp.swift b/Sources/Lithe/LitheApp.swift index 288d8e6d5..cb4cdf49b 100644 --- a/Sources/Lithe/LitheApp.swift +++ b/Sources/Lithe/LitheApp.swift @@ -155,6 +155,20 @@ struct LitheApp: App { CommandMenu("Navigate") { Group { + Button("Back") { + model.navigateBack() + } + .keyboardShortcut("[", modifiers: .command) + .disabled(!model.canNavigateBack) + + Button("Forward") { + model.navigateForward() + } + .keyboardShortcut("]", modifiers: .command) + .disabled(!model.canNavigateForward) + + Divider() + Button("Search Everywhere…") { model.toggleSearchEverywhere() } @@ -186,11 +200,14 @@ struct LitheApp: App { Divider() - Button("Go to Usage") { - model.goToUsages() + Button("Go to Declaration or Usages") { + model.goToDeclarationOrUsages() } .keyboardShortcut("b", modifiers: .command) - .disabled(!model.supportsLanguageServerFeature(.references)) + .disabled( + !model.supportsLanguageServerFeature(.definition) + && !model.supportsLanguageServerFeature(.references) + ) Button("Go to Implementation") { model.goToImplementation() diff --git a/Sources/Lithe/Models/AppModel+Development.swift b/Sources/Lithe/Models/AppModel+Development.swift index 1d1a0195e..8ef450ca8 100644 --- a/Sources/Lithe/Models/AppModel+Development.swift +++ b/Sources/Lithe/Models/AppModel+Development.swift @@ -2,6 +2,22 @@ import Foundation @MainActor extension AppModel { + func navigateBack() { + let current = editorCaret.map { + EditorNavigationTarget(url: $0.url, line: $0.line, utf16Column: $0.utf16Column) + } + guard let destination = editorNavigationFeature.takeBackDestination(from: current) else { return } + navigateEditor(to: destination, recordsHistory: false) + } + + func navigateForward() { + let current = editorCaret.map { + EditorNavigationTarget(url: $0.url, line: $0.line, utf16Column: $0.utf16Column) + } + guard let destination = editorNavigationFeature.takeForwardDestination(from: current) else { return } + navigateEditor(to: destination, recordsHistory: false) + } + func toggleRun() { isRunVisible.toggle() guard isRunVisible else { return } @@ -337,12 +353,16 @@ extension AppModel { isRunVisible = false } - func goToDefinition() { + func goToDefinition(line: Int? = nil, utf16Column: Int? = nil) { guard supportsLanguageServerFeature(.definition) else { showNotification("Definition navigation is not supported by this language server") return } - performGenericNavigation(method: "textDocument/definition", kind: .definitions) + performGenericNavigation( + method: "textDocument/definition", + kind: .definitions, + caretOverride: navigationCaret(line: line, utf16Column: utf16Column) + ) } func goToUsages() { @@ -357,12 +377,86 @@ extension AppModel { ) } - func goToImplementation() { + func goToDeclarationOrUsages() { + guard supportsLanguageServerFeature(.definition) else { + if supportsLanguageServerFeature(.references) { + goToUsages() + } else { + showNotification("Navigation is not supported by this language server") + } + return + } + guard let document = activeDocument, + let caret = editorCaret, + caret.url.standardizedFileURL == document.url.standardizedFileURL, + let workspaceURL, + let provider = languageProviderCatalog.provider(for: document.url) else { + showNotification("Place the caret on a language symbol first") + return + } + + isLoadingLanguageNavigation = true + languageNavigationSubject = navigationSubject(in: document, at: caret) + languageNavigationProviderID = provider.id + languageNavigationResultKind = .definitions + let requestID = UUID() + languageNavigationRequestID = requestID + flushLanguageServerSynchronization(for: document) + do { + try languageToolingSessions.navigate( + method: "textDocument/definition", + fileURL: document.url, + text: document.text, + position: LanguageServerPosition( + line: max(0, caret.line), + utf16Column: max(0, caret.utf16Column) + ), + rootURL: workspaceURL + ) { [weak self] result in + guard let self, self.languageNavigationRequestID == requestID else { return } + switch result { + case .failure(let error): + self.isLoadingLanguageNavigation = false + self.languageNavigationProviderID = nil + self.showNotification(error.localizedDescription) + case .success(let definitions): + let isAtDeclaration = definitions.count == 1 + && definitions.first.map { LanguageNavigationSemantics.contains(caret, in: $0) } == true + if isAtDeclaration, + self.languageToolingSessions.features(for: document.url).contains(.references) { + self.requestUsagesForDeclaration( + document: document, + caret: caret, + workspaceURL: workspaceURL, + requestID: requestID + ) + } else { + self.isLoadingLanguageNavigation = false + self.presentGenericNavigationValues( + definitions, + kind: .definitions, + presentation: .navigateSingle + ) + } + } + } + } catch { + isLoadingLanguageNavigation = false + languageNavigationProviderID = nil + showNotification(error.localizedDescription) + } + } + + func goToImplementation(line: Int? = nil, utf16Column: Int? = nil) { guard supportsLanguageServerFeature(.implementation) else { showNotification("Implementation navigation is not supported by this language server") return } - performGenericNavigation(method: "textDocument/implementation", kind: .implementations) + performGenericNavigation( + method: "textDocument/implementation", + kind: .implementations, + caretOverride: navigationCaret(line: line, utf16Column: utf16Column) + ) } func navigateToSymbol(line: Int, utf16Column: Int, in fileURL: URL) { @@ -383,7 +477,7 @@ extension AppModel { } } - func findReferences() { + func findReferences(line: Int? = nil, utf16Column: Int? = nil) { guard supportsLanguageServerFeature(.references) else { showNotification("Reference navigation is not supported by this language server") return @@ -391,7 +485,8 @@ extension AppModel { performGenericNavigation( method: "textDocument/references", kind: .references, - presentation: .toolWindow + presentation: .toolWindow, + caretOverride: navigationCaret(line: line, utf16Column: utf16Column) ) } @@ -413,6 +508,8 @@ extension AppModel { } func navigate(to location: LanguageNavigationLocation) { + languageNavigationRequestID = UUID() + isLoadingLanguageNavigation = false isLanguageNavigationChooserVisible = false guard location.url.isFileURL else { guard let providerID = languageNavigationProviderID else { @@ -465,6 +562,7 @@ extension AppModel { } func clearLanguageNavigationProjection() { + languageNavigationRequestID = UUID() languageNavigationProviderID = nil languageNavigationLocations = [] languageNavigationPreviews = [:] @@ -781,11 +879,11 @@ extension AppModel { method: String, kind: LanguageNavigationResultKind, presentation: LanguageNavigationPresentation = .navigateSingle, - fallbackToImplementationsIfSelf: Bool = false + fallbackToImplementationsIfSelf: Bool = false, + caretOverride: EditorCaret? = nil ) { - guard !isLoadingLanguageNavigation, - let document = activeDocument, - let caret = editorCaret, + guard let document = activeDocument, + let caret = caretOverride ?? editorCaret, caret.url.standardizedFileURL == document.url.standardizedFileURL, let workspaceURL, let provider = languageProviderCatalog.provider(for: document.url) else { @@ -797,10 +895,13 @@ extension AppModel { isLanguageNavigationChooserVisible = true languageNavigationLocations = [] languageNavigationPreviews = [:] - languageNavigationSubject = editorSelectedText } + languageNavigationSubject = navigationSubject(in: document, at: caret) languageNavigationProviderID = provider.id languageNavigationResultKind = kind + let requestID = UUID() + languageNavigationRequestID = requestID + flushLanguageServerSynchronization(for: document) do { try languageToolingSessions.navigate( method: method, @@ -810,9 +911,20 @@ extension AppModel { line: max(0, caret.line), utf16Column: max(0, caret.utf16Column) ), - rootURL: workspaceURL + rootURL: workspaceURL, + provisional: { [weak self] values in + guard let self, self.languageNavigationRequestID == requestID else { return } + // A direct definition/implementation jump must stay visually + // stable; lexical candidates are only useful in result lists. + if case .navigateSingle = presentation { return } + self.presentProvisionalGenericNavigationValues( + values, + kind: kind, + presentation: presentation + ) + } ) { [weak self] result in - guard let self else { return } + guard let self, self.languageNavigationRequestID == requestID else { return } self.isLoadingLanguageNavigation = false switch result { case .failure(let error): @@ -832,7 +944,8 @@ extension AppModel { caret: caret, workspaceURL: workspaceURL, originalValues: values, - presentation: presentation + presentation: presentation, + requestID: requestID ) return } @@ -853,12 +966,85 @@ extension AppModel { } } + private func requestUsagesForDeclaration( + document: EditorDocument, + caret: EditorCaret, + workspaceURL: URL, + requestID: UUID + ) { + languageNavigationResultKind = .references + isLanguageNavigationChooserVisible = true + languageNavigationLocations = [] + languageNavigationPreviews = [:] + do { + try languageToolingSessions.navigate( + method: "textDocument/references", + fileURL: document.url, + text: document.text, + position: LanguageServerPosition( + line: max(0, caret.line), + utf16Column: max(0, caret.utf16Column) + ), + rootURL: workspaceURL, + provisional: { [weak self] values in + guard let self, self.languageNavigationRequestID == requestID else { return } + self.presentProvisionalGenericNavigationValues( + values, + kind: .references, + presentation: .chooser + ) + } + ) { [weak self] result in + guard let self, self.languageNavigationRequestID == requestID else { return } + self.isLoadingLanguageNavigation = false + switch result { + case .failure(let error): + self.languageNavigationProviderID = nil + self.isLanguageNavigationChooserVisible = false + self.showNotification(error.localizedDescription) + case .success(let values): + self.presentGenericNavigationValues( + values, + kind: .references, + presentation: .chooser + ) + } + } + } catch { + isLoadingLanguageNavigation = false + languageNavigationProviderID = nil + isLanguageNavigationChooserVisible = false + showNotification(error.localizedDescription) + } + } + + private func navigationCaret(line: Int?, utf16Column: Int?) -> EditorCaret? { + guard let document = activeDocument, + let line, + let utf16Column else { return nil } + return EditorCaret( + url: document.url.standardizedFileURL, + line: max(0, line), + utf16Column: max(0, utf16Column) + ) + } + + private func navigationSubject(in document: EditorDocument, at caret: EditorCaret) -> String { + let selected = editorSelectedText.trimmingCharacters(in: .whitespacesAndNewlines) + if !selected.isEmpty { return selected } + return ProjectSymbolNavigation.identifier( + in: document.text, + at: LanguageServerPosition(line: caret.line, utf16Column: caret.utf16Column) + )?.value ?? "" + } + private func requestGenericImplementationFallback( document: EditorDocument, caret: EditorCaret, workspaceURL: URL, originalValues: [LanguageServerLocation], - presentation: LanguageNavigationPresentation + presentation: LanguageNavigationPresentation, + requestID: UUID ) { isLoadingLanguageNavigation = true do { @@ -872,7 +1058,7 @@ extension AppModel { ), rootURL: workspaceURL ) { [weak self] result in - guard let self else { return } + guard let self, self.languageNavigationRequestID == requestID else { return } self.isLoadingLanguageNavigation = false if case .success(let implementations) = result, !implementations.isEmpty { self.presentGenericNavigationValues( @@ -933,6 +1119,31 @@ extension AppModel { } } + private func presentProvisionalGenericNavigationValues( + _ values: [LanguageServerLocation], + kind: LanguageNavigationResultKind, + presentation: LanguageNavigationPresentation + ) { + guard !values.isEmpty else { return } + let locations = values.map { + LanguageNavigationLocation( + url: $0.url, + range: $0.range, + isReadOnly: $0.isReadOnly, + displayPath: $0.displayPath + ) + } + languageNavigationResultKind = kind + languageNavigationLocations = locations + loadLanguageNavigationPreviews(for: locations) + // Lexical index results are intentionally never auto-jumped. The LSP + // may replace them with the semantic destination a moment later. + presentLanguageNavigationResults( + kind, + presentation: presentation == .toolWindow ? .toolWindow : .chooser + ) + } + private func loadLanguageNavigationPreviews(for locations: [LanguageNavigationLocation]) { let requestID = UUID() languageNavigationPreviewRequestID = requestID diff --git a/Sources/Lithe/Models/AppModel.swift b/Sources/Lithe/Models/AppModel.swift index 7891dc1e1..4e5f5b8a8 100644 --- a/Sources/Lithe/Models/AppModel.swift +++ b/Sources/Lithe/Models/AppModel.swift @@ -83,9 +83,12 @@ final class AppModel: ObservableObject, Identifiable { @Published var languageNavigationResultKind: LanguageNavigationResultKind = .definitions @Published var languageNavigationSubject = "" @Published var isLoadingLanguageNavigation = false + var languageNavigationRequestID = UUID() var languageNavigationPreviewRequestID = UUID() @Published var editorCaret: EditorCaret? var editorNavigationTarget: EditorNavigationTarget? { editorNavigationFeature.target } + var canNavigateBack: Bool { editorNavigationFeature.canNavigateBack } + var canNavigateForward: Bool { editorNavigationFeature.canNavigateForward } var javaCodeVisionHints: [URL: [JavaCodeVisionHint]] { javaFeature.javaCodeVisionHints } @@ -138,6 +141,7 @@ final class AppModel: ObservableObject, Identifiable { private var projectHistoryFeatureObservation: AnyCancellable? private var databaseFeatureObservation: AnyCancellable? private var editorNavigationFeatureObservation: AnyCancellable? + private var languageServerSynchronizationTasks: [URL: Task] = [:] var detectedCodexConfiguration: CodexConfigurationSnapshot? { detectedAIConfigurations.first { $0.source == .codex } @@ -381,6 +385,7 @@ final class AppModel: ObservableObject, Identifiable { }, processExternalChanges: { [weak self] paths in guard let self else { return false } + self.languageToolingSessions.invalidateNavigationCache() let conflict = self.documentFeature.processExternalChanges(paths) self.projectHistoryFeature.recordExternalChanges(paths) return conflict @@ -551,6 +556,7 @@ final class AppModel: ObservableObject, Identifiable { func shutdownProjectSession() { doubleShiftDetector?.stop() + cancelLanguageServerSynchronizationTasks() languageToolingSessions.stopAll() languageTestService.stop() stopTerminalSessions() @@ -738,6 +744,7 @@ final class AppModel: ObservableObject, Identifiable { // every provider session before replacing the catalog or clearing the // document projection so no old-root documents, diagnostics, or // responses can survive into the next workspace. + cancelLanguageServerSynchronizationTasks() languageToolingSessions.stopAll() reloadLanguageProviderCatalog(for: normalizedURL) stopTerminalSessions() @@ -830,6 +837,7 @@ final class AppModel: ObservableObject, Identifiable { isTestsVisible = false isDebugVisible = false stopTerminalSessions() + cancelLanguageServerSynchronizationTasks() languageToolingSessions.stopAll() languageTestService.reset() runtimeFeature.closeProject() @@ -890,13 +898,21 @@ final class AppModel: ObservableObject, Identifiable { func navigateEditor( to target: EditorNavigationTarget, isReadOnly: Bool = false, - displayPath: String? = nil + displayPath: String? = nil, + recordsHistory: Bool = true ) { selectedChange = nil closeBranchComparison() let normalizedURL = target.url.standardizedFileURL let normalizedTarget = EditorNavigationTarget(url: normalizedURL, range: target.range) - editorNavigationFeature.navigate(to: normalizedTarget) { [documentFeature] in + let source = editorCaret.map { + EditorNavigationTarget(url: $0.url.standardizedFileURL, line: $0.line, utf16Column: $0.utf16Column) + } + editorNavigationFeature.navigate( + to: normalizedTarget, + from: source, + recordsHistory: recordsHistory + ) { [documentFeature] in guard let document = await documentFeature.openFileAsync( normalizedURL, isReadOnly: isReadOnly, @@ -1064,7 +1080,7 @@ final class AppModel: ObservableObject, Identifiable { } private func handleDocumentChanged(_ document: EditorDocument) { - activateLanguageServerIfAvailable(for: document) + scheduleLanguageServerSynchronization(for: document) Task { @MainActor [weak self, weak document] in try? await Task.sleep(for: .milliseconds(450)) guard !Task.isCancelled, let self, let document else { return } @@ -1075,6 +1091,9 @@ final class AppModel: ObservableObject, Identifiable { } private func handleDocumentClosed(_ document: EditorDocument) { + let url = document.url.standardizedFileURL + languageServerSynchronizationTasks[url]?.cancel() + languageServerSynchronizationTasks[url] = nil languageToolingSessions.closeDocument(document.url) if javaFeature.handles(fileURL: document.url) { javaFeature.close(document) @@ -1114,6 +1133,29 @@ final class AppModel: ObservableObject, Identifiable { } } + func flushLanguageServerSynchronization(for document: EditorDocument) { + let url = document.url.standardizedFileURL + languageServerSynchronizationTasks[url]?.cancel() + languageServerSynchronizationTasks[url] = nil + _ = activateLanguageServerIfAvailable(for: document) + } + + private func scheduleLanguageServerSynchronization(for document: EditorDocument) { + let url = document.url.standardizedFileURL + languageServerSynchronizationTasks[url]?.cancel() + languageServerSynchronizationTasks[url] = Task { @MainActor [weak self, weak document] in + try? await Task.sleep(for: .milliseconds(120)) + guard !Task.isCancelled, let self, let document else { return } + _ = self.activateLanguageServerIfAvailable(for: document) + self.languageServerSynchronizationTasks[url] = nil + } + } + + private func cancelLanguageServerSynchronizationTasks() { + for task in languageServerSynchronizationTasks.values { task.cancel() } + languageServerSynchronizationTasks.removeAll() + } + func searchProject(options: ProjectSearchOptions = .default) async { guard let workspaceURL else { return } let query = searchQuery diff --git a/Sources/Lithe/Models/JavaNavigationModels.swift b/Sources/Lithe/Models/JavaNavigationModels.swift index aac7f3134..f237e173a 100644 --- a/Sources/Lithe/Models/JavaNavigationModels.swift +++ b/Sources/Lithe/Models/JavaNavigationModels.swift @@ -78,6 +78,36 @@ struct LanguageNavigationLocation: Identifiable, Hashable, Sendable { var displayName: String { displayPath?.split(separator: "/").last.map(String.init) ?? url.lastPathComponent } } +enum LanguageNavigationSemantics { + /// LSP definition ranges normally identify the declaration token. Treat a + /// caret inside that token as already being at the declaration so the + /// declaration-or-usages command can switch to references. + static func contains( + _ caret: EditorCaret, + in location: LanguageServerLocation + ) -> Bool { + guard caret.url.standardizedFileURL == location.url.standardizedFileURL else { + return false + } + let position = LanguageServerPosition( + line: caret.line, + utf16Column: caret.utf16Column + ) + let range = location.range + if range.start == range.end { return position == range.start } + return isOrdered(range.start, beforeOrEqualTo: position) + && isOrdered(position, beforeOrEqualTo: range.end) + } + + private static func isOrdered( + _ lhs: LanguageServerPosition, + beforeOrEqualTo rhs: LanguageServerPosition + ) -> Bool { + lhs.line < rhs.line + || (lhs.line == rhs.line && lhs.utf16Column <= rhs.utf16Column) + } +} + struct JavaWorkspaceSymbol: Identifiable, Hashable, Sendable { let name: String let containerName: String? diff --git a/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 17bb8d4b4..8296db333 100644 --- a/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -32,6 +32,7 @@ final class MacServiceContainer { let rustCore = RustCoreBridge() let javaMavenOperations = RustJavaMavenOperations(core: rustCore) let fileStorage = MacFileStorage() + let workspaceOperations = RustWorkspaceOperations(core: rustCore) runConfigurationStore = MacRunConfigurationStore( core: rustCore, storage: fileStorage, @@ -133,7 +134,13 @@ final class MacServiceContainer { catalog: languagePackRegistry.catalog, runtimes: languagePackRegistry.toolingRuntimes, runtimeFactory: languageToolingRuntimeFactory, - core: rustCore + core: rustCore, + languageFeatureProviders: [ + ProjectSymbolFeatureProvider( + operations: workspaceOperations, + visibilityRules: { settings.fileVisibilityRules } + ) + ] ) let testExecutableResolver = RunExecutableResolver( runtimeService: runtimeService, @@ -174,7 +181,6 @@ final class MacServiceContainer { runConfigurationOperations: runConfigurationStore ) let gitOperations = RustGitOperations(core: rustCore) - let workspaceOperations = RustWorkspaceOperations(core: rustCore) let localHistoryOperations = RustLocalHistoryOperations(core: rustCore) let markdownRenderer = RustMarkdownRendering(core: rustCore) let markdownImageImporter = MarkdownImageImportService(storage: fileStorage) diff --git a/Sources/Lithe/Services/LanguageToolingSessionManager.swift b/Sources/Lithe/Services/LanguageToolingSessionManager.swift index 0b94c7c72..5c13e3070 100644 --- a/Sources/Lithe/Services/LanguageToolingSessionManager.swift +++ b/Sources/Lithe/Services/LanguageToolingSessionManager.swift @@ -24,6 +24,25 @@ enum LanguageToolingSessionError: LocalizedError, Equatable, Sendable { /// and lightweight local providers without exposing either implementation. @MainActor final class LanguageToolingSessionManager: ObservableObject { + private struct NavigationCacheKey: Hashable { + let method: String + let fileURL: URL + let rootURL: URL + let position: LanguageServerPosition + let textFingerprint: Int + } + + private struct CachedNavigationResult { + let storedAt: Date + let locations: [LanguageServerLocation] + } + + private struct PendingNavigationRequest { + var completions: [(Result<[LanguageServerLocation], Error>) -> Void] + var provisionalObservers: [([LanguageServerLocation]) -> Void] + var latestProvisional: [LanguageServerLocation]? + } + @Published private(set) var diagnostics: [URL: [LanguageServerDiagnostic]] = [:] @Published private(set) var languageServerFeatures: [String: LanguageServerFeatureSet] = [:] @Published private(set) var languageServerLogs: [LanguageServerLogEntry] = [] @@ -48,6 +67,10 @@ final class LanguageToolingSessionManager: ObservableObject { private var debugAdapters: [String: any DebugAdapterSession] = [:] private var debugAdapterRoots: [String: URL] = [:] private var requestedBreakpoints: [String: [URL: [DebugSourceBreakpoint]]] = [:] + private var synchronizedDocumentFingerprints: [URL: Int] = [:] + private var navigationCache: [NavigationCacheKey: CachedNavigationResult] = [:] + private var pendingNavigationRequests: [NavigationCacheKey: PendingNavigationRequest] = [:] + private let navigationCacheLifetime: TimeInterval = 8 init( catalog: LanguageProviderCatalog = .standard, @@ -89,6 +112,7 @@ final class LanguageToolingSessionManager: ObservableObject { .filter { previousDescriptors[$0] != updatedDescriptors[$0] } self.catalog = catalog + invalidateNavigationCache() let validProviderIDs = Set(catalog.descriptors.map(\.id)) languageServerFeatures = languageServerFeatures.filter { validProviderIDs.contains($0.key) } languageServerStates = languageServerStates.filter { validProviderIDs.contains($0.key) } @@ -240,17 +264,28 @@ final class LanguageToolingSessionManager: ObservableObject { ) session = created } + let standardizedFileURL = fileURL.standardizedFileURL + let fingerprint = Self.textFingerprint(text) + if synchronizedDocumentFingerprints[standardizedFileURL] == fingerprint { + return + } + // Any document change can affect references and definitions in another + // file, so invalidate the small semantic cache conservatively. + invalidateNavigationCache() try session.synchronize( fileURL: fileURL, text: text, languageID: descriptor.languageIdentifier(for: fileURL) ) + synchronizedDocumentFingerprints[standardizedFileURL] = fingerprint } func closeDocument(_ fileURL: URL) { let standardizedURL = fileURL.standardizedFileURL clearDiagnostics(for: standardizedURL) languageServerSession(for: standardizedURL)?.closeDocument(standardizedURL) + synchronizedDocumentFingerprints[standardizedURL] = nil + invalidateNavigationCache() } func clearDiagnostics() { @@ -305,6 +340,8 @@ final class LanguageToolingSessionManager: ObservableObject { languageServerInfos[providerID] = nil languageServerFeatureProviders[providerID] = nil languageServerStates[providerID] = .stopped + synchronizedDocumentFingerprints.removeAll() + invalidateNavigationCache() } func stopAllLanguageServers() { @@ -325,6 +362,8 @@ final class LanguageToolingSessionManager: ObservableObject { languageServerSessionIdentities.removeAll() languageServerFeatureProviders.removeAll() languageServerStates = [:] + synchronizedDocumentFingerprints.removeAll() + invalidateNavigationCache() for session in sessions { session.stop() } } @@ -334,6 +373,7 @@ final class LanguageToolingSessionManager: ObservableObject { text: String, position: LanguageServerPosition, rootURL: URL, + provisional: (([LanguageServerLocation]) -> Void)? = nil, completion: @escaping (Result<[LanguageServerLocation], Error>) -> Void ) throws { let context = featureContext( @@ -349,13 +389,180 @@ final class LanguageToolingSessionManager: ObservableObject { guard !providers.isEmpty else { throw unavailableLanguageServerError(for: fileURL) } + let cacheKey = NavigationCacheKey( + method: method, + fileURL: fileURL.standardizedFileURL, + rootURL: rootURL.standardizedFileURL, + position: position, + textFingerprint: Self.textFingerprint(text) + ) + if let cached = cachedNavigationResult(for: cacheKey) { + recordNavigationTiming( + fileURL: fileURL, + method: method, + source: "cache", + startedAt: Date(), + resultCount: cached.count + ) + completion(.success(cached)) + return + } + if var pending = pendingNavigationRequests[cacheKey] { + pending.completions.append(completion) + if let provisional { + pending.provisionalObservers.append(provisional) + if let latest = pending.latestProvisional { provisional(latest) } + } + pendingNavigationRequests[cacheKey] = pending + return + } + pendingNavigationRequests[cacheKey] = PendingNavigationRequest( + completions: [completion], + provisionalObservers: provisional.map { [$0] } ?? [], + latestProvisional: nil + ) + + let languageServers = providers.filter { $0.priority == .languageServer } + let projectSymbols = providers.filter { $0.priority == .projectSymbols } + let fallback = providers.filter { $0.priority < .projectSymbols } + guard !languageServers.isEmpty else { + routeNavigation( + providers: providers, + index: 0, + method: method, + context: context + ) { [self] result in + let locations = (try? result.get()) ?? [] + if !locations.isEmpty { storeNavigationResult(locations, for: cacheKey) } + completeNavigationRequest(cacheKey, with: .success(locations)) + } + return + } + + let startedAt = Date() + guard !projectSymbols.isEmpty else { + routeNavigation( + providers: languageServers, + index: 0, + method: method, + context: context + ) { [self] result in + let locations = (try? result.get()) ?? [] + guard !locations.isEmpty else { + routeNavigation( + providers: fallback, + index: 0, + method: method, + context: context + ) { [self] fallbackResult in + let fallbackLocations = (try? fallbackResult.get()) ?? [] + recordNavigationTiming( + fileURL: fileURL, + method: method, + source: "builtin", + startedAt: startedAt, + resultCount: fallbackLocations.count + ) + if !fallbackLocations.isEmpty { + storeNavigationResult(fallbackLocations, for: cacheKey) + } + completeNavigationRequest(cacheKey, with: .success(fallbackLocations)) + } + return + } + storeNavigationResult(locations, for: cacheKey) + recordNavigationTiming( + fileURL: fileURL, + method: method, + source: "language-server", + startedAt: startedAt, + resultCount: locations.count + ) + completeNavigationRequest(cacheKey, with: .success(locations)) + } + return + } + + var isFinished = false + var languageServerFinished = false + var projectSymbolsFinished = false + var projectLocations: [LanguageServerLocation] = [] + + func finish(_ locations: [LanguageServerLocation], source: String, cache: Bool) { + guard !isFinished else { return } + isFinished = true + if cache, !locations.isEmpty { + storeNavigationResult(locations, for: cacheKey) + } + recordNavigationTiming( + fileURL: fileURL, + method: method, + source: source, + startedAt: startedAt, + resultCount: locations.count + ) + completeNavigationRequest(cacheKey, with: .success(locations)) + } + + func finishWithFallback() { + guard !isFinished else { return } + routeNavigation( + providers: fallback, + index: 0, + method: method, + context: context + ) { result in + finish((try? result.get()) ?? [], source: "builtin", cache: true) + } + } + routeNavigation( - providers: providers, + providers: projectSymbols, index: 0, method: method, - context: context, - completion: completion - ) + context: context + ) { [self] result in + guard !isFinished else { return } + projectSymbolsFinished = true + projectLocations = (try? result.get()) ?? [] + if !projectLocations.isEmpty { + recordNavigationTiming( + fileURL: fileURL, + method: method, + source: "project-index-provisional", + startedAt: startedAt, + resultCount: projectLocations.count + ) + publishNavigationProvisional(projectLocations, for: cacheKey) + } + if languageServerFinished { + if projectLocations.isEmpty { + finishWithFallback() + } else { + finish(projectLocations, source: "project-index", cache: true) + } + } + } + + routeNavigation( + providers: languageServers, + index: 0, + method: method, + context: context + ) { result in + guard !isFinished else { return } + languageServerFinished = true + let locations = (try? result.get()) ?? [] + if !locations.isEmpty { + finish(locations, source: "language-server", cache: true) + } else if projectSymbolsFinished { + if projectLocations.isEmpty { + finishWithFallback() + } else { + finish(projectLocations, source: "project-index", cache: true) + } + } + } } func hover( @@ -630,6 +837,8 @@ final class LanguageToolingSessionManager: ObservableObject { lastDebugEvents = [:] verifiedBreakpoints = [:] requestedBreakpoints = [:] + synchronizedDocumentFingerprints.removeAll() + invalidateNavigationCache() } @discardableResult @@ -929,6 +1138,71 @@ final class LanguageToolingSessionManager: ObservableObject { ) } + func invalidateNavigationCache() { + navigationCache.removeAll(keepingCapacity: true) + } + + private func cachedNavigationResult(for key: NavigationCacheKey) -> [LanguageServerLocation]? { + let now = Date() + navigationCache = navigationCache.filter { + now.timeIntervalSince($0.value.storedAt) <= navigationCacheLifetime + } + return navigationCache[key]?.locations + } + + private func storeNavigationResult( + _ locations: [LanguageServerLocation], + for key: NavigationCacheKey + ) { + if navigationCache.count >= 128, + let oldest = navigationCache.min(by: { $0.value.storedAt < $1.value.storedAt })?.key { + navigationCache[oldest] = nil + } + navigationCache[key] = CachedNavigationResult(storedAt: Date(), locations: locations) + } + + private func publishNavigationProvisional( + _ locations: [LanguageServerLocation], + for key: NavigationCacheKey + ) { + guard var pending = pendingNavigationRequests[key] else { return } + pending.latestProvisional = locations + pendingNavigationRequests[key] = pending + for observer in pending.provisionalObservers { observer(locations) } + } + + private func completeNavigationRequest( + _ key: NavigationCacheKey, + with result: Result<[LanguageServerLocation], Error> + ) { + guard let pending = pendingNavigationRequests.removeValue(forKey: key) else { return } + for completion in pending.completions { completion(result) } + } + + private func recordNavigationTiming( + fileURL: URL, + method: String, + source: String, + startedAt: Date, + resultCount: Int + ) { + let providerID = catalog.provider(for: fileURL)?.id ?? "language-tooling" + let elapsedMilliseconds = max(0, Int(Date().timeIntervalSince(startedAt) * 1_000)) + recordLanguageServerLog( + providerID: providerID, + level: .info, + message: "Navigation completed", + detail: "method=\(method) source=\(source) elapsedMs=\(elapsedMilliseconds) results=\(resultCount)" + ) + } + + private static func textFingerprint(_ text: String) -> Int { + var hasher = Hasher() + hasher.combine(text.utf16.count) + hasher.combine(text) + return hasher.finalize() + } + private func configureLanguageServerCallbacks( _ session: any LanguageServerSession, providerID: String, diff --git a/Sources/Lithe/Services/ProjectSymbolFeatureProvider.swift b/Sources/Lithe/Services/ProjectSymbolFeatureProvider.swift new file mode 100644 index 000000000..f8cce825a --- /dev/null +++ b/Sources/Lithe/Services/ProjectSymbolFeatureProvider.swift @@ -0,0 +1,276 @@ +import Foundation + +/// Fast, language-neutral navigation candidates backed by the workspace text index. +/// +/// These locations are lexical candidates rather than semantic truth. The session +/// manager may surface them while an LSP request is in flight, then replace them +/// with the authoritative server response. +@MainActor +final class ProjectSymbolFeatureProvider: LanguageFeatureProvider { + let id = "project-symbols" + let priority: LanguageFeatureProviderPriority = .projectSymbols + + private let operations: any WorkspaceOperations + private let visibilityRules: () -> FileVisibilityRules + + init( + operations: any WorkspaceOperations, + visibilityRules: @escaping () -> FileVisibilityRules + ) { + self.operations = operations + self.visibilityRules = visibilityRules + } + + func supports(_ feature: LanguageFeature, in context: LanguageFeatureRequestContext) -> Bool { + guard case .navigation = feature, + context.workspaceURL != nil else { return false } + return ProjectSymbolNavigation.identifier( + in: context.text, + at: context.position + ) != nil + } + + func completions( + in _: LanguageFeatureRequestContext, + completion: @escaping (Result<[LanguageServerCompletionItem], Error>) -> Void + ) throws { + completion(.success([])) + } + + func hover( + in _: LanguageFeatureRequestContext, + completion: @escaping (Result) -> Void + ) throws { + completion(.success(nil)) + } + + func navigate( + method: String, + in context: LanguageFeatureRequestContext, + completion: @escaping (Result<[LanguageServerLocation], Error>) -> Void + ) throws { + guard let rootURL = context.workspaceURL, + let symbol = ProjectSymbolNavigation.identifier( + in: context.text, + at: context.position + ) else { + completion(.success([])) + return + } + + let operations = operations + let rules = visibilityRules() + Task { @MainActor in + let locations = await Task.detached(priority: .userInitiated) { + ProjectSymbolNavigation.locations( + method: method, + symbol: symbol, + currentFileURL: context.fileURL, + currentText: context.text, + rootURL: rootURL, + operations: operations, + visibilityRules: rules + ) + }.value + completion(.success(locations)) + } + } +} + +enum ProjectSymbolNavigation { + struct Identifier: Equatable, Sendable { + let value: String + let range: LanguageServerRange + } + + private struct RankedLocation: Sendable { + let location: LanguageServerLocation + let score: Int + } + + static func identifier( + in text: String, + at position: LanguageServerPosition + ) -> Identifier? { + guard position.line >= 0, position.utf16Column >= 0, + let line = line(in: text, number: position.line) else { return nil } + let source = line as NSString + guard source.length > 0 else { return nil } + + var cursor = min(position.utf16Column, source.length) + if cursor == source.length || !isIdentifierUnit(source.character(at: cursor)) { + guard cursor > 0, isIdentifierUnit(source.character(at: cursor - 1)) else { return nil } + cursor -= 1 + } + var start = cursor + var end = cursor + 1 + while start > 0, isIdentifierUnit(source.character(at: start - 1)) { start -= 1 } + while end < source.length, isIdentifierUnit(source.character(at: end)) { end += 1 } + guard end > start else { return nil } + + let value = source.substring(with: NSRange(location: start, length: end - start)) + return Identifier( + value: value, + range: LanguageServerRange( + start: LanguageServerPosition(line: position.line, utf16Column: start), + end: LanguageServerPosition(line: position.line, utf16Column: end) + ) + ) + } + + static func locations( + method: String, + symbol: Identifier, + currentFileURL: URL, + currentText: String, + rootURL: URL, + operations: any WorkspaceOperations, + visibilityRules: FileVisibilityRules + ) -> [LanguageServerLocation] { + var options = ProjectSearchOptions.default + options.caseSensitive = true + options.wholeWords = true + let indexedResults = operations.search( + at: rootURL, + query: symbol.value, + options: options, + visibilityRules: visibilityRules + ) ?? [] + + let currentURL = currentFileURL.standardizedFileURL + var requestedLinesByURL: [URL: Set] = [:] + for result in indexedResults where result.kind == .content { + guard let line = result.line, line > 0 else { continue } + requestedLinesByURL[result.url.standardizedFileURL, default: []].insert(line - 1) + } + // The editor buffer can be newer than the on-disk index. Scan it in full + // and replace any stale indexed view of the same file. + requestedLinesByURL[currentURL] = Set(currentText.split( + separator: "\n", + omittingEmptySubsequences: false + ).indices) + + var ranked: [RankedLocation] = [] + for (url, requestedLines) in requestedLinesByURL { + let source: String + if url == currentURL { + source = currentText + } else { + guard let relativePath = relativePath(for: url, rootURL: rootURL), + let loaded = operations.readFile(at: rootURL, relativePath: relativePath) else { + continue + } + source = loaded + } + let lines = source.split(separator: "\n", omittingEmptySubsequences: false) + for lineNumber in requestedLines.sorted() where lines.indices.contains(lineNumber) { + let lineText = String(lines[lineNumber]) + for range in exactRanges(of: symbol.value, in: lineText) { + let location = LanguageServerLocation( + url: url, + range: LanguageServerRange( + start: LanguageServerPosition( + line: lineNumber, + utf16Column: range.location + ), + end: LanguageServerPosition( + line: lineNumber, + utf16Column: range.location + range.length + ) + ) + ) + ranked.append(RankedLocation( + location: location, + score: structuralScore( + method: method, + line: lineText, + symbolRange: range + ) + )) + } + } + } + + ranked.sort { + if $0.score != $1.score { return $0.score > $1.score } + let lhsPath = $0.location.url.standardizedFileURL.path + let rhsPath = $1.location.url.standardizedFileURL.path + if lhsPath != rhsPath { return lhsPath < rhsPath } + if $0.location.range.start.line != $1.location.range.start.line { + return $0.location.range.start.line < $1.location.range.start.line + } + return $0.location.range.start.utf16Column < $1.location.range.start.utf16Column + } + + if method == "textDocument/definition" || method == "textDocument/implementation" { + let structural = ranked.filter { $0.score > 0 } + if !structural.isEmpty { ranked = structural } + } + return Array(ranked.prefix(500).map(\.location)) + } + + private static func exactRanges(of symbol: String, in line: String) -> [NSRange] { + let source = line as NSString + let query = symbol as NSString + guard query.length > 0 else { return [] } + var result: [NSRange] = [] + var searchRange = NSRange(location: 0, length: source.length) + while searchRange.length > 0 { + let match = source.range(of: symbol, options: .literal, range: searchRange) + guard match.location != NSNotFound else { break } + let leftIsBoundary = match.location == 0 || + !isIdentifierUnit(source.character(at: match.location - 1)) + let matchEnd = match.location + match.length + let rightIsBoundary = matchEnd == source.length || + !isIdentifierUnit(source.character(at: matchEnd)) + if leftIsBoundary && rightIsBoundary { result.append(match) } + let next = max(matchEnd, match.location + 1) + searchRange = NSRange(location: next, length: source.length - next) + } + return result + } + + private static func structuralScore(method: String, line: String, symbolRange: NSRange) -> Int { + let prefix = (line as NSString).substring(to: symbolRange.location).lowercased() + let tokens = prefix.split { !$0.isLetter && !$0.isNumber && $0 != "_" } + let nearby = Set(tokens.suffix(4).map(String.init)) + let definitionMarkers: Set = [ + "class", "struct", "interface", "protocol", "trait", "enum", "record", "union", + "type", "typealias", "func", "function", "fn", "def", "const", "let", "var" + ] + let implementationMarkers: Set = ["impl", "implements", "override", "extension"] + switch method { + case "textDocument/implementation": + return nearby.intersection(implementationMarkers).isEmpty ? 0 : 2 + case "textDocument/definition": + return nearby.intersection(definitionMarkers).isEmpty ? 0 : 2 + default: + return 0 + } + } + + private static func line(in text: String, number: Int) -> String? { + guard number >= 0 else { return nil } + let lines = text.split(separator: "\n", omittingEmptySubsequences: false) + guard lines.indices.contains(number) else { return nil } + return String(lines[number]) + } + + private static func isIdentifierUnit(_ unit: unichar) -> Bool { + switch unit { + case 48...57, 65...90, 97...122, 95, 36: + return true + default: + // Treat non-ASCII UTF-16 units as identifier constituents. This + // keeps Unicode identifiers intact without assuming a language. + return unit >= 0x80 + } + } + + private static func relativePath(for url: URL, rootURL: URL) -> String? { + let rootPath = rootURL.standardizedFileURL.path + let path = url.standardizedFileURL.path + guard path.hasPrefix(rootPath + "/") else { return nil } + return String(path.dropFirst(rootPath.count + 1)) + } +} diff --git a/Sources/Lithe/Views/CodeEditorView.swift b/Sources/Lithe/Views/CodeEditorView.swift index b56bf4055..997c18e88 100644 --- a/Sources/Lithe/Views/CodeEditorView.swift +++ b/Sources/Lithe/Views/CodeEditorView.swift @@ -193,9 +193,15 @@ struct CodeEditorView: NSViewRepresentable { textView.onNavigateToSymbol = { [weak model] line, utf16Column in model?.navigateToSymbol(line: line, utf16Column: utf16Column, in: document.url) } - textView.onGoToDefinition = { [weak model] in model?.goToDefinition() } - textView.onGoToImplementation = { [weak model] in model?.goToImplementation() } - textView.onFindUsages = { [weak model] in model?.findReferences() } + textView.onGoToDefinition = { [weak model] line, column in + model?.goToDefinition(line: line, utf16Column: column) + } + textView.onGoToImplementation = { [weak model] line, column in + model?.goToImplementation(line: line, utf16Column: column) + } + textView.onFindUsages = { [weak model] line, column in + model?.findReferences(line: line, utf16Column: column) + } textView.onFindRequested = { [weak model] in model?.showFindBar() } textView.onFindNextRequested = { [weak model] in model?.navigateFind(offset: 1) } textView.onFindPreviousRequested = { [weak model] in model?.navigateFind(offset: -1) } @@ -919,9 +925,9 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { var languageServerFeatures: LanguageServerFeatureSet = [] var onWindowAttached: (() -> Void)? var onNavigateToSymbol: ((Int, Int) -> Void)? - var onGoToDefinition: (() -> Void)? - var onGoToImplementation: (() -> Void)? - var onFindUsages: (() -> Void)? + var onGoToDefinition: ((Int, Int) -> Void)? + var onGoToImplementation: ((Int, Int) -> Void)? + var onFindUsages: ((Int, Int) -> Void)? var onFindRequested: (() -> Void)? var onFindNextRequested: (() -> Void)? var onFindPreviousRequested: (() -> Void)? @@ -1968,7 +1974,8 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { } @objc private func goToDefinitionFromMenu() { - onGoToDefinition?() + let position = languageServerPosition(at: selectedRange().location) + onGoToDefinition?(position.line, position.utf16Column) } @objc private func showQuickDocumentationFromMenu() { @@ -2123,11 +2130,13 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { } @objc private func goToImplementationFromMenu() { - onGoToImplementation?() + let position = languageServerPosition(at: selectedRange().location) + onGoToImplementation?(position.line, position.utf16Column) } @objc private func findUsagesFromMenu() { - onFindUsages?() + let position = languageServerPosition(at: selectedRange().location) + onFindUsages?(position.line, position.utf16Column) } override init(frame frameRect: NSRect) { diff --git a/Sources/Lithe/Views/JavaReferencesView.swift b/Sources/Lithe/Views/JavaReferencesView.swift index c229e0221..7bf240ad0 100644 --- a/Sources/Lithe/Views/JavaReferencesView.swift +++ b/Sources/Lithe/Views/JavaReferencesView.swift @@ -2,11 +2,13 @@ import SwiftUI struct LanguageReferencesView: View { @EnvironmentObject private var model: AppModel + @State private var query = "" var body: some View { VStack(spacing: 0) { toolbar - if model.languageNavigationResults.isEmpty { + filterBar + if filteredLocations.isEmpty { emptyState } else { results @@ -15,6 +17,29 @@ struct LanguageReferencesView: View { .background(LitheTheme.sidebar) } + private var filterBar: some View { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.system(size: 11)) + .foregroundStyle(LitheTheme.secondaryText) + TextField("Filter results", text: $query) + .textFieldStyle(.plain) + .font(.system(size: 12)) + if !query.isEmpty { + Button { query = "" } label: { + Image(systemName: "xmark.circle.fill") + } + .litheIconButton() + } + } + .padding(.horizontal, 10) + .frame(height: 32) + .background(LitheTheme.popupBackground) + .overlay(alignment: .bottom) { + Rectangle().fill(LitheTheme.divider).frame(height: 1) + } + } + private var toolbar: some View { LitheToolWindowHeader( title: model.languageNavigationKind.title, @@ -33,31 +58,16 @@ struct LanguageReferencesView: View { private var results: some View { ScrollView(.vertical) { LazyVStack(spacing: 1) { - ForEach(model.languageNavigationResults) { location in + ForEach(filteredLocations) { location in Button { model.navigate(to: location) } label: { - HStack(spacing: 10) { - Image(systemName: "chevron.left.forwardslash.chevron.right") - .font(.system(size: 11)) - .foregroundStyle(LitheTheme.accent) - .frame(width: 16) - Text(location.displayName) - .font(.system(size: 12.5, weight: .medium)) - .foregroundStyle(LitheTheme.primaryText) - Text(location.displayPath ?? model.relativePath(for: location.url)) - .font(.system(size: 10.5)) - .foregroundStyle(LitheTheme.secondaryText) - .lineLimit(1) - Spacer() - Text("\(location.line + 1):\(location.utf16Column + 1)") - .font(.system(size: 10.5, design: .monospaced)) - .foregroundStyle(LitheTheme.secondaryText) - } - .padding(.horizontal, 10) - .frame(maxWidth: .infinity) - .frame(height: 30) - .contentShape(Rectangle()) + LanguageNavigationResultRow( + location: location, + parentPath: parentPath(for: location), + preview: model.languageNavigationPreviews[location.id] ?? "", + isSelected: false + ) } .buttonStyle(.plain) .lithePointer() @@ -67,6 +77,21 @@ struct LanguageReferencesView: View { } } + private var filteredLocations: [LanguageNavigationLocation] { + guard !query.isEmpty else { return model.languageNavigationResults } + return model.languageNavigationResults.filter { + $0.displayName.localizedCaseInsensitiveContains(query) + || parentPath(for: $0).localizedCaseInsensitiveContains(query) + || (model.languageNavigationPreviews[$0.id] ?? "") + .localizedCaseInsensitiveContains(query) + } + } + + private func parentPath(for location: LanguageNavigationLocation) -> String { + let path = location.displayPath ?? model.relativePath(for: location.url) + return (path as NSString).deletingLastPathComponent + } + private var emptyState: some View { Text("No navigation results") .font(LitheTheme.uiFont) @@ -186,35 +211,12 @@ struct LanguageNavigationChooserView: View { private func resultRow(_ location: LanguageNavigationLocation, index: Int) -> some View { Button { navigate(to: location) } label: { - HStack(spacing: 9) { - LitheIcon(kind: LitheIcons.kind(for: location.url, isDirectory: false), size: 15) - .frame(width: 18) - Text(location.displayName) - .font(.system(size: 12.5, weight: .medium)) - .foregroundStyle(LitheTheme.primaryText) - .lineLimit(1) - .frame(width: 170, alignment: .leading) - Text(parentPath(for: location)) - .font(.system(size: 10.5)) - .foregroundStyle(LitheTheme.secondaryText) - .lineLimit(1) - .truncationMode(.middle) - .frame(width: 150, alignment: .leading) - Text("\(location.line + 1)") - .font(.system(size: 11, design: .monospaced)) - .foregroundStyle(LitheTheme.secondaryText) - .frame(width: 44, alignment: .trailing) - Text(model.languageNavigationPreviews[location.id] ?? "") - .font(.system(size: 12, design: .monospaced)) - .foregroundStyle(LitheTheme.primaryText) - .lineLimit(1) - Spacer(minLength: 0) - } - .padding(.horizontal, 9) - .frame(maxWidth: .infinity) - .frame(height: 29) - .background(index == selectedIndex ? LitheTheme.selection : .clear) - .contentShape(Rectangle()) + LanguageNavigationResultRow( + location: location, + parentPath: parentPath(for: location), + preview: model.languageNavigationPreviews[location.id] ?? "", + isSelected: index == selectedIndex + ) } .buttonStyle(.plain) .lithePointer() @@ -280,3 +282,42 @@ struct LanguageNavigationChooserView: View { } } } + +private struct LanguageNavigationResultRow: View { + let location: LanguageNavigationLocation + let parentPath: String + let preview: String + let isSelected: Bool + + var body: some View { + HStack(spacing: 9) { + LitheIcon(kind: LitheIcons.kind(for: location.url, isDirectory: false), size: 15) + .frame(width: 18) + Text(location.displayName) + .font(.system(size: 12.5, weight: .medium)) + .foregroundStyle(LitheTheme.primaryText) + .lineLimit(1) + .frame(width: 170, alignment: .leading) + Text(parentPath) + .font(.system(size: 10.5)) + .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(1) + .truncationMode(.middle) + .frame(width: 150, alignment: .leading) + Text("\(location.line + 1)") + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(LitheTheme.secondaryText) + .frame(width: 44, alignment: .trailing) + Text(preview) + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(LitheTheme.primaryText) + .lineLimit(1) + Spacer(minLength: 0) + } + .padding(.horizontal, 9) + .frame(maxWidth: .infinity) + .frame(height: 29) + .background(isSelected ? LitheTheme.selection : .clear) + .contentShape(Rectangle()) + } +} diff --git a/Sources/Lithe/Views/LSPControlCenterView.swift b/Sources/Lithe/Views/LSPControlCenterView.swift index 861bc7aff..19b5b98cb 100644 --- a/Sources/Lithe/Views/LSPControlCenterView.swift +++ b/Sources/Lithe/Views/LSPControlCenterView.swift @@ -21,6 +21,7 @@ struct LSPControlCenterView: View { languageRow(descriptor) } } + runtimeActivity if model.languageProviderCatalogSnapshot.isDegraded { degradedCatalogNotice } @@ -33,6 +34,69 @@ struct LSPControlCenterView: View { .background(LitheTheme.editor) } + private var runtimeActivity: some View { + VStack(alignment: .leading, spacing: 9) { + HStack { + Label( + usesChinese ? "运行日志" : "Runtime activity", + systemImage: "waveform.path.ecg" + ) + .font(.system(size: 11.5, weight: .semibold)) + .foregroundStyle(LitheTheme.primaryText) + Spacer() + if !model.languageToolingSessions.languageServerLogs.isEmpty { + Button(usesChinese ? "清空" : "Clear") { + model.languageToolingSessions.clearLanguageServerLogs() + } + .buttonStyle(.plain) + .font(.system(size: 10.5)) + .foregroundStyle(LitheTheme.accent) + .lithePointer() + } + } + + if model.languageToolingSessions.languageServerLogs.isEmpty { + Text(usesChinese ? "暂无 LSP 活动" : "No LSP activity yet") + .font(.system(size: 10.5)) + .foregroundStyle(LitheTheme.secondaryText) + } else { + ForEach(Array(model.languageToolingSessions.languageServerLogs.prefix(20))) { entry in + HStack(alignment: .top, spacing: 7) { + Circle() + .fill(logColor(entry.level)) + .frame(width: 6, height: 6) + .padding(.top, 4) + Text(entry.timestamp.formatted(date: .omitted, time: .standard)) + .font(.system(size: 9.5, design: .monospaced)) + .foregroundStyle(LitheTheme.tertiaryText) + .frame(width: 72, alignment: .leading) + Text(entry.providerID) + .font(.system(size: 9.5, weight: .medium, design: .monospaced)) + .foregroundStyle(LitheTheme.secondaryText) + .frame(width: 54, alignment: .leading) + VStack(alignment: .leading, spacing: 2) { + Text(entry.message) + .font(.system(size: 10.5, weight: .medium)) + .foregroundStyle(LitheTheme.primaryText) + if let detail = entry.detail, !detail.isEmpty { + Text(detail) + .font(.system(size: 9.5, design: .monospaced)) + .foregroundStyle(LitheTheme.secondaryText) + .textSelection(.enabled) + } + } + } + } + } + } + .padding(12) + .background(RoundedRectangle(cornerRadius: 7).fill(LitheTheme.sidebar)) + .overlay { + RoundedRectangle(cornerRadius: 7) + .stroke(LitheTheme.panelBorder, lineWidth: 1) + } + } + private var header: some View { HStack { Text(usesChinese ? "语言支持" : "Language Support") @@ -351,4 +415,12 @@ struct LSPControlCenterView: View { case .error: LitheTheme.error } } + + private func logColor(_ level: LanguageServerLogLevel) -> Color { + switch level { + case .info: LitheTheme.accent + case .warning: LitheTheme.warning + case .error: LitheTheme.error + } + } } diff --git a/Tests/LitheTests/EditorNavigationTests.swift b/Tests/LitheTests/EditorNavigationTests.swift index c9cafd01c..3c4baa333 100644 --- a/Tests/LitheTests/EditorNavigationTests.swift +++ b/Tests/LitheTests/EditorNavigationTests.swift @@ -63,6 +63,30 @@ struct EditorNavigationTests { #expect(feature.target?.line == 8) } + @Test + @MainActor + func navigationHistorySupportsBackAndForward() async { + let feature = EditorNavigationFeatureModel() + let source = EditorNavigationTarget( + url: URL(fileURLWithPath: "/workspace/Source.swift"), + line: 3, + utf16Column: 2 + ) + let destination = EditorNavigationTarget( + url: URL(fileURLWithPath: "/workspace/Destination.swift"), + line: 12, + utf16Column: 4 + ) + + feature.navigate(to: destination, from: source) { true } + await waitUntil { feature.target?.url == destination.url } + + #expect(feature.canNavigateBack) + #expect(feature.takeBackDestination(from: destination)?.url == source.url) + #expect(feature.canNavigateForward) + #expect(feature.takeForwardDestination(from: source)?.url == destination.url) + } + @Test func viewportKeepsComfortablyVisibleTargetsStable() { let destination = EditorNavigationViewport.destinationY( @@ -160,6 +184,31 @@ struct EditorNavigationTests { #expect(reports.last?.1 == 1) } + @Test + func declarationOrUsagesRecognizesCaretInsideDefinitionRange() { + let fileURL = URL(fileURLWithPath: "/workspace/main.rs") + let location = LanguageServerLocation( + url: fileURL, + range: LanguageServerRange( + start: LanguageServerPosition(line: 12, utf16Column: 7), + end: LanguageServerPosition(line: 12, utf16Column: 14) + ) + ) + + #expect(LanguageNavigationSemantics.contains( + EditorCaret(url: fileURL, line: 12, utf16Column: 10), + in: location + )) + #expect(!LanguageNavigationSemantics.contains( + EditorCaret(url: fileURL, line: 13, utf16Column: 10), + in: location + )) + #expect(!LanguageNavigationSemantics.contains( + EditorCaret(url: URL(fileURLWithPath: "/workspace/other.rs"), line: 12, utf16Column: 10), + in: location + )) + } + @MainActor private func waitUntil( _ predicate: @escaping @MainActor () -> Bool diff --git a/Tests/LitheTests/LanguageFeatureProviderTests.swift b/Tests/LitheTests/LanguageFeatureProviderTests.swift index 670b92994..f01c0d56e 100644 --- a/Tests/LitheTests/LanguageFeatureProviderTests.swift +++ b/Tests/LitheTests/LanguageFeatureProviderTests.swift @@ -115,6 +115,135 @@ struct LanguageFeatureProviderTests { #expect(locations.map(\.url) == [fileURL]) } + @Test + func projectSymbolsUseWorkspaceIndexAndPreserveUTF16Columns() async throws { + let rootURL = URL(fileURLWithPath: "/workspace") + let currentURL = rootURL.appendingPathComponent("Sources/Current.swift") + let otherURL = rootURL.appendingPathComponent("Sources/Other.swift") + let operations = ProjectSymbolTestWorkspaceOperations( + results: [ + FileSearchResult(url: otherURL, line: 1, preview: "let 变量 = 2") + ], + files: ["Sources/Other.swift": "😀 let 变量 = 2\n"] + ) + let provider = ProjectSymbolFeatureProvider( + operations: operations, + visibilityRules: { .default } + ) + let context = LanguageFeatureRequestContext( + fileURL: currentURL, + text: "😀 let 变量 = 1\nprint(变量)", + position: LanguageServerPosition(line: 0, utf16Column: 8), + workspaceURL: rootURL + ) + + let locations = try await withCheckedThrowingContinuation { continuation in + do { + try provider.navigate(method: "textDocument/references", in: context) { + continuation.resume(with: $0) + } + } catch { + continuation.resume(throwing: error) + } + } + + #expect(locations.count == 3) + #expect(locations.contains { + $0.url == currentURL && $0.range.start == LanguageServerPosition(line: 0, utf16Column: 7) + }) + #expect(locations.contains { + $0.url == currentURL && $0.range.start == LanguageServerPosition(line: 1, utf16Column: 6) + }) + #expect(locations.contains { + $0.url == otherURL && $0.range.start == LanguageServerPosition(line: 0, utf16Column: 7) + }) + } + + @Test + func managerPublishesProjectCandidatesBeforeLSPAndCachesSemanticResult() async throws { + let fileURL = URL(fileURLWithPath: "/tmp/main.go") + let projectLocation = Self.location(fileURL, line: 4) + let semanticLocation = Self.location(fileURL, line: 9) + let project = NavigationFeatureProvider( + id: "project-symbols", + priority: .projectSymbols, + locations: [projectLocation] + ) + let lsp = NavigationFeatureProvider( + id: "lsp:test", + priority: .languageServer, + locations: [semanticLocation], + delayMilliseconds: 30 + ) + let manager = LanguageToolingSessionManager(languageFeatureProviders: [project, lsp]) + var provisionalValues: [[LanguageServerLocation]] = [] + + let first = try await navigate( + with: manager, + fileURL: fileURL, + provisional: { provisionalValues.append($0) } + ) + let second = try await navigate(with: manager, fileURL: fileURL) + + #expect(provisionalValues == [[projectLocation]]) + #expect(first == [semanticLocation]) + #expect(second == [semanticLocation]) + #expect(project.navigationRequestCount == 1) + #expect(lsp.navigationRequestCount == 1) + #expect(manager.languageServerLogs.contains { + $0.detail?.contains("source=cache") == true + }) + } + + @Test + func managerCachesProjectFallbackWhenLanguageServerHasNoResult() async throws { + let fileURL = URL(fileURLWithPath: "/tmp/main.go") + let projectLocation = Self.location(fileURL, line: 4) + let project = NavigationFeatureProvider( + id: "project-symbols", + priority: .projectSymbols, + locations: [projectLocation] + ) + let lsp = NavigationFeatureProvider( + id: "lsp:test", + priority: .languageServer, + locations: [] + ) + let manager = LanguageToolingSessionManager(languageFeatureProviders: [project, lsp]) + + let first = try await navigate(with: manager, fileURL: fileURL) + let second = try await navigate(with: manager, fileURL: fileURL) + + #expect(first == [projectLocation]) + #expect(second == [projectLocation]) + #expect(project.navigationRequestCount == 1) + #expect(lsp.navigationRequestCount == 1) + #expect(manager.languageServerLogs.contains { + $0.detail?.contains("source=cache") == true + }) + } + + @Test + func managerCoalescesIdenticalNavigationRequestsInFlight() async throws { + let fileURL = URL(fileURLWithPath: "/tmp/main.go") + let semanticLocation = Self.location(fileURL, line: 9) + let lsp = NavigationFeatureProvider( + id: "lsp:test", + priority: .languageServer, + locations: [semanticLocation], + delayMilliseconds: 30 + ) + let manager = LanguageToolingSessionManager(languageFeatureProviders: [lsp]) + + async let first = navigate(with: manager, fileURL: fileURL) + async let second = navigate(with: manager, fileURL: fileURL) + let values = try await (first, second) + + #expect(values.0 == [semanticLocation]) + #expect(values.1 == [semanticLocation]) + #expect(lsp.navigationRequestCount == 1) + } + private static func item(label: String, detail: String) -> LanguageServerCompletionItem { LanguageServerCompletionItem( label: label, @@ -129,6 +258,65 @@ struct LanguageFeatureProviderTests { data: nil ) } + + private static func location(_ url: URL, line: Int) -> LanguageServerLocation { + let position = LanguageServerPosition(line: line, utf16Column: 0) + return LanguageServerLocation( + url: url, + range: LanguageServerRange(start: position, end: position) + ) + } + + private func navigate( + with manager: LanguageToolingSessionManager, + fileURL: URL, + provisional: (([LanguageServerLocation]) -> Void)? = nil + ) async throws -> [LanguageServerLocation] { + try await withCheckedThrowingContinuation { continuation in + do { + try manager.navigate( + method: "textDocument/references", + fileURL: fileURL, + text: "target", + position: LanguageServerPosition(line: 0, utf16Column: 2), + rootURL: fileURL.deletingLastPathComponent(), + provisional: provisional + ) { continuation.resume(with: $0) } + } catch { + continuation.resume(throwing: error) + } + } + } +} + +private struct ProjectSymbolTestWorkspaceOperations: WorkspaceOperations { + let results: [FileSearchResult] + let files: [String: String] + + func snapshot(at _: URL, visibilityRules _: FileVisibilityRules) -> WorkspaceSnapshot? { nil } + func search( + at _: URL, + query _: String, + options _: ProjectSearchOptions, + visibilityRules _: FileVisibilityRules + ) -> [FileSearchResult]? { results } + func searchEverywhere( + at _: URL, + query _: String, + options _: ProjectSearchOptions, + visibilityRules _: FileVisibilityRules + ) -> SearchEverywhereResults? { nil } + func previewReplacement( + at _: URL, + query _: String, + replacement _: String, + options _: ProjectSearchOptions, + paths _: [String], + textOverrides _: [String: String], + visibilityRules _: FileVisibilityRules + ) -> [ProjectReplacementFile]? { nil } + func readFile(at _: URL, relativePath: String) -> String? { files[relativePath] } + func writeFile(_: String, at _: URL, relativePath _: String) -> Bool { false } } private enum FeatureProviderTestError: LocalizedError { @@ -277,3 +465,60 @@ private final class CompletionFeatureProvider: LanguageFeatureProvider { completion(.success([])) } } + +@MainActor +private final class NavigationFeatureProvider: LanguageFeatureProvider { + let id: String + let priority: LanguageFeatureProviderPriority + private let locations: [LanguageServerLocation] + private let delayMilliseconds: Int + private(set) var navigationRequestCount = 0 + + init( + id: String, + priority: LanguageFeatureProviderPriority, + locations: [LanguageServerLocation], + delayMilliseconds: Int = 0 + ) { + self.id = id + self.priority = priority + self.locations = locations + self.delayMilliseconds = delayMilliseconds + } + + func supports(_ feature: LanguageFeature, in _: LanguageFeatureRequestContext) -> Bool { + if case .navigation = feature { return true } + return false + } + + func completions( + in _: LanguageFeatureRequestContext, + completion: @escaping (Result<[LanguageServerCompletionItem], Error>) -> Void + ) throws { + completion(.success([])) + } + + func hover( + in _: LanguageFeatureRequestContext, + completion: @escaping (Result) -> Void + ) throws { + completion(.success(nil)) + } + + func navigate( + method _: String, + in _: LanguageFeatureRequestContext, + completion: @escaping (Result<[LanguageServerLocation], Error>) -> Void + ) throws { + navigationRequestCount += 1 + let locations = locations + guard delayMilliseconds > 0 else { + completion(.success(locations)) + return + } + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(delayMilliseconds)) + completion(.success(locations)) + } + } +} diff --git a/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/Tests/LitheTests/RunConfigurationIntegrationTests.swift index c9c39e434..6242ed0f4 100644 --- a/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -1187,6 +1187,31 @@ struct RunConfigurationIntegrationTests { #expect(message?.contains("invalid result") == true) } + @Test + func identicalDocumentTextIsNotSynchronizedTwice() async throws { + let harness = makeLanguageServerHarness() + let original = "struct App {}\n" + + try harness.manager.synchronizeLanguageServer( + for: harness.source, + text: original, + rootURL: harness.root + ) + try harness.manager.synchronizeLanguageServer( + for: harness.source, + text: original, + rootURL: harness.root + ) + #expect(await Self.waitForMainActorCondition { harness.core.syncCalls.count == 1 }) + + try harness.manager.synchronizeLanguageServer( + for: harness.source, + text: "struct App { let changed = true }\n", + rootURL: harness.root + ) + #expect(await Self.waitForMainActorCondition { harness.core.syncCalls.count == 2 }) + } + @Test func initializeTimeoutTransitionsInitializingSessionToFailed() async throws { let harness = makeLanguageServerHarness(initializeTimeoutNanoseconds: 2_000_000) diff --git a/docs/architecture/language-tooling.md b/docs/architecture/language-tooling.md index f9d46d355..9fbb2cacf 100644 --- a/docs/architecture/language-tooling.md +++ b/docs/architecture/language-tooling.md @@ -20,6 +20,7 @@ flowchart LR UI["Editor / feature model"] --> MANAGER["LanguageToolingSessionManager"] MANAGER --> ROUTER["LanguageFeatureProvider routing"] ROUTER --> BUILTIN["Builtin provider
keywords + current-file symbols"] + ROUTER --> PROJECT["Project symbol provider
workspace text index"] ROUTER --> LSPPROVIDER["LSP provider
server capabilities"] LSPPROVIDER --> SESSION["Swift semantic facade
opaque operation IDs"] SESSION --> CORE["Rust LSP runtime
process + state + deadlines + stdio"] @@ -31,6 +32,7 @@ flowchart LR | `LanguageToolingSessionManager` | 文档同步、provider 选择、结果降级/合并、诊断和会话归属 | JSON-RPC 编解码、直接启动 `Process` | | `LanguageFeatureProvider` | 声明单项能力、优先级和统一结果类型 | 维护 UI 状态 | | `BuiltinLanguageFeatureProvider` | 当前文件标识符、轻量 hover/导航、语言关键字 | 类型推断、跨文件索引 | +| `ProjectSymbolFeatureProvider` | 用已预热的 workspace text index 生成跨文件、UTF-16 精确的词法导航候选 | 类型解析、替代 LSP 语义结果 | | `LanguageServerFeatureProvider` | 将已协商的服务器能力适配到统一 provider 接口 | 猜测服务器能力 | | `StdioLanguageServerSession` | 调用语义命令、投影 typed event,并以不透明 operation ID 交付 UI 回调 | LSP 请求 ID、frame、文档版本、协议超时或子进程 | | Rust Core | LSP 子进程与 stdio、session/document state、请求 ID、deadline、frame、UTF-16 位置、结果归一化、动态能力 | 可执行文件发现、UI provider 路由 | @@ -60,15 +62,21 @@ lsp/ ## Provider 路由 -当前优先级由高到低为 `languageServer (200)`、预留的 `projectSymbols (100)`、`builtin (0)`。每次请求先按文件和功能过滤 provider,再按优先级路由: +当前优先级由高到低为 `languageServer (200)`、`projectSymbols (100)`、`builtin (0)`。每次请求先按文件和功能过滤 provider,再按优先级路由: - **Completion**:依次收集所有成功结果,保持高优先级顺序,并按 `label` 去重。因此 LSP 可提供精确候选,本地关键字和当前文件符号仍能补足结果。 - **Hover**:返回第一个非空结果;LSP 无结果或失败时继续询问本地 provider。 -- **Definition/References/Implementation**:返回第一个非空位置列表,并在 LSP 不可用时降级到当前文件文本级导航。 +- **Definition/References/Implementation**:LSP 与 project-symbol provider 并发请求。项目索引候选先返回时只作为 provisional result 展示,不自动跳转;LSP 的非空语义结果随后替换候选并成为最终结果。LSP 空结果或失败时使用项目候选,再降级到当前文件文本级导航。 - **Rename/Formatting/Code Action/Resolve/Execute Command**:目前仍是 LSP-only;未运行或未声明相应能力时应返回明确的 capability 错误。 provider 抛错不会让路由提前结束。这个策略用于隔离第三方语言服务器故障,但也意味着新增 provider 时必须给出稳定优先级,并避免返回伪造的“成功但无意义”结果。 +project-symbol provider 不按扩展名或 provider ID 分支。它从光标提取通用标识符,使用 workspace search index 的 whole-word/case-sensitive 查询缩小候选文件,再在后台读取命中行并计算精确 UTF-16 范围。打开且未保存的当前文档直接扫描 editor buffer,避免磁盘索引覆盖用户的新输入。Definition/Implementation 只用语言无关的结构词提高候选排序;最终语义仍以 LSP 为准。 + +成功的导航结果按 method、workspace、文件、位置和文本指纹保存 8 秒进程内缓存;相同的并发请求也只发送一次,再把结果扇出给所有调用方。任一文档同步、关闭、外部文件变更、catalog/root/session 变化都会使缓存失效,避免跨版本复用位置。每次 provisional/final/cache 命中都会把 `source`、`elapsedMs` 和结果数写入 LSP 控制中心的“运行日志”,便于区分索引耗时与服务器耗时。 + +编辑器的 `⌘B` 是 declaration-or-usages 命令,而不是 references 的别名:先请求 definition;引用位置得到单个目标时直接跳转,已经位于声明 token 上时再请求 references 并打开轻量选择器。完整 Find Usages 使用相同的文件、行号和代码预览行,但保留在底部工具窗口中。导航前后位置由 `EditorNavigationFeatureModel` 维护,可以用 `⌘[`/`⌘]` 返回和前进。 + ## 无进程能力 Rust Core 的 `lsp.builtinCompletions`、`lsp.builtinHover` 和 @@ -140,7 +148,7 @@ LSP 控制中心标题栏的工具设置会在用户偏好中保存每个 provid 1. Swift 完成可执行文件和环境发现,向 Rust 提交 typed `startServer`; 2. Rust engine 创建 session、启动进程并安装 stdout/stderr reader,再发送 `initialize`; 3. 收到响应后,Rust 保存服务器 capability,发送 `initialized` 和 provider adapter 通知; -4. manager 发布实际 capability,随后通过 `didOpen`/全量 `didChange` 同步文档; +4. manager 发布实际 capability,随后通过 `didOpen`/全量 `didChange` 同步文档;连续编辑在应用层以 120ms 窗口合并,语义导航发起前强制刷新最新 buffer;相同文本指纹不会重复发送; 5. Rust 以 LSP request ID 关联 deadline,并用不透明 operation ID 把 terminal result 投影给 Swift。 服务端 capability 可以来自 initialize 响应,也可以通过 `client/registerCapability` 和 @@ -154,7 +162,8 @@ LSP 控制中心标题栏的工具设置会在用户偏好中保存每个 provid - session 当前以 provider ID 和单个 workspace root 为单位,尚无 multi-root session。 - `workspace/applyEdit` 只提供协议确认和 normalized edit 数据,实际应用仍必须经过编辑器工作区安全校验。 - initialize 可协商 snippet、resolve、inlay hint、folding range、code lens 和 workspace symbol;UI 只启用已完整投影且服务器实际声明的能力。 -- 文档同步当前发送全量文本,没有按服务器类型实现增量 diff。 +- 文档同步当前仍发送全量文本,没有按服务器类型实现增量 diff;应用层只负责短窗口合并和相同文本去重。 +- workspace text index 当前是进程内预热和文件监听增量更新,不跨应用启动持久化;它提供快速词法候选,不是类型图或调用图。 - catalog 描述的是“可尝试启动的工具”;最终功能必须以运行时服务器 capability 为准。 - project config 是受信任的项目配置,只接受 schema 中的 typed 字段,不执行 shell 命令。 From 21e7e9719822dc8aaae59ebe2818a5f718ef358a Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Thu, 13 Aug 2026 23:13:24 +0800 Subject: [PATCH 07/10] =?UTF-8?q?fix:=E4=BF=AE=E5=A4=8DLSP=20=E5=9B=9E?= =?UTF-8?q?=E9=80=80=E5=88=B0=E6=9C=AC=E5=9C=B0=E5=AF=BC=E8=88=AA=E5=90=8E?= =?UTF-8?q?=EF=BC=8C=E6=A0=87=E8=AF=86=E7=AC=A6=E4=BD=8D=E7=BD=AE=E8=AE=A1?= =?UTF-8?q?=E7=AE=97=E6=98=AF=20O(n=C2=B2)=E7=9A=84=E6=95=88=E7=8E=87?= =?UTF-8?q?=E9=97=AE=E9=A2=98=EF=BC=8C=E5=9C=A8=E5=A4=A7=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E4=B8=8B=E8=BF=87=E5=BA=A6=E7=9A=84=E5=8D=A1=E9=A1=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../LanguageToolingSessionManager.swift | 87 ++++++++++++++----- .../LanguageFeatureProviderTests.swift | 74 ++++++++++++++++ docs/architecture/language-tooling.md | 5 +- rust/lithe-core/src/lsp/lightweight/edits.rs | 30 +------ .../lithe-core/src/lsp/lightweight/symbols.rs | 45 ++++++++-- rust/lithe-core/src/lsp/tests.rs | 28 +++++- 6 files changed, 205 insertions(+), 64 deletions(-) diff --git a/Sources/Lithe/Services/LanguageToolingSessionManager.swift b/Sources/Lithe/Services/LanguageToolingSessionManager.swift index 5c13e3070..f952fd8b5 100644 --- a/Sources/Lithe/Services/LanguageToolingSessionManager.swift +++ b/Sources/Lithe/Services/LanguageToolingSessionManager.swift @@ -41,6 +41,7 @@ final class LanguageToolingSessionManager: ObservableObject { var completions: [(Result<[LanguageServerLocation], Error>) -> Void] var provisionalObservers: [([LanguageServerLocation]) -> Void] var latestProvisional: [LanguageServerLocation]? + var interactiveDeadlineTask: Task? } @Published private(set) var diagnostics: [URL: [LanguageServerDiagnostic]] = [:] @@ -71,16 +72,19 @@ final class LanguageToolingSessionManager: ObservableObject { private var navigationCache: [NavigationCacheKey: CachedNavigationResult] = [:] private var pendingNavigationRequests: [NavigationCacheKey: PendingNavigationRequest] = [:] private let navigationCacheLifetime: TimeInterval = 8 + private let navigationInteractiveDeadline: Duration init( catalog: LanguageProviderCatalog = .standard, runtimes: [any LanguageProviderRuntime] = [], runtimeFactory: (any LanguageProviderRuntimeFactory)? = nil, core: RustCoreBridge = RustCoreBridge(), - languageFeatureProviders: [any LanguageFeatureProvider] = [] + languageFeatureProviders: [any LanguageFeatureProvider] = [], + navigationInteractiveDeadline: Duration = .milliseconds(750) ) { self.catalog = catalog self.runtimeFactory = runtimeFactory + self.navigationInteractiveDeadline = navigationInteractiveDeadline self.languageFeatureProviders = languageFeatureProviders + [ BuiltinLanguageFeatureProvider(core: core) ] @@ -419,7 +423,8 @@ final class LanguageToolingSessionManager: ObservableObject { pendingNavigationRequests[cacheKey] = PendingNavigationRequest( completions: [completion], provisionalObservers: provisional.map { [$0] } ?? [], - latestProvisional: nil + latestProvisional: nil, + interactiveDeadlineTask: nil ) let languageServers = providers.filter { $0.priority == .languageServer } @@ -441,33 +446,42 @@ final class LanguageToolingSessionManager: ObservableObject { let startedAt = Date() guard !projectSymbols.isEmpty else { + func finishWithFallback(source: String) { + guard pendingNavigationRequests[cacheKey] != nil else { return } + routeNavigation( + providers: fallback, + index: 0, + method: method, + context: context + ) { [self] fallbackResult in + guard pendingNavigationRequests[cacheKey] != nil else { return } + let fallbackLocations = (try? fallbackResult.get()) ?? [] + recordNavigationTiming( + fileURL: fileURL, + method: method, + source: source, + startedAt: startedAt, + resultCount: fallbackLocations.count + ) + if !fallbackLocations.isEmpty { + storeNavigationResult(fallbackLocations, for: cacheKey) + } + completeNavigationRequest(cacheKey, with: .success(fallbackLocations)) + } + } + scheduleNavigationInteractiveDeadline(for: cacheKey) { + finishWithFallback(source: "interactive-deadline") + } routeNavigation( providers: languageServers, index: 0, method: method, context: context ) { [self] result in + guard pendingNavigationRequests[cacheKey] != nil else { return } let locations = (try? result.get()) ?? [] guard !locations.isEmpty else { - routeNavigation( - providers: fallback, - index: 0, - method: method, - context: context - ) { [self] fallbackResult in - let fallbackLocations = (try? fallbackResult.get()) ?? [] - recordNavigationTiming( - fileURL: fileURL, - method: method, - source: "builtin", - startedAt: startedAt, - resultCount: fallbackLocations.count - ) - if !fallbackLocations.isEmpty { - storeNavigationResult(fallbackLocations, for: cacheKey) - } - completeNavigationRequest(cacheKey, with: .success(fallbackLocations)) - } + finishWithFallback(source: "builtin") return } storeNavigationResult(locations, for: cacheKey) @@ -504,7 +518,7 @@ final class LanguageToolingSessionManager: ObservableObject { completeNavigationRequest(cacheKey, with: .success(locations)) } - func finishWithFallback() { + func finishWithFallback(source: String = "builtin") { guard !isFinished else { return } routeNavigation( providers: fallback, @@ -512,7 +526,15 @@ final class LanguageToolingSessionManager: ObservableObject { method: method, context: context ) { result in - finish((try? result.get()) ?? [], source: "builtin", cache: true) + finish((try? result.get()) ?? [], source: source, cache: true) + } + } + + scheduleNavigationInteractiveDeadline(for: cacheKey) { + if projectLocations.isEmpty { + finishWithFallback(source: "interactive-deadline") + } else { + finish(projectLocations, source: "project-index-deadline", cache: true) } } @@ -1176,9 +1198,28 @@ final class LanguageToolingSessionManager: ObservableObject { with result: Result<[LanguageServerLocation], Error> ) { guard let pending = pendingNavigationRequests.removeValue(forKey: key) else { return } + pending.interactiveDeadlineTask?.cancel() for completion in pending.completions { completion(result) } } + private func scheduleNavigationInteractiveDeadline( + for key: NavigationCacheKey, + action: @escaping @MainActor () -> Void + ) { + guard var pending = pendingNavigationRequests[key] else { return } + let deadline = navigationInteractiveDeadline + pending.interactiveDeadlineTask = Task { @MainActor [weak self] in + do { + try await Task.sleep(for: deadline) + } catch { + return + } + guard let self, self.pendingNavigationRequests[key] != nil else { return } + action() + } + pendingNavigationRequests[key] = pending + } + private func recordNavigationTiming( fileURL: URL, method: String, diff --git a/Tests/LitheTests/LanguageFeatureProviderTests.swift b/Tests/LitheTests/LanguageFeatureProviderTests.swift index f01c0d56e..bfd19ee98 100644 --- a/Tests/LitheTests/LanguageFeatureProviderTests.swift +++ b/Tests/LitheTests/LanguageFeatureProviderTests.swift @@ -244,6 +244,80 @@ struct LanguageFeatureProviderTests { #expect(lsp.navigationRequestCount == 1) } + @Test + func managerUsesProjectIndexAtInteractiveDeadlineWhenLSPIsBusy() async throws { + let fileURL = URL(fileURLWithPath: "/tmp/main.go") + let projectLocation = Self.location(fileURL, line: 4) + let semanticLocation = Self.location(fileURL, line: 9) + let project = NavigationFeatureProvider( + id: "project-symbols", + priority: .projectSymbols, + locations: [projectLocation] + ) + let lsp = NavigationFeatureProvider( + id: "lsp:test", + priority: .languageServer, + locations: [semanticLocation], + delayMilliseconds: 150 + ) + let manager = LanguageToolingSessionManager( + languageFeatureProviders: [project, lsp], + navigationInteractiveDeadline: .milliseconds(20) + ) + + let first = try await navigate(with: manager, fileURL: fileURL) + try await Task.sleep(for: .milliseconds(180)) + let second = try await navigate(with: manager, fileURL: fileURL) + + #expect(first == [projectLocation]) + #expect(second == [projectLocation]) + #expect(project.navigationRequestCount == 1) + #expect(lsp.navigationRequestCount == 1) + #expect(manager.languageServerLogs.contains { + $0.detail?.contains("source=project-index-deadline") == true + }) + } + + @Test + func managerStopsWaitingAtInteractiveDeadlineWithoutProjectCandidates() async throws { + let fileURL = URL(fileURLWithPath: "/tmp/main.go") + let fallbackLocation = Self.location(fileURL, line: 2) + let lsp = NavigationFeatureProvider( + id: "lsp:test", + priority: .languageServer, + locations: [Self.location(fileURL, line: 9)], + delayMilliseconds: 150 + ) + let fallback = NavigationFeatureProvider( + id: "fallback", + priority: .builtin, + locations: [fallbackLocation] + ) + let manager = LanguageToolingSessionManager( + languageFeatureProviders: [lsp, fallback], + navigationInteractiveDeadline: .milliseconds(20) + ) + + let result = try await withCheckedThrowingContinuation { continuation in + do { + try manager.navigate( + method: "textDocument/definition", + fileURL: fileURL, + text: " ", + position: LanguageServerPosition(line: 0, utf16Column: 0), + rootURL: fileURL.deletingLastPathComponent() + ) { continuation.resume(with: $0) } + } catch { + continuation.resume(throwing: error) + } + } + + #expect(result == [fallbackLocation]) + #expect(manager.languageServerLogs.contains { + $0.detail?.contains("source=interactive-deadline") == true + }) + } + private static func item(label: String, detail: String) -> LanguageServerCompletionItem { LanguageServerCompletionItem( label: label, diff --git a/docs/architecture/language-tooling.md b/docs/architecture/language-tooling.md index 9fbb2cacf..99b468e05 100644 --- a/docs/architecture/language-tooling.md +++ b/docs/architecture/language-tooling.md @@ -66,14 +66,14 @@ lsp/ - **Completion**:依次收集所有成功结果,保持高优先级顺序,并按 `label` 去重。因此 LSP 可提供精确候选,本地关键字和当前文件符号仍能补足结果。 - **Hover**:返回第一个非空结果;LSP 无结果或失败时继续询问本地 provider。 -- **Definition/References/Implementation**:LSP 与 project-symbol provider 并发请求。项目索引候选先返回时只作为 provisional result 展示,不自动跳转;LSP 的非空语义结果随后替换候选并成为最终结果。LSP 空结果或失败时使用项目候选,再降级到当前文件文本级导航。 +- **Definition/References/Implementation**:LSP 与 project-symbol provider 并发请求。项目索引候选先返回时只作为 provisional result 展示;LSP 在 750ms 交互时限内返回非空结果时仍以语义结果为准。服务器冷启动、索引繁忙或无响应而超过交互时限时,立即使用项目候选,再降级到当前文件文本级导航,不能让编辑器等待协议层最长 30 秒的容错 deadline。迟到的服务器结果不会覆盖已经呈现的导航事务。 - **Rename/Formatting/Code Action/Resolve/Execute Command**:目前仍是 LSP-only;未运行或未声明相应能力时应返回明确的 capability 错误。 provider 抛错不会让路由提前结束。这个策略用于隔离第三方语言服务器故障,但也意味着新增 provider 时必须给出稳定优先级,并避免返回伪造的“成功但无意义”结果。 project-symbol provider 不按扩展名或 provider ID 分支。它从光标提取通用标识符,使用 workspace search index 的 whole-word/case-sensitive 查询缩小候选文件,再在后台读取命中行并计算精确 UTF-16 范围。打开且未保存的当前文档直接扫描 editor buffer,避免磁盘索引覆盖用户的新输入。Definition/Implementation 只用语言无关的结构词提高候选排序;最终语义仍以 LSP 为准。 -成功的导航结果按 method、workspace、文件、位置和文本指纹保存 8 秒进程内缓存;相同的并发请求也只发送一次,再把结果扇出给所有调用方。任一文档同步、关闭、外部文件变更、catalog/root/session 变化都会使缓存失效,避免跨版本复用位置。每次 provisional/final/cache 命中都会把 `source`、`elapsedMs` 和结果数写入 LSP 控制中心的“运行日志”,便于区分索引耗时与服务器耗时。 +成功的导航结果按 method、workspace、文件、位置和文本指纹保存 8 秒进程内缓存;相同的并发请求也只发送一次,再把结果扇出给所有调用方。任一文档同步、关闭、外部文件变更、catalog/root/session 变化都会使缓存失效,避免跨版本复用位置。每次 provisional/final/cache/interactive-deadline 命中都会把 `source`、`elapsedMs` 和结果数写入 LSP 控制中心的“运行日志”,便于区分索引耗时、服务器耗时和交互超时降级。 编辑器的 `⌘B` 是 declaration-or-usages 命令,而不是 references 的别名:先请求 definition;引用位置得到单个目标时直接跳转,已经位于声明 token 上时再请求 references 并打开轻量选择器。完整 Find Usages 使用相同的文件、行号和代码预览行,但保留在底部工具窗口中。导航前后位置由 `EditorNavigationFeatureModel` 维护,可以用 `⌘[`/`⌘]` 返回和前进。 @@ -81,6 +81,7 @@ project-symbol provider 不按扩展名或 provider ID 分支。它从光标提 Rust Core 的 `lsp.builtinCompletions`、`lsp.builtinHover` 和 `lsp.builtinNavigation` 只读取当前文件文本。Swift 层另外为 Go、Swift、Rust、Python、JavaScript 和 TypeScript 提供关键字候选;即使 Rust Core 未链接,关键字补全仍可使用。 +内置标识符扫描在一次遍历中同时维护 byte offset、line 和 UTF-16 column;禁止为每个 token 从文件开头重复换算位置,否则大文件导航会退化为 O(n²) 并阻塞 UI 回退路径。 这些结果是可用性降级,不是类型系统: diff --git a/rust/lithe-core/src/lsp/lightweight/edits.rs b/rust/lithe-core/src/lsp/lightweight/edits.rs index 6dda78d63..cb1dc6aac 100644 --- a/rust/lithe-core/src/lsp/lightweight/edits.rs +++ b/rust/lithe-core/src/lsp/lightweight/edits.rs @@ -1,4 +1,4 @@ -use crate::lsp::interface::{LspPosition, LspPositionResponse, LspRange, LspRangeResponse}; +use crate::lsp::interface::{LspPosition, LspRange}; use crate::protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; @@ -127,31 +127,3 @@ fn byte_offset_for_utf16_column( } contents_end } - -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() { - if index >= offset { - break; - } - if character == '\n' { - line += 1; - column = 0; - } else { - column += character.len_utf16() as i64; - } - } - LspPositionResponse { - line, - utf16_column: column, - } -} - -pub(super) fn range_for_offsets(text: &str, start: usize, end: usize) -> LspRangeResponse { - LspRangeResponse { - start: byte_offset_to_lsp_position(text, start), - end: byte_offset_to_lsp_position(text, end), - } -} diff --git a/rust/lithe-core/src/lsp/lightweight/symbols.rs b/rust/lithe-core/src/lsp/lightweight/symbols.rs index 0d1c00c09..4284b7135 100644 --- a/rust/lithe-core/src/lsp/lightweight/symbols.rs +++ b/rust/lithe-core/src/lsp/lightweight/symbols.rs @@ -1,4 +1,4 @@ -use super::edits::{range_for_offsets, utf16_position_to_byte_offset}; +use super::edits::utf16_position_to_byte_offset; use crate::lsp::interface::{ LspPosition, LspPositionResponse, LspRangeResponse, LspTextEditResponse, }; @@ -188,23 +188,47 @@ pub fn builtin_navigation( fn identifier_occurrences(text: &str) -> Vec { let mut values = Vec::new(); - let mut current_start: Option = None; + let mut current_start: Option<(usize, LspPositionResponse)> = None; + let mut position = LspPositionResponse { + line: 0, + utf16_column: 0, + }; for (index, character) in text.char_indices() { if is_identifier_character(character) { if current_start.is_none() { - current_start = Some(index); + current_start = Some((index, position)); } - } else if let Some(start) = current_start.take() { - push_identifier(text, start, index, &mut values); + } else if let Some((start, start_position)) = current_start.take() { + push_identifier(text, start, index, start_position, position, &mut values); + } + if character == '\n' { + position.line += 1; + position.utf16_column = 0; + } else { + position.utf16_column += character.len_utf16() as i64; } } - if let Some(start) = current_start { - push_identifier(text, start, text.len(), &mut values); + if let Some((start, start_position)) = current_start { + push_identifier( + text, + start, + text.len(), + start_position, + position, + &mut values, + ); } values } -fn push_identifier(text: &str, start: usize, end: usize, values: &mut Vec) { +fn push_identifier( + text: &str, + start: usize, + end: usize, + start_position: LspPositionResponse, + end_position: LspPositionResponse, + values: &mut Vec, +) { let value = &text[start..end]; if value.chars().next().is_some_and(is_identifier_start) && !is_language_keyword(value) @@ -214,7 +238,10 @@ fn push_identifier(text: &str, start: usize, end: usize, values: &mut Vec PathBuf { let nonce = SystemTime::now() @@ -397,6 +397,32 @@ fn builtin_navigation_prefers_declarations_and_finds_references() { assert_eq!(references.locations.len(), 2); } +#[test] +fn builtin_navigation_scans_large_files_in_linear_time() { + let mut text = "let filler_value = 1;\n".repeat(4_000); + text.push_str("let target = 1;\nprint(target);\n"); + let started_at = Instant::now(); + + let definitions = builtin_navigation(BuiltinNavigationRequest { + file_path: "/tmp/large.swift".to_string(), + text, + position: LspPosition { + line: 4_001, + utf16_column: 8, + }, + method: "textDocument/definition".to_string(), + }) + .unwrap(); + + assert_eq!(definitions.locations.len(), 1); + assert_eq!(definitions.locations[0].range.start.line, 4_000); + assert_eq!(definitions.locations[0].range.start.utf16_column, 4); + assert!( + started_at.elapsed() < Duration::from_secs(2), + "large-file navigation exceeded the interactive budget" + ); +} + #[test] fn file_uri_paths_decode_spaces_and_utf8_characters() { assert_eq!( From 119bd6bc70324b1cd2d83a7aa60253a9f0db0b4c Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Thu, 13 Aug 2026 23:39:10 +0800 Subject: [PATCH 08/10] fix(lsp): stabilize navigation during server warmup --- Resources/zh-Hans.lproj/Localizable.strings | 1 + .../Lithe/Models/AppModel+Development.swift | 24 +++++- .../LanguageToolingSessionManager.swift | 76 +++++++++++++++++-- Sources/Lithe/Views/CodeEditorView.swift | 11 +++ .../LanguageFeatureProviderTests.swift | 49 ++++++++++++ Tests/LitheTests/LitheCoreLogicTests.swift | 33 ++++++++ docs/architecture/language-tooling.md | 2 +- 7 files changed, 186 insertions(+), 10 deletions(-) diff --git a/Resources/zh-Hans.lproj/Localizable.strings b/Resources/zh-Hans.lproj/Localizable.strings index 36c31f84a..0e3b42b26 100644 --- a/Resources/zh-Hans.lproj/Localizable.strings +++ b/Resources/zh-Hans.lproj/Localizable.strings @@ -552,6 +552,7 @@ "No usages found" = "没有找到调用位置"; "No implementations found" = "没有找到实现"; "Definition not found" = "没有找到定义"; +"Language server is warming up or indexing. Navigation will continue when it is ready." = "LSP 正在预热或构建索引,准备就绪后会继续跳转。"; "Java navigation is available for .java files" = "Java 导航仅适用于 .java 文件"; "Starting Java navigation..." = "正在启动 Java 导航…"; "Java navigation is idle" = "Java 导航空闲"; diff --git a/Sources/Lithe/Models/AppModel+Development.swift b/Sources/Lithe/Models/AppModel+Development.swift index 8ef450ca8..f8b9d39ae 100644 --- a/Sources/Lithe/Models/AppModel+Development.swift +++ b/Sources/Lithe/Models/AppModel+Development.swift @@ -377,7 +377,7 @@ extension AppModel { ) } - func goToDeclarationOrUsages() { + func goToDeclarationOrUsages(line: Int? = nil, utf16Column: Int? = nil) { guard supportsLanguageServerFeature(.definition) else { if supportsLanguageServerFeature(.references) { goToUsages() @@ -387,7 +387,7 @@ extension AppModel { return } guard let document = activeDocument, - let caret = editorCaret, + let caret = navigationCaret(line: line, utf16Column: utf16Column) ?? editorCaret, caret.url.standardizedFileURL == document.url.standardizedFileURL, let workspaceURL, let provider = languageProviderCatalog.provider(for: document.url) else { @@ -411,7 +411,11 @@ extension AppModel { line: max(0, caret.line), utf16Column: max(0, caret.utf16Column) ), - rootURL: workspaceURL + rootURL: workspaceURL, + waitingForLanguageServer: { [weak self] in + guard let self, self.languageNavigationRequestID == requestID else { return } + self.showNotification("Language server is warming up or indexing. Navigation will continue when it is ready.") + } ) { [weak self] result in guard let self, self.languageNavigationRequestID == requestID else { return } switch result { @@ -922,6 +926,10 @@ extension AppModel { kind: kind, presentation: presentation ) + }, + waitingForLanguageServer: { [weak self] in + guard let self, self.languageNavigationRequestID == requestID else { return } + self.showNotification("Language server is warming up or indexing. Navigation will continue when it is ready.") } ) { [weak self] result in guard let self, self.languageNavigationRequestID == requestID else { return } @@ -993,6 +1001,10 @@ extension AppModel { kind: .references, presentation: .chooser ) + }, + waitingForLanguageServer: { [weak self] in + guard let self, self.languageNavigationRequestID == requestID else { return } + self.showNotification("Language server is warming up or indexing. Navigation will continue when it is ready.") } ) { [weak self] result in guard let self, self.languageNavigationRequestID == requestID else { return } @@ -1056,7 +1068,11 @@ extension AppModel { line: max(0, caret.line), utf16Column: max(0, caret.utf16Column) ), - rootURL: workspaceURL + rootURL: workspaceURL, + waitingForLanguageServer: { [weak self] in + guard let self, self.languageNavigationRequestID == requestID else { return } + self.showNotification("Language server is warming up or indexing. Navigation will continue when it is ready.") + } ) { [weak self] result in guard let self, self.languageNavigationRequestID == requestID else { return } self.isLoadingLanguageNavigation = false diff --git a/Sources/Lithe/Services/LanguageToolingSessionManager.swift b/Sources/Lithe/Services/LanguageToolingSessionManager.swift index f952fd8b5..e5a2954a9 100644 --- a/Sources/Lithe/Services/LanguageToolingSessionManager.swift +++ b/Sources/Lithe/Services/LanguageToolingSessionManager.swift @@ -40,7 +40,9 @@ final class LanguageToolingSessionManager: ObservableObject { private struct PendingNavigationRequest { var completions: [(Result<[LanguageServerLocation], Error>) -> Void] var provisionalObservers: [([LanguageServerLocation]) -> Void] + var languageServerWaitingObservers: [() -> Void] var latestProvisional: [LanguageServerLocation]? + var didPublishLanguageServerWaiting: Bool var interactiveDeadlineTask: Task? } @@ -378,6 +380,7 @@ final class LanguageToolingSessionManager: ObservableObject { position: LanguageServerPosition, rootURL: URL, provisional: (([LanguageServerLocation]) -> Void)? = nil, + waitingForLanguageServer: (() -> Void)? = nil, completion: @escaping (Result<[LanguageServerLocation], Error>) -> Void ) throws { let context = featureContext( @@ -417,13 +420,19 @@ final class LanguageToolingSessionManager: ObservableObject { pending.provisionalObservers.append(provisional) if let latest = pending.latestProvisional { provisional(latest) } } + if let waitingForLanguageServer { + pending.languageServerWaitingObservers.append(waitingForLanguageServer) + if pending.didPublishLanguageServerWaiting { waitingForLanguageServer() } + } pendingNavigationRequests[cacheKey] = pending return } pendingNavigationRequests[cacheKey] = PendingNavigationRequest( completions: [completion], provisionalObservers: provisional.map { [$0] } ?? [], + languageServerWaitingObservers: waitingForLanguageServer.map { [$0] } ?? [], latestProvisional: nil, + didPublishLanguageServerWaiting: false, interactiveDeadlineTask: nil ) @@ -446,7 +455,10 @@ final class LanguageToolingSessionManager: ObservableObject { let startedAt = Date() guard !projectSymbols.isEmpty else { - func finishWithFallback(source: String) { + func finishWithFallback( + source: String, + completeWhenEmpty: Bool = true + ) { guard pendingNavigationRequests[cacheKey] != nil else { return } routeNavigation( providers: fallback, @@ -456,6 +468,16 @@ final class LanguageToolingSessionManager: ObservableObject { ) { [self] fallbackResult in guard pendingNavigationRequests[cacheKey] != nil else { return } let fallbackLocations = (try? fallbackResult.get()) ?? [] + guard completeWhenEmpty || !fallbackLocations.isEmpty else { + self.publishLanguageServerWaiting(for: cacheKey) + self.recordNavigationFallbackMiss( + fileURL: fileURL, + method: method, + source: source, + startedAt: startedAt + ) + return + } recordNavigationTiming( fileURL: fileURL, method: method, @@ -470,7 +492,10 @@ final class LanguageToolingSessionManager: ObservableObject { } } scheduleNavigationInteractiveDeadline(for: cacheKey) { - finishWithFallback(source: "interactive-deadline") + finishWithFallback( + source: "interactive-deadline", + completeWhenEmpty: false + ) } routeNavigation( providers: languageServers, @@ -518,7 +543,10 @@ final class LanguageToolingSessionManager: ObservableObject { completeNavigationRequest(cacheKey, with: .success(locations)) } - func finishWithFallback(source: String = "builtin") { + func finishWithFallback( + source: String = "builtin", + completeWhenEmpty: Bool = true + ) { guard !isFinished else { return } routeNavigation( providers: fallback, @@ -526,13 +554,27 @@ final class LanguageToolingSessionManager: ObservableObject { method: method, context: context ) { result in - finish((try? result.get()) ?? [], source: source, cache: true) + let locations = (try? result.get()) ?? [] + guard completeWhenEmpty || !locations.isEmpty else { + self.publishLanguageServerWaiting(for: cacheKey) + self.recordNavigationFallbackMiss( + fileURL: fileURL, + method: method, + source: source, + startedAt: startedAt + ) + return + } + finish(locations, source: source, cache: true) } } scheduleNavigationInteractiveDeadline(for: cacheKey) { if projectLocations.isEmpty { - finishWithFallback(source: "interactive-deadline") + finishWithFallback( + source: "interactive-deadline", + completeWhenEmpty: false + ) } else { finish(projectLocations, source: "project-index-deadline", cache: true) } @@ -1193,6 +1235,14 @@ final class LanguageToolingSessionManager: ObservableObject { for observer in pending.provisionalObservers { observer(locations) } } + private func publishLanguageServerWaiting(for key: NavigationCacheKey) { + guard var pending = pendingNavigationRequests[key], + !pending.didPublishLanguageServerWaiting else { return } + pending.didPublishLanguageServerWaiting = true + pendingNavigationRequests[key] = pending + for observer in pending.languageServerWaitingObservers { observer() } + } + private func completeNavigationRequest( _ key: NavigationCacheKey, with result: Result<[LanguageServerLocation], Error> @@ -1237,6 +1287,22 @@ final class LanguageToolingSessionManager: ObservableObject { ) } + private func recordNavigationFallbackMiss( + fileURL: URL, + method: String, + source: String, + startedAt: Date + ) { + let providerID = catalog.provider(for: fileURL)?.id ?? "language-tooling" + let elapsedMilliseconds = max(0, Int(Date().timeIntervalSince(startedAt) * 1_000)) + recordLanguageServerLog( + providerID: providerID, + level: .info, + message: "Navigation fallback missed; continuing language server request", + detail: "method=\(method) source=\(source) elapsedMs=\(elapsedMilliseconds) results=0" + ) + } + private static func textFingerprint(_ text: String) -> Int { var hasher = Hasher() hasher.combine(text.utf16.count) diff --git a/Sources/Lithe/Views/CodeEditorView.swift b/Sources/Lithe/Views/CodeEditorView.swift index 997c18e88..538dc2778 100644 --- a/Sources/Lithe/Views/CodeEditorView.swift +++ b/Sources/Lithe/Views/CodeEditorView.swift @@ -193,6 +193,9 @@ struct CodeEditorView: NSViewRepresentable { textView.onNavigateToSymbol = { [weak model] line, utf16Column in model?.navigateToSymbol(line: line, utf16Column: utf16Column, in: document.url) } + textView.onGoToDeclarationOrUsages = { [weak model] line, column in + model?.goToDeclarationOrUsages(line: line, utf16Column: column) + } textView.onGoToDefinition = { [weak model] line, column in model?.goToDefinition(line: line, utf16Column: column) } @@ -925,6 +928,7 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { var languageServerFeatures: LanguageServerFeatureSet = [] var onWindowAttached: (() -> Void)? var onNavigateToSymbol: ((Int, Int) -> Void)? + var onGoToDeclarationOrUsages: ((Int, Int) -> Void)? var onGoToDefinition: ((Int, Int) -> Void)? var onGoToImplementation: ((Int, Int) -> Void)? var onFindUsages: ((Int, Int) -> Void)? @@ -1010,6 +1014,13 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { requestLanguageCompletions() return true } + if modifiers == .command, + character?.lowercased() == "b", + !languageServerFeatures.intersection([.definition, .references]).isEmpty { + let position = languageServerPosition(at: selectedRange().location) + onGoToDeclarationOrUsages?(position.line, position.utf16Column) + return true + } return super.performKeyEquivalent(with: event) } diff --git a/Tests/LitheTests/LanguageFeatureProviderTests.swift b/Tests/LitheTests/LanguageFeatureProviderTests.swift index bfd19ee98..123602a0d 100644 --- a/Tests/LitheTests/LanguageFeatureProviderTests.swift +++ b/Tests/LitheTests/LanguageFeatureProviderTests.swift @@ -318,6 +318,55 @@ struct LanguageFeatureProviderTests { }) } + @Test + func managerKeepsWaitingForLateLSPWhenInteractiveFallbackMisses() async throws { + let fileURL = URL(fileURLWithPath: "/tmp/main.go") + let semanticLocation = Self.location(fileURL, line: 9) + let lsp = NavigationFeatureProvider( + id: "lsp:test", + priority: .languageServer, + locations: [semanticLocation], + delayMilliseconds: 60 + ) + let fallback = NavigationFeatureProvider( + id: "fallback", + priority: .builtin, + locations: [] + ) + let manager = LanguageToolingSessionManager( + languageFeatureProviders: [lsp, fallback], + navigationInteractiveDeadline: .milliseconds(20) + ) + var waitingNotificationCount = 0 + + let result = try await withCheckedThrowingContinuation { continuation in + do { + try manager.navigate( + method: "textDocument/definition", + fileURL: fileURL, + text: "externalSymbol", + position: LanguageServerPosition(line: 0, utf16Column: 2), + rootURL: fileURL.deletingLastPathComponent(), + waitingForLanguageServer: { + waitingNotificationCount += 1 + } + ) { continuation.resume(with: $0) } + } catch { + continuation.resume(throwing: error) + } + } + + #expect(result == [semanticLocation]) + #expect(waitingNotificationCount == 1) + #expect(manager.languageServerLogs.contains { + $0.message == "Navigation fallback missed; continuing language server request" + }) + #expect(manager.languageServerLogs.contains { + $0.detail?.contains("source=language-server") == true + && $0.detail?.contains("results=1") == true + }) + } + private static func item(label: String, detail: String) -> LanguageServerCompletionItem { LanguageServerCompletionItem( label: label, diff --git a/Tests/LitheTests/LitheCoreLogicTests.swift b/Tests/LitheTests/LitheCoreLogicTests.swift index fbeb2209e..adb14a529 100644 --- a/Tests/LitheTests/LitheCoreLogicTests.swift +++ b/Tests/LitheTests/LitheCoreLogicTests.swift @@ -2064,6 +2064,39 @@ struct LitheCoreLogicTests { #expect(handled) } + @Test + @MainActor + func codeEditorUsesLiveSelectionForCommandBNavigation() throws { + let textView = CodeTextView(frame: .zero) + textView.string = "fn main() {}\nstruct Request {}\n" + textView.rebuildLineIndex() + textView.languageServerFeatures = [.definition, .references] + let requestRange = (textView.string as NSString).range(of: "Request") + textView.setSelectedRange(NSRange(location: requestRange.location + 2, length: 0)) + var requestedPosition: (line: Int, column: Int)? + textView.onGoToDeclarationOrUsages = { line, column in + requestedPosition = (line, column) + } + let event = try #require( + NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: .command, + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "b", + charactersIgnoringModifiers: "b", + isARepeat: false, + keyCode: 11 + ) + ) + + #expect(textView.performKeyEquivalent(with: event)) + #expect(requestedPosition?.line == 1) + #expect(requestedPosition?.column == 9) + } + @Test @MainActor func codeEditorLanguageMenuTracksCurrentServerFeatures() { diff --git a/docs/architecture/language-tooling.md b/docs/architecture/language-tooling.md index 99b468e05..71c738f76 100644 --- a/docs/architecture/language-tooling.md +++ b/docs/architecture/language-tooling.md @@ -66,7 +66,7 @@ lsp/ - **Completion**:依次收集所有成功结果,保持高优先级顺序,并按 `label` 去重。因此 LSP 可提供精确候选,本地关键字和当前文件符号仍能补足结果。 - **Hover**:返回第一个非空结果;LSP 无结果或失败时继续询问本地 provider。 -- **Definition/References/Implementation**:LSP 与 project-symbol provider 并发请求。项目索引候选先返回时只作为 provisional result 展示;LSP 在 750ms 交互时限内返回非空结果时仍以语义结果为准。服务器冷启动、索引繁忙或无响应而超过交互时限时,立即使用项目候选,再降级到当前文件文本级导航,不能让编辑器等待协议层最长 30 秒的容错 deadline。迟到的服务器结果不会覆盖已经呈现的导航事务。 +- **Definition/References/Implementation**:LSP 与 project-symbol provider 并发请求。项目索引候选先返回时只作为 provisional result 展示;LSP 在 750ms 交互时限内返回非空结果时仍以语义结果为准。服务器冷启动、索引繁忙或无响应而超过交互时限时,立即使用非空的项目候选,再降级到当前文件文本级导航。若两层本地回退都没有候选,则保持原 LSP 请求存活并向界面发布“预热/索引中”状态,避免把跨文件、依赖库或宏生成符号错误地提前结束为空结果。迟到的服务器结果不会覆盖已经呈现的非空导航事务。 - **Rename/Formatting/Code Action/Resolve/Execute Command**:目前仍是 LSP-only;未运行或未声明相应能力时应返回明确的 capability 错误。 provider 抛错不会让路由提前结束。这个策略用于隔离第三方语言服务器故障,但也意味着新增 provider 时必须给出稳定优先级,并避免返回伪造的“成功但无意义”结果。 From 6939e7fb3c1d86dc5e1b59e936ff42e7c6c0becd Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Thu, 13 Aug 2026 23:49:59 +0800 Subject: [PATCH 09/10] fix(editor): support mouse-targeted navigation --- .../Lithe/Models/AppModel+Development.swift | 18 ----- Sources/Lithe/Views/CodeEditorView.swift | 41 +++++----- Tests/LitheTests/LitheCoreLogicTests.swift | 74 +++++++++++++++++++ 3 files changed, 98 insertions(+), 35 deletions(-) diff --git a/Sources/Lithe/Models/AppModel+Development.swift b/Sources/Lithe/Models/AppModel+Development.swift index f8b9d39ae..5e1975c71 100644 --- a/Sources/Lithe/Models/AppModel+Development.swift +++ b/Sources/Lithe/Models/AppModel+Development.swift @@ -463,24 +463,6 @@ extension AppModel { ) } - func navigateToSymbol(line: Int, utf16Column: Int, in fileURL: URL) { - let normalizedURL = fileURL.standardizedFileURL - guard languageProviderCatalog.provider(for: normalizedURL)?.capabilities.contains(.languageServer) == true - else { return } - editorCaret = EditorCaret( - url: normalizedURL, - line: max(0, line), - utf16Column: max(0, utf16Column) - ) - if languageToolingSessions.features(for: normalizedURL).contains(.definition) { - performGenericNavigation( - method: "textDocument/definition", - kind: .definitions, - fallbackToImplementationsIfSelf: true - ) - } - } - func findReferences(line: Int? = nil, utf16Column: Int? = nil) { guard supportsLanguageServerFeature(.references) else { showNotification("Reference navigation is not supported by this language server") diff --git a/Sources/Lithe/Views/CodeEditorView.swift b/Sources/Lithe/Views/CodeEditorView.swift index 538dc2778..0005a870e 100644 --- a/Sources/Lithe/Views/CodeEditorView.swift +++ b/Sources/Lithe/Views/CodeEditorView.swift @@ -190,9 +190,6 @@ struct CodeEditorView: NSViewRepresentable { textView.isLanguageNavigationEnabled = !textView.languageServerFeatures.intersection([ .definition, .references, .implementation ]).isEmpty - textView.onNavigateToSymbol = { [weak model] line, utf16Column in - model?.navigateToSymbol(line: line, utf16Column: utf16Column, in: document.url) - } textView.onGoToDeclarationOrUsages = { [weak model] line, column in model?.goToDeclarationOrUsages(line: line, utf16Column: column) } @@ -927,7 +924,6 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { var isLanguageIntelligenceEnabled = false var languageServerFeatures: LanguageServerFeatureSet = [] var onWindowAttached: (() -> Void)? - var onNavigateToSymbol: ((Int, Int) -> Void)? var onGoToDeclarationOrUsages: ((Int, Int) -> Void)? var onGoToDefinition: ((Int, Int) -> Void)? var onGoToImplementation: ((Int, Int) -> Void)? @@ -1017,8 +1013,7 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { if modifiers == .command, character?.lowercased() == "b", !languageServerFeatures.intersection([.definition, .references]).isEmpty { - let position = languageServerPosition(at: selectedRange().location) - onGoToDeclarationOrUsages?(position.line, position.utf16Column) + requestDeclarationOrUsages(at: commandBNavigationLocation()) return true } return super.performKeyEquivalent(with: event) @@ -1620,11 +1615,8 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { if hasNavigationModifier(event.modifierFlags) { updateLinkHighlight(at: point) - if let linkRange, - let characterIndex = characterIndex(at: point), - NSLocationInRange(characterIndex, linkRange) { - let (line, column) = lineAndColumn(for: linkRange.location) - onNavigateToSymbol?(line, column) + if let target = linkRange(at: point) { + requestDeclarationOrUsages(at: target.location) return } } @@ -1802,12 +1794,27 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { return mask.contains(.command) || mask.contains(.control) } - private func lineAndColumn(for location: Int) -> (line: Int, column: Int) { - let source = string as NSString - let safeLocation = min(max(0, location), source.length) - let line = lineIndex.lineNumber(at: safeLocation) - let lineStart = lineIndex.characterOffset(forLine: line) - return (line, safeLocation - lineStart) + private func commandBNavigationLocation() -> Int { + let mouseSymbolRange: NSRange? + if let window { + let point = convert(window.mouseLocationOutsideOfEventStream, from: nil) + mouseSymbolRange = visibleRect.contains(point) ? linkRange(at: point) : nil + } else { + mouseSymbolRange = nil + } + return Self.navigationLocation( + caretLocation: selectedRange().location, + mouseSymbolRange: mouseSymbolRange + ) + } + + static func navigationLocation(caretLocation: Int, mouseSymbolRange: NSRange?) -> Int { + mouseSymbolRange?.location ?? caretLocation + } + + private func requestDeclarationOrUsages(at location: Int) { + let position = languageServerPosition(at: location) + onGoToDeclarationOrUsages?(position.line, position.utf16Column) } private var centeredParagraphStyle: NSParagraphStyle { diff --git a/Tests/LitheTests/LitheCoreLogicTests.swift b/Tests/LitheTests/LitheCoreLogicTests.swift index adb14a529..b51986c8f 100644 --- a/Tests/LitheTests/LitheCoreLogicTests.swift +++ b/Tests/LitheTests/LitheCoreLogicTests.swift @@ -2097,6 +2097,80 @@ struct LitheCoreLogicTests { #expect(requestedPosition?.column == 9) } + @Test + @MainActor + func codeEditorCommandBNavigationPrefersTheSymbolUnderTheMouse() { + let caret = NSRange(location: 2, length: 0) + let hoveredSymbol = NSRange(location: 25, length: 7) + + #expect( + CodeTextView.navigationLocation( + caretLocation: caret.location, + mouseSymbolRange: hoveredSymbol + ) == hoveredSymbol.location + ) + #expect( + CodeTextView.navigationLocation( + caretLocation: caret.location, + mouseSymbolRange: nil + ) == caret.location + ) + } + + @Test + @MainActor + func codeEditorCommandClickNavigatesTheSymbolUnderTheMouse() throws { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 480, height: 120), + styleMask: .borderless, + backing: .buffered, + defer: false + ) + let textView = CodeTextView(frame: NSRect(x: 0, y: 0, width: 480, height: 120)) + window.contentView = textView + textView.string = "fn main() {}\nstruct Request {}\n" + textView.font = .monospacedSystemFont(ofSize: 13, weight: .regular) + textView.rebuildLineIndex() + textView.languageServerFeatures = [.definition, .references] + textView.isLanguageNavigationEnabled = true + let requestRange = (textView.string as NSString).range(of: "Request") + let textContainer = try #require(textView.textContainer) + let layoutManager = try #require(textView.layoutManager) + layoutManager.ensureLayout(for: textContainer) + let glyphRange = layoutManager.glyphRange( + forCharacterRange: requestRange, + actualCharacterRange: nil + ) + let glyphRect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) + let clickLocationInView = NSPoint( + x: textView.textContainerOrigin.x + glyphRect.midX, + y: textView.textContainerOrigin.y + glyphRect.midY + ) + let clickLocationInWindow = textView.convert(clickLocationInView, to: nil) + var requestedPosition: (line: Int, column: Int)? + textView.onGoToDeclarationOrUsages = { line, column in + requestedPosition = (line, column) + } + let event = try #require( + NSEvent.mouseEvent( + with: .leftMouseDown, + location: clickLocationInWindow, + modifierFlags: .command, + timestamp: 0, + windowNumber: window.windowNumber, + context: nil, + eventNumber: 0, + clickCount: 1, + pressure: 1 + ) + ) + + textView.mouseDown(with: event) + + #expect(requestedPosition?.line == 1) + #expect(requestedPosition?.column == 7) + } + @Test @MainActor func codeEditorLanguageMenuTracksCurrentServerFeatures() { From 7bb9f98a3eb672defbfc9956e479a22cb263133c Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Fri, 14 Aug 2026 08:33:47 +0800 Subject: [PATCH 10/10] =?UTF-8?q?fix:=E6=81=A2=E5=A4=8D=E5=8E=9F=E6=9D=A5?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=20JDK=20=E8=B7=AF=E5=BE=84=E7=9A=84=E9=83=A8?= =?UTF-8?q?=E5=88=86=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../LanguageToolingFeatureModel.swift | 37 +++++++++++++++---- Sources/Lithe/Models/AppModel.swift | 1 - .../Lithe/Views/LSPControlCenterView.swift | 6 +-- Tests/LitheTests/LitheCoreLogicTests.swift | 15 ++++++++ docs/architecture/language-tooling.md | 4 +- 5 files changed, 50 insertions(+), 13 deletions(-) diff --git a/Sources/Lithe/Application/LanguageToolingFeatureModel.swift b/Sources/Lithe/Application/LanguageToolingFeatureModel.swift index 9377ed318..65bfab545 100644 --- a/Sources/Lithe/Application/LanguageToolingFeatureModel.swift +++ b/Sources/Lithe/Application/LanguageToolingFeatureModel.swift @@ -1,6 +1,26 @@ import Combine import Foundation +struct WorkspaceLanguageServerEnablement { + private(set) var enabledProviderIDs: Set = [] + + func isDisabled(_ providerID: String) -> Bool { + !enabledProviderIDs.contains(providerID) + } + + mutating func setEnabled(_ enabled: Bool, providerID: String) { + if enabled { + enabledProviderIDs.insert(providerID) + } else { + enabledProviderIDs.remove(providerID) + } + } + + mutating func reset() { + enabledProviderIDs.removeAll() + } +} + /// Owns language-provider selection and workspace-scoped language-server UI state. /// Protocol/session ownership remains in LanguageToolingSessionManager; this model /// coordinates the feature without exposing that workflow to views. @@ -8,7 +28,7 @@ import Foundation final class LanguageToolingFeatureModel: ObservableObject { @Published private(set) var catalog: LanguageProviderCatalog @Published private(set) var catalogSnapshot: LanguageProviderCatalogSnapshot - private(set) var disabledProviderIDs: Set = [] + @Published private var enablement = WorkspaceLanguageServerEnablement() private(set) var startupFailures: [String: String] = [:] private let catalogSource: any LanguageProviderCatalogSource @@ -51,7 +71,7 @@ final class LanguageToolingFeatureModel: ObservableObject { } func resetWorkspaceState() { - disabledProviderIDs.removeAll() + enablement.reset() startupFailures.removeAll() } @@ -63,15 +83,14 @@ final class LanguageToolingFeatureModel: ObservableObject { } func isDisabled(_ providerID: String) -> Bool { - disabledProviderIDs.contains(providerID) + enablement.isDisabled(providerID) } func setEnabled(_ enabled: Bool, providerID: String) { + enablement.setEnabled(enabled, providerID: providerID) if enabled { - disabledProviderIDs.remove(providerID) synchronizeOpenDocuments(providerID: providerID) } else { - disabledProviderIDs.insert(providerID) sessions.recordLanguageServerLog( providerID: providerID, level: .warning, @@ -83,21 +102,23 @@ final class LanguageToolingFeatureModel: ObservableObject { } func toolConfigurationDidChange(providerID: String) { - disabledProviderIDs.remove(providerID) startupFailures[providerID] = nil sessions.stopLanguageServer(providerID: providerID) sessions.recordLanguageServerLog( providerID: providerID, level: .info, message: "Language server tool configuration changed", - detail: "Workspace disable state cleared" + detail: isDisabled(providerID) + ? "Configuration saved; language server remains disabled" + : "Restarting enabled language server" ) + guard !isDisabled(providerID) else { return } + synchronizeOpenDocuments(providerID: providerID) } func selectJavaJDK(_ path: String) { settings.javaLanguageServerJDKPath = path toolConfigurationDidChange(providerID: "java") - synchronizeOpenDocuments(providerID: "java") } func markActivationSucceeded(providerID: String) { diff --git a/Sources/Lithe/Models/AppModel.swift b/Sources/Lithe/Models/AppModel.swift index 4e5f5b8a8..1e3e0c044 100644 --- a/Sources/Lithe/Models/AppModel.swift +++ b/Sources/Lithe/Models/AppModel.swift @@ -641,7 +641,6 @@ final class AppModel: ObservableObject, Identifiable { func restartLanguageServers() { languageToolingSessions.stopAllLanguageServers() - languageToolingFeature.resetWorkspaceState() let didStart = activateCurrentDocumentLanguageServerIfAvailable() showNotification( didStart diff --git a/Sources/Lithe/Views/LSPControlCenterView.swift b/Sources/Lithe/Views/LSPControlCenterView.swift index 19b5b98cb..40ba785b4 100644 --- a/Sources/Lithe/Views/LSPControlCenterView.swift +++ b/Sources/Lithe/Views/LSPControlCenterView.swift @@ -115,8 +115,8 @@ struct LSPControlCenterView: View { .font(.system(size: 14, weight: .semibold)) .foregroundStyle(LitheTheme.primaryText) Text(usesChinese - ? "内置 Provider 与项目配置合并显示。每个 LSP 都可以自动探测、指定可执行文件或通过 Brew 安装。" - : "Built-in providers are merged with project configuration. Every LSP supports automatic discovery, a custom executable, and Brew installation.") + ? "LSP 默认关闭以避免后台性能损耗。启用后可自动探测、指定可执行文件或通过 Brew 安装。" + : "Language servers are off by default to avoid background overhead. Once enabled, each supports automatic discovery, a custom executable, and Brew installation.") .font(.system(size: 11.5)) .foregroundStyle(LitheTheme.secondaryText) .fixedSize(horizontal: false, vertical: true) @@ -222,7 +222,7 @@ struct LSPControlCenterView: View { ) } - if configuredProviderID == descriptor.id, descriptor.id == "java" { + if descriptor.id == "java" { Divider().overlay(LitheTheme.divider) VStack(alignment: .leading, spacing: 7) { Text(usesChinese ? "LSP 运行 JDK" : "LSP Runtime JDK") diff --git a/Tests/LitheTests/LitheCoreLogicTests.swift b/Tests/LitheTests/LitheCoreLogicTests.swift index b51986c8f..dbbaf4ece 100644 --- a/Tests/LitheTests/LitheCoreLogicTests.swift +++ b/Tests/LitheTests/LitheCoreLogicTests.swift @@ -6,6 +6,21 @@ import Testing @Suite("Lithe core logic") struct LitheCoreLogicTests { + @Test + func languageServersAreDisabledUntilExplicitlyEnabledInAWorkspace() { + var enablement = WorkspaceLanguageServerEnablement() + + #expect(enablement.isDisabled("java")) + #expect(enablement.isDisabled("rust")) + + enablement.setEnabled(true, providerID: "java") + #expect(!enablement.isDisabled("java")) + #expect(enablement.isDisabled("rust")) + + enablement.reset() + #expect(enablement.isDisabled("java")) + } + @Test @MainActor func closingAWorkspaceWindowClosesTheProjectInsteadOfTheWindow() { diff --git a/docs/architecture/language-tooling.md b/docs/architecture/language-tooling.md index 71c738f76..bd36f87e3 100644 --- a/docs/architecture/language-tooling.md +++ b/docs/architecture/language-tooling.md @@ -8,7 +8,7 @@ 语言能力不应等同于“已经启动一个 LSP 进程”。当前实现遵循以下规则: 1. 编辑器只依赖统一的 `LanguageFeatureProvider`,不直接依赖具体语言服务器。 -2. 轻量本地能力无需外部进程,LSP 是按需启动的语义增强层。 +2. 轻量本地能力无需外部进程;LSP 默认关闭,只有用户在当前工作区显式启用后才按需启动。 3. 可调用的 LSP 功能以服务器 `initialize` 响应和动态注册结果为准,不能根据语言名称硬编码。 4. LSP 进程、stdio、JSON-RPC 状态机、deadline 和结果归一化属于 Rust Core;平台 adapter 只负责可执行文件与运行环境发现。 5. 单个 provider 失败、缺失或返回空结果时,不应阻断仍可工作的本地能力。 @@ -134,6 +134,8 @@ Rust Core 的 `lsp.builtinCompletions`、`lsp.builtinHover` 和 macOS discovery 的查找顺序包括项目 `.lithe` 工具目录、`LITHE__PATH`/`LITHE_TOOL__PATH`、`PATH` 和常见系统目录;`gopls` 等 Go 工具还会检查 `GOBIN`、`GOPATH/bin`、`~/go/bin` 和 `~/.go/bin`。discovery 只查找,不自动安装软件。 +打开工作区或切换项目时,所有 LSP provider 均从关闭状态开始。用户必须在 LSP 控制中心逐个启用;只修改可执行文件、Homebrew 安装结果或 Java LSP 运行 JDK,不会隐式启用或启动服务器。Java 的 JDTLS 工具路径与 LSP 运行 JDK 配置在关闭状态下仍可编辑和持久化。 + LSP 控制中心标题栏的工具设置会在用户偏好中保存每个 provider 的可执行文件覆盖路径。session 创建时先验证并使用该路径,路径失效时继续使用 catalog 候选进行自动探测。Homebrew formula 和官方兜底地址都来自 `languageServerInstallation`,Swift 不维护 provider ID 映射。安装仍由平台层以参数数组直接执行 `brew install`,不经过 shell;没有 Homebrew/formula 时只打开对应项目的 HTTPS 官方发布或安装页面,避免用一套不安全的通用解压逻辑处理不同项目的签名和包结构。 项目配置是可执行工具配置,只有打开受信任项目时才应启用。JSON 可以声明 executable name 和参数,但不能声明 shell、任意安装命令或关闭路径/URL 校验;进程创建、超时、可执行文件验证、Homebrew 调用方式和 HTTPS 限制仍属于平台安全边界。