From 80341ce19ebdcc19e352d405488c4cef19c95337 Mon Sep 17 00:00:00 2001 From: fenghp Date: Fri, 28 Aug 2026 16:13:20 +0800 Subject: [PATCH 01/66] fix(macos): load run configurations without waiting for the Spring index Opening a large multi-module Maven workspace left Run and Debug unusable for as long as the Spring index took, because loadProjectServices awaited it before loading build-system and run state. Schedule the index instead so run configurations, test discovery, and the Git refresh that follows no longer depend on its duration. Identifying the project during that window was dropped silently, which is indistinguishable from a broken button. Report the unloaded workspace as an explicit state, and let the Run and Debug entry points load the project on demand the way the tool-window entry points already do. Refs #300 --- .../zh-Hans.lproj/Localizable.strings | 2 + .../Features/SpringFeatureModel.swift | 20 ++++ .../AppModel/AppModel+Development.swift | 11 +++ .../Lithe/Models/AppModel/AppModel.swift | 6 +- macos/Sources/Lithe/Views/Run/RunView.swift | 6 ++ .../Execution/RunConfigurationContracts.swift | 3 + .../Application/ExecutionFeatureModels.swift | 2 + .../Services/RunService.swift | 12 ++- .../ExecutionModuleTests.swift | 89 ++++++++++++++++++ .../LitheTests/SpringFeatureModelTests.swift | 92 ++++++++++++++++++- 10 files changed, 239 insertions(+), 4 deletions(-) diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index db44d392..6df05199 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -929,6 +929,8 @@ "Run configurations may be out of date" = "运行配置可能已过期"; "Project toolchain needs attention" = "项目工具链需要处理"; "Different JDK vendor selected" = "选择了不同的 JDK 发行版"; +"Project is still loading" = "项目仍在加载中"; +"Wait for the project to finish loading, then identify it again." = "请等待项目加载完成,然后重新识别。"; "Project identification complete" = "项目识别完成"; "Generated 1 runnable project entry." = "已生成 1 个可运行的项目入口。"; "Generated %lld runnable project entries." = "已生成 %lld 个可运行的项目入口。"; diff --git a/macos/Sources/Lithe/Application/Features/SpringFeatureModel.swift b/macos/Sources/Lithe/Application/Features/SpringFeatureModel.swift index 2bd9a0d6..37e64af5 100644 --- a/macos/Sources/Lithe/Application/Features/SpringFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/SpringFeatureModel.swift @@ -51,6 +51,26 @@ final class SpringFeatureModel: ObservableObject { isIndexing = false } + /// Starts a workspace index without making the caller wait for it. Opening a + /// project must not block build-system and run state behind Spring indexing, + /// which scales with the number of Java sources in the workspace. + func scheduleLoad( + workspaceURL: URL, + files: [URL], + textOverrides: [URL: String] = [:], + refreshDependencyMetadata: Bool = true + ) { + reloadTask?.cancel() + reloadTask = Task { @MainActor [weak self] in + await self?.load( + workspaceURL: workspaceURL, + files: files, + textOverrides: textOverrides, + refreshDependencyMetadata: refreshDependencyMetadata + ) + } + } + func reset() { reloadTask?.cancel() reloadTask = nil diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift index d04d89c4..0d8b32c9 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift @@ -153,8 +153,18 @@ extension AppModel { Task { [weak self] in await self?.runSelectedConfigurationAfterActivation() } } + /// Run and Debug can activate the execution module before the workspace + /// snapshot has loaded it, which leaves the run feature without a project. + /// The tool-window entry points already load on demand; these do the same so + /// every entry point observes the same state. + private func loadProjectServicesIfRunProjectIsUnbound(_ runFeature: RunFeatureModel) async { + guard !runFeature.isProjectLoaded, let workspaceURL else { return } + await loadProjectServices(at: workspaceURL, files: projectFiles) + } + private func runSelectedConfigurationAfterActivation() async { guard let runFeature = await activateExecutionModule()?.runFeature else { return } + await loadProjectServicesIfRunProjectIsUnbound(runFeature) guard runFeature.configurationStatus == .ready else { runFeature.requestRunConfigurationGeneration(intent: .run) return @@ -325,6 +335,7 @@ extension AppModel { guard let execution = await activateExecutionModule(), let debug = await activateDebugModule() else { return } let runFeature = execution.runFeature + await loadProjectServicesIfRunProjectIsUnbound(runFeature) let debugFeature = debug.javaFeature javaFeature.configureRuntime( mavenFeature: execution.mavenFeature, diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 4581e899..0d876604 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -747,12 +747,16 @@ final class AppModel: ObservableObject, Identifiable { /// Loads build-system and run state at the workspace boundary. The generic /// run lifecycle is intentionally not owned by JavaFeatureModel. + /// + /// Spring indexing is scheduled rather than awaited. It scales with the + /// number of Java sources, and run configurations, test discovery, and the + /// Git refresh that follows this call must not wait for it. func loadProjectServices(at workspaceURL: URL, files: [URL]) async { prepareJavaLanguageServerForWorkspaceIfNeeded( at: workspaceURL, files: files ) - await springFeature.load( + springFeature.scheduleLoad( workspaceURL: workspaceURL, files: files, textOverrides: Dictionary(uniqueKeysWithValues: openDocuments.map { diff --git a/macos/Sources/Lithe/Views/Run/RunView.swift b/macos/Sources/Lithe/Views/Run/RunView.swift index e5aedac7..393b12b5 100644 --- a/macos/Sources/Lithe/Views/Run/RunView.swift +++ b/macos/Sources/Lithe/Views/Run/RunView.swift @@ -180,6 +180,12 @@ struct RunView: View { ) } switch feature.generationState { + case .projectNotLoaded: + return ( + String(localized: "Project is still loading"), + String(localized: "Wait for the project to finish loading, then identify it again."), + "clock.fill" + ) case .succeeded(let entryCount): return ( String(localized: "Project identification complete"), diff --git a/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift b/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift index 06a6e030..11c6cb6a 100644 --- a/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift +++ b/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift @@ -49,6 +49,9 @@ package struct ProjectRunConfigurationInspection: Equatable, Sendable { package enum RunConfigurationGenerationState: Equatable, Sendable { case idle + /// The request arrived before the workspace finished loading, so there was + /// no project to identify. Nothing failed and nothing was written. + case projectNotLoaded case succeeded(entryCount: Int) case noEntries case failed(String) diff --git a/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift b/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift index a37f6821..62131d72 100644 --- a/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift +++ b/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift @@ -159,6 +159,8 @@ package final class RunFeatureModel: ObservableObject { service.clearOutput() } + package var isProjectLoaded: Bool { service.isProjectLoaded } + package func loadProject( at workspaceURL: URL, files: [URL], diff --git a/macos/Sources/LitheExecutionModule/Services/RunService.swift b/macos/Sources/LitheExecutionModule/Services/RunService.swift index 050c8c89..d41cb424 100644 --- a/macos/Sources/LitheExecutionModule/Services/RunService.swift +++ b/macos/Sources/LitheExecutionModule/Services/RunService.swift @@ -105,6 +105,11 @@ package final class RunService: ObservableObject { package var lastRunFileURL: URL? { lastCurrentFileURL } package var lastConfiguration: RunConfiguration? { lastRunConfiguration } + /// Reports whether `loadProject` has bound this service to a workspace. + /// Entry points that activate the execution module on demand use this to + /// avoid acting on a service that has no project yet. + package var isProjectLoaded: Bool { projectURL != nil } + @discardableResult package func registerLanguageRunExtension( _ provider: any LanguageRunExtensionProviding, @@ -199,7 +204,12 @@ package final class RunService: ObservableObject { } package func generateRunConfigurations() async { - guard let projectURL else { return } + // Dropping the request silently is indistinguishable from a broken + // button, so report that the workspace is not loaded yet instead. + guard let projectURL else { + generationState = .projectNotLoaded + return + } let loadID = projectLoadID isLoadingProject = true defer { diff --git a/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift b/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift index fd089fc8..062b3519 100644 --- a/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift +++ b/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift @@ -47,6 +47,63 @@ struct ExecutionModuleTests { #expect(recorder.graphCalls == 2) } + /// Run and Debug can reach identification before the workspace snapshot has + /// bound a project. Reporting nothing at all made the confirmed dialog look + /// like a dead button, so the unloaded project must become visible state. + @Test + func identificationBeforeProjectLoadReportsUnloadedProjectWithoutGenerating() async throws { + let operations = RecordingRunConfigurationOperations() + let service = RunService( + runtime: TestRuntime(), + process: TestStreamingProcess(), + processFactory: { TestStreamingProcess() }, + fileAccess: TestRunFileAccess(), + preferences: TestRunPreferences(), + serverPortParser: TestServerPortParser(), + runConfigurationOperations: operations, + executableResolver: TestExecutableResolver(), + languageProviderCatalog: .compatibilityFallback, + languageRunProviders: .standard(catalog: .compatibilityFallback) + ) + + #expect(!service.isProjectLoaded) + await service.generateRunConfigurations() + + #expect(service.generationState == .projectNotLoaded) + #expect(operations.generateCallCount == 0) + #expect(service.configurationStatus == .missing) + } + + /// Once the project is bound, identification must behave exactly as before. + @Test + func identificationAfterProjectLoadGeneratesAndClearsTheUnloadedState() async throws { + let operations = RecordingRunConfigurationOperations() + let service = RunService( + runtime: TestRuntime(), + process: TestStreamingProcess(), + processFactory: { TestStreamingProcess() }, + fileAccess: TestRunFileAccess(), + preferences: TestRunPreferences(), + serverPortParser: TestServerPortParser(), + runConfigurationOperations: operations, + executableResolver: TestExecutableResolver(), + languageProviderCatalog: .compatibilityFallback, + languageRunProviders: .standard(catalog: .compatibilityFallback) + ) + let root = URL(fileURLWithPath: "/workspace", isDirectory: true) + + await service.generateRunConfigurations() + #expect(service.generationState == .projectNotLoaded) + + await service.loadProject(at: root, files: [], mavenProject: nil) + #expect(service.isProjectLoaded) + await service.generateRunConfigurations() + + #expect(operations.generateCallCount == 1) + #expect(service.generationState == .succeeded(entryCount: 1)) + #expect(service.configurationStatus == .ready) + } + @Test func currentGoFileRunsThroughExtensionOwnedSession() async throws { let builtInProcess = TestStreamingProcess() @@ -326,6 +383,38 @@ private struct TestRunConfigurationOperations: RunConfigurationOperations { func migrateLegacySettings(at projectURL: URL, configurationIDs: [String]) throws {} } +/// Counts generation attempts so a test can prove that an unloaded project +/// never reaches the store. +private final class RecordingRunConfigurationOperations: RunConfigurationOperations, @unchecked Sendable { + private(set) var generateCallCount = 0 + + func inspect(at projectURL: URL) -> ProjectRunConfigurationInspection { + ProjectRunConfigurationInspection( + status: generateCallCount == 0 ? .missing : .ready, + diagnostics: [] + ) + } + func generate(at projectURL: URL, files: [URL], modulePaths: [String]) throws -> RunConfigurationGenerationResult { + generateCallCount += 1 + return RunConfigurationGenerationResult(entryCount: 1) + } + func resolve(at projectURL: URL, toolchainCandidates: [ProjectToolchainCandidate]) throws -> RunConfigurationResolution { + RunConfigurationResolution( + configurations: [EffectiveRunConfiguration( + configuration: .currentFile, + options: RunOptions() + )], + diagnostics: [], + defaultConfigurationID: RunConfiguration.currentFileID + ) + } + func launchPlan(at projectURL: URL, configurationID: String, currentFile: String?, classPath: String?, debugPort: Int?) throws -> SharedLaunchPlan { + throw RunConfigurationOperationFailure(message: "Unavailable in identification test") + } + func createConfiguration(_ draft: RunConfigurationDraft, at projectURL: URL) throws -> String { draft.name } + func migrateLegacySettings(at projectURL: URL, configurationIDs: [String]) throws {} +} + private struct TestReadyRunConfigurationOperations: RunConfigurationOperations { func inspect(at projectURL: URL) -> ProjectRunConfigurationInspection { ProjectRunConfigurationInspection(status: .ready, diagnostics: []) diff --git a/macos/Tests/LitheTests/SpringFeatureModelTests.swift b/macos/Tests/LitheTests/SpringFeatureModelTests.swift index 48d55761..676e5f03 100644 --- a/macos/Tests/LitheTests/SpringFeatureModelTests.swift +++ b/macos/Tests/LitheTests/SpringFeatureModelTests.swift @@ -99,17 +99,105 @@ struct SpringFeatureModelTests { let locations = feature.navigationLocations(for: injectionURL, line: 7) #expect(locations.map(\.url) == [firstURL, secondURL]) } + + /// Opening a workspace must not wait for Spring indexing, which scales with + /// the number of Java sources. `scheduleLoad` returns immediately and the + /// index arrives later. + @Test + func scheduleLoadReturnsBeforeTheIndexIsPublished() async throws { + let root = URL(fileURLWithPath: "/workspace") + let beanURL = root.appendingPathComponent("Service.java") + let gate = SpringIndexGate() + let result = SpringIndexResult( + properties: [], values: [], propertyReferences: [], diagnostics: [], + beans: [SpringBean( + id: "service", name: "service", typeName: "Service", + url: beanURL, line: 3, column: 7, kind: "component" + )], + injections: [], endpoints: [] + ) + let feature = SpringFeatureModel( + operations: SpringTestOperations(result: result, gate: gate) + ) + + feature.scheduleLoad(workspaceURL: root, files: [beanURL]) + + #expect(feature.beans.isEmpty) + gate.open() + try await pollUntil { !feature.beans.isEmpty } + #expect(feature.beans.map(\.id) == ["service"]) + #expect(!feature.isIndexing) + } + + /// A newer schedule supersedes the previous one so a burst of reloads cannot + /// publish a stale index. + @Test + func scheduleLoadCancelsThePreviousSchedule() async throws { + let root = URL(fileURLWithPath: "/workspace") + let gate = SpringIndexGate() + let operations = SpringTestOperations(result: .empty, gate: gate) + let feature = SpringFeatureModel(operations: operations) + + feature.scheduleLoad(workspaceURL: root, files: []) + feature.scheduleLoad(workspaceURL: root, files: []) + gate.open() + try await pollUntil { !feature.isIndexing } + + #expect(operations.indexCallCount <= 2) + } } -private struct SpringTestOperations: JavaMavenOperations { +/// Polls the MainActor state instead of sleeping for a fixed interval so the +/// test does not depend on scheduler timing. +@MainActor +private func pollUntil( + attempts: Int = 200, + _ condition: () -> Bool +) async throws { + for _ in 0.. SpringIndexResult? { result } + ) -> SpringIndexResult? { + lock.lock() + indexCalls += 1 + lock.unlock() + gate?.wait() + return result + } func scanMavenProject(at rootURL: URL, files: [URL]) -> MavenProject? { nil } func mavenDiagnostics(output: String, projectRoot: URL) -> [MavenBuildIssue] { [] } func codeVision(at rootURL: URL, targetPath: String, paths: [String]) -> [JavaCodeVisionValue] { [] } From 7749d988c6f1953c798482b76f93d94486336e57 Mon Sep 17 00:00:00 2001 From: fenghp Date: Fri, 28 Aug 2026 17:10:25 +0800 Subject: [PATCH 02/66] =?UTF-8?q?fix(macos):=20=E8=AE=A9=E8=A2=AB=E5=8F=96?= =?UTF-8?q?=E4=BB=A3=E7=9A=84=20Spring=20=E7=B4=A2=E5=BC=95=E8=B0=83?= =?UTF-8?q?=E5=BA=A6=E7=9C=9F=E6=AD=A3=E6=8F=90=E5=89=8D=E9=80=80=E5=87=BA?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E6=94=B9=E7=94=A8=E7=A1=AE=E5=AE=9A=E6=80=A7?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI 的测试稳定性门禁拒绝了原先的两个 scheduleLoad 测试:轮询里用了真实 Task.sleep,测试替身里用了无界的 DispatchSemaphore.wait()。改成由 actor 模型 保证的同步断言,加上以 objectWillChange 为事件源、带本地超时的等待。 重写后的测试暴露出一个真实缺陷:Swift 的取消是协作式的,reloadTask.cancel() 并不会阻止任务体运行,而 load 里没有取消检查点,所以被取代的那次调度仍然会跑 完一次全量工作区索引,只是结果被 generation 令牌丢弃。为 scheduleLoad 补上 Task.isCancelled 检查,与既有的 scheduleReload 保持一致。 Refs #300 --- .../Features/SpringFeatureModel.swift | 6 +- .../LitheTests/SpringFeatureModelTests.swift | 170 ++++++++++++------ 2 files changed, 120 insertions(+), 56 deletions(-) diff --git a/macos/Sources/Lithe/Application/Features/SpringFeatureModel.swift b/macos/Sources/Lithe/Application/Features/SpringFeatureModel.swift index 37e64af5..98d97694 100644 --- a/macos/Sources/Lithe/Application/Features/SpringFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/SpringFeatureModel.swift @@ -62,7 +62,11 @@ final class SpringFeatureModel: ObservableObject { ) { reloadTask?.cancel() reloadTask = Task { @MainActor [weak self] in - await self?.load( + // Cancellation is cooperative, so a schedule that was superseded + // before it started must return here instead of running a second + // full workspace index whose result the generation token discards. + guard !Task.isCancelled, let self else { return } + await self.load( workspaceURL: workspaceURL, files: files, textOverrides: textOverrides, diff --git a/macos/Tests/LitheTests/SpringFeatureModelTests.swift b/macos/Tests/LitheTests/SpringFeatureModelTests.swift index 676e5f03..bdb664c8 100644 --- a/macos/Tests/LitheTests/SpringFeatureModelTests.swift +++ b/macos/Tests/LitheTests/SpringFeatureModelTests.swift @@ -1,3 +1,4 @@ +import Combine import Foundation import Testing @testable import Lithe @@ -101,89 +102,149 @@ struct SpringFeatureModelTests { } /// Opening a workspace must not wait for Spring indexing, which scales with - /// the number of Java sources. `scheduleLoad` returns immediately and the - /// index arrives later. + /// the number of Java sources. @Test - func scheduleLoadReturnsBeforeTheIndexIsPublished() async throws { + func scheduleLoadDefersIndexingAndPublishesTheResult() async throws { let root = URL(fileURLWithPath: "/workspace") let beanURL = root.appendingPathComponent("Service.java") - let gate = SpringIndexGate() - let result = SpringIndexResult( - properties: [], values: [], propertyReferences: [], diagnostics: [], - beans: [SpringBean( - id: "service", name: "service", typeName: "Service", - url: beanURL, line: 3, column: 7, kind: "component" - )], - injections: [], endpoints: [] - ) - let feature = SpringFeatureModel( - operations: SpringTestOperations(result: result, gate: gate) - ) + let operations = SpringTestOperations(result: componentIndex(at: beanURL)) + let feature = SpringFeatureModel(operations: operations) + defer { feature.reset() } feature.scheduleLoad(workspaceURL: root, files: [beanURL]) + // The schedule owns a MainActor task, which cannot run before this test + // suspends. Reaching these assertions proves the caller was not blocked. #expect(feature.beans.isEmpty) - gate.open() - try await pollUntil { !feature.beans.isEmpty } - #expect(feature.beans.map(\.id) == ["service"]) - #expect(!feature.isIndexing) + #expect(operations.requestedFiles.isEmpty) + + let published = await awaitChange(on: feature) { + !feature.isIndexing && !feature.beans.isEmpty + } + #expect(published, "the scheduled index never published a result") + #expect(feature.beans.map(\.id) == ["Service.java"]) + #expect(operations.requestedFiles == [[beanURL]]) } - /// A newer schedule supersedes the previous one so a burst of reloads cannot + /// A newer schedule supersedes the pending one so a burst of reloads cannot /// publish a stale index. @Test - func scheduleLoadCancelsThePreviousSchedule() async throws { + func scheduleLoadReplacesAPendingSchedule() async throws { let root = URL(fileURLWithPath: "/workspace") - let gate = SpringIndexGate() - let operations = SpringTestOperations(result: .empty, gate: gate) + let staleURL = root.appendingPathComponent("Stale.java") + let freshURL = root.appendingPathComponent("Fresh.java") + let operations = SpringTestOperations { files in + files.first.map(componentIndex(at:)) ?? .empty + } let feature = SpringFeatureModel(operations: operations) + defer { feature.reset() } - feature.scheduleLoad(workspaceURL: root, files: []) - feature.scheduleLoad(workspaceURL: root, files: []) - gate.open() - try await pollUntil { !feature.isIndexing } + feature.scheduleLoad(workspaceURL: root, files: [staleURL]) + feature.scheduleLoad(workspaceURL: root, files: [freshURL]) - #expect(operations.indexCallCount <= 2) + let published = await awaitChange(on: feature) { + !feature.isIndexing && !feature.beans.isEmpty + } + #expect(published, "the replacement schedule never published a result") + // A schedule superseded before it started must not run a second full + // workspace index, which the generation token would only discard. + #expect(operations.requestedFiles == [[freshURL]]) + #expect(feature.beans.map(\.id) == ["Fresh.java"]) } } -/// Polls the MainActor state instead of sleeping for a fixed interval so the -/// test does not depend on scheduler timing. +private func componentIndex(at url: URL) -> SpringIndexResult { + SpringIndexResult( + properties: [], + values: [], + propertyReferences: [], + diagnostics: [], + beans: [SpringBean( + id: url.lastPathComponent, + name: "service", + typeName: "Service", + url: url, + line: 3, + column: 7, + kind: "component" + )], + injections: [], + endpoints: [] + ) +} + +/// Awaits an observable publication with a local deadline. The feature publishes +/// from a task it owns, so the test cannot observe completion synchronously, and +/// a poll loop would depend on machine speed. @MainActor -private func pollUntil( - attempts: Int = 200, - _ condition: () -> Bool -) async throws { - for _ in 0.. Bool +) async -> Bool { + if isSatisfied() { return true } + return await withCheckedContinuation { continuation in + let resumption = SingleResumption(continuation) + // objectWillChange fires before each assignment, so the predicate runs on + // the following main-actor turn, once the publication has completed. + resumption.observe(feature.objectWillChange.sink { _ in + Task { @MainActor in + guard isSatisfied() else { return } + resumption.finish(with: true) + } + }) + DispatchQueue.main.asyncAfter(deadline: .now() + timeout) { + resumption.finish(with: false) + } } - Issue.record("The awaited condition never became true") } -/// Blocks the detached index call until the test decides the schedule has had a -/// chance to return. -private final class SpringIndexGate: @unchecked Sendable { - private let semaphore = DispatchSemaphore(value: 0) - func open() { semaphore.signal() } - func wait() { semaphore.wait(); semaphore.signal() } +/// The observation and the deadline race for a continuation that may only be +/// resumed once. Both arms run on the main thread. +private final class SingleResumption: @unchecked Sendable { + private var continuation: CheckedContinuation? + private var observation: AnyCancellable? + + init(_ continuation: CheckedContinuation) { + self.continuation = continuation + } + + func observe(_ observation: AnyCancellable) { + guard continuation != nil else { + observation.cancel() + return + } + self.observation = observation + } + + func finish(with value: Bool) { + guard let pending = continuation else { return } + continuation = nil + observation?.cancel() + observation = nil + pending.resume(returning: value) + } } private final class SpringTestOperations: JavaMavenOperations, @unchecked Sendable { - let result: SpringIndexResult - private let gate: SpringIndexGate? + private let makeResult: @Sendable ([URL]) -> SpringIndexResult private let lock = NSLock() - private var indexCalls = 0 + private var requested: [[URL]] = [] - var indexCallCount: Int { + /// Every set of files handed to the index, in call order. An empty value + /// proves the double was never reached. + var requestedFiles: [[URL]] { lock.lock() defer { lock.unlock() } - return indexCalls + return requested + } + + init(result: SpringIndexResult) { + makeResult = { _ in result } } - init(result: SpringIndexResult, gate: SpringIndexGate? = nil) { - self.result = result - self.gate = gate + init(resultForFiles: @escaping @Sendable ([URL]) -> SpringIndexResult) { + makeResult = resultForFiles } func springIndex( @@ -193,10 +254,9 @@ private final class SpringTestOperations: JavaMavenOperations, @unchecked Sendab refreshDependencyMetadata: Bool ) -> SpringIndexResult? { lock.lock() - indexCalls += 1 + requested.append(files) lock.unlock() - gate?.wait() - return result + return makeResult(files) } func scanMavenProject(at rootURL: URL, files: [URL]) -> MavenProject? { nil } func mavenDiagnostics(output: String, projectRoot: URL) -> [MavenBuildIssue] { [] } From e4b49c12d91b7f1f38e5d304549714cd6559f319 Mon Sep 17 00:00:00 2001 From: fenghp Date: Fri, 28 Aug 2026 18:12:16 +0800 Subject: [PATCH 03/66] =?UTF-8?q?test(macos):=20=E8=A6=86=E7=9B=96=20Run?= =?UTF-8?q?=20=E4=B8=8E=20Debug=20=E5=85=A5=E5=8F=A3=E5=9C=A8=E5=BF=AB?= =?UTF-8?q?=E7=85=A7=E7=BB=91=E5=AE=9A=E9=A1=B9=E7=9B=AE=E4=B9=8B=E5=89=8D?= =?UTF-8?q?=E7=9A=84=E5=8A=A0=E8=BD=BD=E8=A1=8C=E4=B8=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 之前只靠编译期类型检查和手动验证,没有测试覆盖这两个入口。 新增的三个测试用一个不可读的工作区让快照返回 unavailable,从而跳过 onSnapshotLoaded,把入口变成唯一能绑定项目的路径。把 AppModel+Development 里 两处 loadProjectServicesIfRunProjectIsUnbound 调用去掉后,Run 与 Debug 两个 测试会失败,而覆盖既有行为的工具窗口测试仍然通过,说明它们确实能抓到回归。 把 objectWillChange 加本地超时的等待器提成 ObservableChangeWaiter,供 SpringFeatureModelTests 与新测试共用,超时收到 5 秒以留出计时预算。 Refs #300 --- .../LitheTests/ObservableChangeWaiter.swift | 60 ++++++++++++++ .../Tests/LitheTests/RunEntryPointTests.swift | 81 +++++++++++++++++++ .../LitheTests/SpringFeatureModelTests.swift | 54 ------------- 3 files changed, 141 insertions(+), 54 deletions(-) create mode 100644 macos/Tests/LitheTests/ObservableChangeWaiter.swift create mode 100644 macos/Tests/LitheTests/RunEntryPointTests.swift diff --git a/macos/Tests/LitheTests/ObservableChangeWaiter.swift b/macos/Tests/LitheTests/ObservableChangeWaiter.swift new file mode 100644 index 00000000..62eda5c9 --- /dev/null +++ b/macos/Tests/LitheTests/ObservableChangeWaiter.swift @@ -0,0 +1,60 @@ +import Combine +import Foundation + +/// Awaits an observable publication with a local deadline. Feature models +/// publish from tasks they own, so a test cannot observe completion +/// synchronously, and a poll loop would depend on machine speed. +/// +/// Returns `false` when the deadline elapses first so the caller can turn the +/// timeout into an assertion instead of hanging until the CI job is killed. The +/// deadline is only paid by a failing test, so it stays well inside the timing +/// harness budget. +@MainActor +func awaitChange( + on model: Model, + timeout: DispatchTimeInterval = .seconds(5), + until isSatisfied: @escaping @MainActor @Sendable () -> Bool +) async -> Bool where Model.ObjectWillChangePublisher == ObservableObjectPublisher { + if isSatisfied() { return true } + return await withCheckedContinuation { continuation in + let resumption = SingleResumption(continuation) + // objectWillChange fires before each assignment, so the predicate runs + // on the following main-actor turn, once the publication has completed. + resumption.observe(model.objectWillChange.sink { _ in + Task { @MainActor in + guard isSatisfied() else { return } + resumption.finish(with: true) + } + }) + DispatchQueue.main.asyncAfter(deadline: .now() + timeout) { + resumption.finish(with: false) + } + } +} + +/// The observation and the deadline race for a continuation that may only be +/// resumed once. Both arms run on the main thread. +private final class SingleResumption: @unchecked Sendable { + private var continuation: CheckedContinuation? + private var observation: AnyCancellable? + + init(_ continuation: CheckedContinuation) { + self.continuation = continuation + } + + func observe(_ observation: AnyCancellable) { + guard continuation != nil else { + observation.cancel() + return + } + self.observation = observation + } + + func finish(with value: Bool) { + guard let pending = continuation else { return } + continuation = nil + observation?.cancel() + observation = nil + pending.resume(returning: value) + } +} diff --git a/macos/Tests/LitheTests/RunEntryPointTests.swift b/macos/Tests/LitheTests/RunEntryPointTests.swift new file mode 100644 index 00000000..e6d654d1 --- /dev/null +++ b/macos/Tests/LitheTests/RunEntryPointTests.swift @@ -0,0 +1,81 @@ +import Foundation +import Testing +@testable import Lithe + +@Suite("Run entry points") +@MainActor +struct RunEntryPointTests { + /// Run and Debug activate the execution module on demand, so they can reach + /// a run feature that no workspace snapshot has bound to a project yet. The + /// entry point has to load the project itself; otherwise identification hits + /// an unbound service and the confirmed dialog appears to do nothing. + /// + /// An unreadable workspace makes the snapshot unavailable, which keeps + /// `onSnapshotLoaded` from running and leaves the entry point as the only + /// path that can bind the project. + @Test + func runningBeforeTheSnapshotLoadsBindsTheProject() async { + let model = makeAppModel() + model.openProjectDirectly(unreadableWorkspaceURL()) + #expect(model.runFeatureIfActive == nil) + + model.runSelectedConfiguration() + + let bound = await awaitChange(on: model) { + model.runFeatureIfActive?.isProjectLoaded == true + } + #expect(bound, "the Run entry point did not bind the workspace to the run feature") + } + + /// Debugging reaches the same run feature through its own entry point. + @Test + func debuggingBeforeTheSnapshotLoadsBindsTheProject() async { + let model = makeAppModel() + model.openProjectDirectly(unreadableWorkspaceURL()) + #expect(model.runFeatureIfActive == nil) + + model.startDebugging() + + let bound = await awaitChange(on: model) { + model.runFeatureIfActive?.isProjectLoaded == true + } + #expect(bound, "the Debug entry point did not bind the workspace to the run feature") + } + + /// The tool-window entry point already loaded the project on demand. It must + /// keep doing so, because the other two now rely on the same contract. + @Test + func openingTheRunToolWindowBindsTheProject() async { + let model = makeAppModel() + model.openProjectDirectly(unreadableWorkspaceURL()) + + model.toggleRun() + + let bound = await awaitChange(on: model) { + model.runFeatureIfActive?.isProjectLoaded == true + } + #expect(bound, "opening the Run tool window did not bind the workspace") + } + + private func makeAppModel() -> AppModel { + let store = RunEntryPointTestStore() + let settings = AppSettings(store: store) + let services = MacServiceContainer(store: store, settings: settings).services + return AppModel(settings: settings, services: services) + } + + private func unreadableWorkspaceURL() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-unreadable-workspace-\(UUID().uuidString)") + } +} + +private final class RunEntryPointTestStore: KeyValueStore, @unchecked Sendable { + private var values: [String: Any] = [:] + + func data(forKey key: String) -> Data? { values[key] as? Data } + func object(forKey key: String) -> Any? { values[key] } + func string(forKey key: String) -> String? { values[key] as? String } + func stringArray(forKey key: String) -> [String]? { values[key] as? [String] } + func set(_ value: Any?, forKey key: String) { values[key] = value } +} diff --git a/macos/Tests/LitheTests/SpringFeatureModelTests.swift b/macos/Tests/LitheTests/SpringFeatureModelTests.swift index bdb664c8..ca8f5a82 100644 --- a/macos/Tests/LitheTests/SpringFeatureModelTests.swift +++ b/macos/Tests/LitheTests/SpringFeatureModelTests.swift @@ -1,4 +1,3 @@ -import Combine import Foundation import Testing @testable import Lithe @@ -173,59 +172,6 @@ private func componentIndex(at url: URL) -> SpringIndexResult { ) } -/// Awaits an observable publication with a local deadline. The feature publishes -/// from a task it owns, so the test cannot observe completion synchronously, and -/// a poll loop would depend on machine speed. -@MainActor -private func awaitChange( - on feature: SpringFeatureModel, - timeout: DispatchTimeInterval = .seconds(10), - until isSatisfied: @escaping @MainActor @Sendable () -> Bool -) async -> Bool { - if isSatisfied() { return true } - return await withCheckedContinuation { continuation in - let resumption = SingleResumption(continuation) - // objectWillChange fires before each assignment, so the predicate runs on - // the following main-actor turn, once the publication has completed. - resumption.observe(feature.objectWillChange.sink { _ in - Task { @MainActor in - guard isSatisfied() else { return } - resumption.finish(with: true) - } - }) - DispatchQueue.main.asyncAfter(deadline: .now() + timeout) { - resumption.finish(with: false) - } - } -} - -/// The observation and the deadline race for a continuation that may only be -/// resumed once. Both arms run on the main thread. -private final class SingleResumption: @unchecked Sendable { - private var continuation: CheckedContinuation? - private var observation: AnyCancellable? - - init(_ continuation: CheckedContinuation) { - self.continuation = continuation - } - - func observe(_ observation: AnyCancellable) { - guard continuation != nil else { - observation.cancel() - return - } - self.observation = observation - } - - func finish(with value: Bool) { - guard let pending = continuation else { return } - continuation = nil - observation?.cancel() - observation = nil - pending.resume(returning: value) - } -} - private final class SpringTestOperations: JavaMavenOperations, @unchecked Sendable { private let makeResult: @Sendable ([URL]) -> SpringIndexResult private let lock = NSLock() From a53ee9412615245f3528ee891813e1a86bcc25b5 Mon Sep 17 00:00:00 2001 From: fenghp Date: Sat, 29 Aug 2026 09:54:59 +0800 Subject: [PATCH 04/66] =?UTF-8?q?fix(macos):=20=E7=94=9F=E6=88=90=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E9=85=8D=E7=BD=AE=E5=89=8D=E8=A6=81=E6=B1=82=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8C=BA=E5=BF=AB=E7=85=A7=E5=B7=B2=E5=B0=B1=E7=BB=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 评审指出冷启动入口把"URL 已绑定"当成了"工作区快照已就绪"。快照未完成时 projectFiles 为空,入口仍会绑定 projectURL 并允许确认"识别并生成",而 runConfig.generate 只扫描传入的 paths,于是可能写出缺少 Java 入口的 .lithe/run/generated.json。 把两件事显式分开。新增 ProjectLoadState(idle / loading / bound / ready / failed):bound 表示已绑定工作区但清单是临时的,只允许读取既有配置;ready 携带 工作区与快照标识,才允许生成。快照标识由 WorkspaceFeatureModel 拥有——它是唯一 应用快照的地方——经 AppModel 与 ProjectDevelopmentFeatureModel 传到 RunService。 snapshotID 缺省为 nil,因此忘记传递的调用方会退到 bound 而不是误判为就绪。 generateRunConfigurations 现在要求当前工作区处于 ready,否则报 projectNotReady(原 projectNotLoaded,改名以覆盖"已绑定但清单不完整")。 MacServiceContainer 增加 workspaceOperations 注入缝,与既有的 moduleStore 等 可选覆盖一致,使 AppModel 层能用可控快照做测试。Search 与本地历史仍使用具体的 RustWorkspaceOperations,它们依赖 WorkspaceOperations 之外的能力。 测试改为断言数据完整性而非仅仅"已绑定":入口测试用受控快照,先阻塞、按下 Run、 断言未就绪且生成被拒且未写出配置,再放行含真实 Java 文件的快照并断言就绪; ExecutionModuleTests 断言生成实际扫描的清单恰好是快照上报的那份。去掉 RunService 的就绪守卫后,后者会直接暴露 generatedInventories == [[]]。 Refs #300 --- .../AppModel/AppModel+Development.swift | 16 +- .../Lithe/Models/AppModel/AppModel.swift | 9 +- .../Platform/MacOS/MacServiceContainer.swift | 12 +- macos/Sources/Lithe/Views/Run/RunView.swift | 2 +- .../Execution/RunConfigurationContracts.swift | 43 ++++- .../Application/ExecutionFeatureModels.swift | 25 ++- .../Services/RunService.swift | 38 +++- .../Application/WorkspaceFeatureModel.swift | 6 + .../ExecutionModuleTests.swift | 72 ++++++- .../RunConfigurationIntegrationTests.swift | 9 +- .../Tests/LitheTests/RunEntryPointTests.swift | 176 ++++++++++++++---- 11 files changed, 334 insertions(+), 74 deletions(-) diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift index 0d8b32c9..a3ef15da 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift @@ -154,17 +154,21 @@ extension AppModel { } /// Run and Debug can activate the execution module before the workspace - /// snapshot has loaded it, which leaves the run feature without a project. - /// The tool-window entry points already load on demand; these do the same so + /// snapshot reaches the run feature, which leaves it without a project. The + /// tool-window entry points already load on demand; these do the same so /// every entry point observes the same state. - private func loadProjectServicesIfRunProjectIsUnbound(_ runFeature: RunFeatureModel) async { - guard !runFeature.isProjectLoaded, let workspaceURL else { return } + /// + /// This does not make an unfinished snapshot ready. When the snapshot is + /// still pending, the load binds the workspace so existing configuration is + /// readable and generation keeps reporting `projectNotReady`. + private func loadProjectServicesIfRunProjectIsNotReady(_ runFeature: RunFeatureModel) async { + guard let workspaceURL, !runFeature.isProjectReady(for: workspaceURL) else { return } await loadProjectServices(at: workspaceURL, files: projectFiles) } private func runSelectedConfigurationAfterActivation() async { guard let runFeature = await activateExecutionModule()?.runFeature else { return } - await loadProjectServicesIfRunProjectIsUnbound(runFeature) + await loadProjectServicesIfRunProjectIsNotReady(runFeature) guard runFeature.configurationStatus == .ready else { runFeature.requestRunConfigurationGeneration(intent: .run) return @@ -335,7 +339,7 @@ extension AppModel { guard let execution = await activateExecutionModule(), let debug = await activateDebugModule() else { return } let runFeature = execution.runFeature - await loadProjectServicesIfRunProjectIsUnbound(runFeature) + await loadProjectServicesIfRunProjectIsNotReady(runFeature) let debugFeature = debug.javaFeature javaFeature.configureRuntime( mavenFeature: execution.mavenFeature, diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 0d876604..0126e247 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -765,7 +765,14 @@ final class AppModel: ObservableObject, Identifiable { ) guard let execution = await activateExecutionModule() else { return } execution.tests.discover(workspaceURL: workspaceURL, files: files) - await execution.projectDevelopment.loadProject(at: workspaceURL, files: files) + // The workspace feature owns snapshot identity. Passing it through lets + // the run service tell a complete file inventory from a provisional one, + // so generation never scans a partial workspace. + await execution.projectDevelopment.loadProject( + at: workspaceURL, + files: files, + snapshotID: workspaceFeature.snapshotID + ) } var projectName: String { diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 90c6ba81..6c4aaf53 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -52,6 +52,7 @@ final class MacServiceContainer { processRegistry: ManagedProcessRegistry = ManagedProcessRegistry(), moduleLaunchMode: ModuleLaunchMode = .normal, moduleStore providedModuleStore: MacModuleConfigurationStore? = nil, + workspaceOperations providedWorkspaceOperations: (any WorkspaceOperations)? = nil, pluginRuntimeRecovery: MacPluginRuntimeRecoveryCoordinator? = nil, authorizationCallbackRouter providedAuthorizationCallbackRouter: MacExternalAuthorizationCallbackRouter? = nil ) { @@ -405,7 +406,12 @@ final class MacServiceContainer { preconditionFailure("Invalid execution/debug module graph: \(error.localizedDescription)") } let gitOperations = RustGitOperations(core: rustCore) - let workspaceOperations = RustWorkspaceOperations(core: rustCore) + let rustWorkspaceOperations = RustWorkspaceOperations(core: rustCore) + // Only the workspace snapshot boundary is overridable. Search and local + // history need the concrete Rust operations, which carry their own + // capabilities beyond WorkspaceOperations. + let workspaceOperations: any WorkspaceOperations = + providedWorkspaceOperations ?? rustWorkspaceOperations let localHistoryOperations = RustLocalHistoryOperations(core: rustCore) let markdownRenderer = RustMarkdownRendering(core: rustCore) let markdownImageImporter = MarkdownImageImportService(storage: fileStorage) @@ -417,11 +423,11 @@ final class MacServiceContainer { ) }) try moduleRegistry.register(ModuleFactory(manifest: SearchModule.moduleManifest, contributions: SearchModule.moduleContributions) { - SearchModule(operations: workspaceOperations) + SearchModule(operations: rustWorkspaceOperations) }) try moduleRegistry.register(ModuleFactory(manifest: HistoryModule.moduleManifest, contributions: HistoryModule.moduleContributions) { HistoryModule( - workspaceAccess: MacLocalHistoryWorkspaceAccess(workspaceOperations: workspaceOperations, fileOperations: fileOperations), + workspaceAccess: MacLocalHistoryWorkspaceAccess(workspaceOperations: rustWorkspaceOperations, fileOperations: fileOperations), storage: MacLocalHistoryStorage(storage: fileStorage), operations: localHistoryOperations ) diff --git a/macos/Sources/Lithe/Views/Run/RunView.swift b/macos/Sources/Lithe/Views/Run/RunView.swift index 393b12b5..ec307fe2 100644 --- a/macos/Sources/Lithe/Views/Run/RunView.swift +++ b/macos/Sources/Lithe/Views/Run/RunView.swift @@ -180,7 +180,7 @@ struct RunView: View { ) } switch feature.generationState { - case .projectNotLoaded: + case .projectNotReady: return ( String(localized: "Project is still loading"), String(localized: "Wait for the project to finish loading, then identify it again."), diff --git a/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift b/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift index 11c6cb6a..1c2bc233 100644 --- a/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift +++ b/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift @@ -47,11 +47,48 @@ package struct ProjectRunConfigurationInspection: Equatable, Sendable { } } +/// How a run service is bound to a workspace. +/// +/// Being bound to a workspace URL and holding a complete file inventory are +/// different things. Reading an existing configuration only needs the URL, while +/// generating one scans the inventory, so a provisional inventory would write a +/// configuration that omits entry points the workspace actually contains. +package enum ProjectLoadState: Equatable, Sendable { + case idle + case loading(workspace: URL) + /// Bound to the workspace, but the file inventory is provisional because the + /// workspace snapshot has not been applied yet. Existing configuration can be + /// read; generation must wait. + case bound(workspace: URL) + /// The inventory came from the identified workspace snapshot, so generation + /// can scan it safely. + case ready(workspace: URL, snapshotID: UUID) + case failed(workspace: URL, message: String) + + /// The workspace this state describes, when it describes one. + package var workspace: URL? { + switch self { + case .idle: nil + case .loading(let workspace): workspace + case .bound(let workspace): workspace + case .ready(let workspace, _): workspace + case .failed(let workspace, _): workspace + } + } + + /// Whether the inventory for `workspace` is complete enough to generate from. + package func isReady(for workspace: URL) -> Bool { + guard case .ready(let boundWorkspace, _) = self else { return false } + return boundWorkspace == workspace.standardizedFileURL + } +} + package enum RunConfigurationGenerationState: Equatable, Sendable { case idle - /// The request arrived before the workspace finished loading, so there was - /// no project to identify. Nothing failed and nothing was written. - case projectNotLoaded + /// The request arrived before the workspace snapshot was applied, so there + /// was no complete file inventory to identify. Nothing failed, and nothing + /// was written. + case projectNotReady case succeeded(entryCount: Int) case noEntries case failed(String) diff --git a/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift b/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift index 62131d72..68ec554c 100644 --- a/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift +++ b/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift @@ -159,14 +159,24 @@ package final class RunFeatureModel: ObservableObject { service.clearOutput() } - package var isProjectLoaded: Bool { service.isProjectLoaded } + package var projectLoadState: ProjectLoadState { service.projectLoadState } + + package func isProjectReady(for workspace: URL) -> Bool { + service.isProjectReady(for: workspace) + } package func loadProject( at workspaceURL: URL, files: [URL], - mavenProject: MavenProject? + mavenProject: MavenProject?, + snapshotID: UUID? = nil ) async { - await service.loadProject(at: workspaceURL, files: files, mavenProject: mavenProject) + await service.loadProject( + at: workspaceURL, + files: files, + mavenProject: mavenProject, + snapshotID: snapshotID + ) } package func generateRunConfigurations() async { @@ -211,7 +221,11 @@ package final class ProjectDevelopmentFeatureModel { self.runFeature = runFeature } - package func loadProject(at workspaceURL: URL, files: [URL]) async { + package func isRunProjectReady(for workspace: URL) -> Bool { + runFeature.isProjectReady(for: workspace) + } + + package func loadProject(at workspaceURL: URL, files: [URL], snapshotID: UUID? = nil) async { // Maven is one build-system Provider, not a workspace prerequisite. // Avoid scanning every project as Maven; non-Maven ecosystems should // reach the generic run pipeline without paying for Java discovery. @@ -226,7 +240,8 @@ package final class ProjectDevelopmentFeatureModel { await runFeature.loadProject( at: workspaceURL, files: files, - mavenProject: mavenFeature.project + mavenProject: mavenFeature.project, + snapshotID: snapshotID ) } } diff --git a/macos/Sources/LitheExecutionModule/Services/RunService.swift b/macos/Sources/LitheExecutionModule/Services/RunService.swift index d41cb424..7f18c4c6 100644 --- a/macos/Sources/LitheExecutionModule/Services/RunService.swift +++ b/macos/Sources/LitheExecutionModule/Services/RunService.swift @@ -14,6 +14,7 @@ package final class RunService: ObservableObject { } } @Published package private(set) var isLoadingProject = false + @Published package private(set) var projectLoadState: ProjectLoadState = .idle @Published package private(set) var isRunning = false @Published package private(set) var runningTitle: String? @Published package private(set) var output = "" @@ -105,10 +106,13 @@ package final class RunService: ObservableObject { package var lastRunFileURL: URL? { lastCurrentFileURL } package var lastConfiguration: RunConfiguration? { lastRunConfiguration } - /// Reports whether `loadProject` has bound this service to a workspace. - /// Entry points that activate the execution module on demand use this to - /// avoid acting on a service that has no project yet. - package var isProjectLoaded: Bool { projectURL != nil } + /// Whether the file inventory for `workspace` came from its snapshot, and is + /// therefore complete enough to generate a configuration from. Entry points + /// that activate the execution module on demand use this to decide whether + /// the project still has to be loaded. + package func isProjectReady(for workspace: URL) -> Bool { + projectLoadState.isReady(for: workspace) + } @discardableResult package func registerLanguageRunExtension( @@ -137,14 +141,22 @@ package final class RunService: ObservableObject { return roots } + /// Loads run state for a workspace. + /// + /// `snapshotID` identifies the workspace snapshot `files` came from. Passing + /// `nil` means no snapshot has been applied yet, which binds the service so + /// existing configuration can be read while generation stays blocked. package func loadProject( at projectURL: URL, files: [URL], - mavenProject: MavenProject? + mavenProject: MavenProject?, + snapshotID: UUID? = nil ) async { let loadID = UUID() projectLoadID = loadID + let workspace = projectURL.standardizedFileURL isLoadingProject = true + projectLoadState = .loading(workspace: workspace) defer { if projectLoadID == loadID { isLoadingProject = false @@ -160,7 +172,10 @@ package final class RunService: ObservableObject { if let currentProject = self.projectURL { selectedConfigurationIDsByProject[currentProject.path] = selectedConfigurationID } - self.projectURL = projectURL.standardizedFileURL + self.projectURL = workspace + projectLoadState = snapshotID + .map { .ready(workspace: workspace, snapshotID: $0) } + ?? .bound(workspace: workspace) self.mavenProject = mavenProject mavenProfiles = mavenProject?.profiles ?? [] self.projectFiles = files @@ -204,10 +219,12 @@ package final class RunService: ObservableObject { } package func generateRunConfigurations() async { - // Dropping the request silently is indistinguishable from a broken - // button, so report that the workspace is not loaded yet instead. - guard let projectURL else { - generationState = .projectNotLoaded + // Generation scans the file inventory this service holds, so a + // provisional inventory would write a configuration that omits entry + // points the workspace contains. Dropping the request silently is also + // indistinguishable from a broken button, so report the pending state. + guard let projectURL, projectLoadState.isReady(for: projectURL) else { + generationState = .projectNotReady return } let loadID = projectLoadID @@ -593,6 +610,7 @@ package final class RunService: ObservableObject { stopAllServices() projectLoadID = UUID() projectURL = nil + projectLoadState = .idle selectedConfigurationIDsByProject = [:] projectFiles = [] mavenProject = nil diff --git a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift index 95783ec0..09262903 100644 --- a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift +++ b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift @@ -13,6 +13,10 @@ package enum WorkspaceRebuildResult: Sendable { package final class WorkspaceFeatureModel: ObservableObject { @Published package private(set) var rootNode: FileNode? @Published package private(set) var projectFiles: [URL] = [] + /// Identifies the snapshot `projectFiles` came from, and is `nil` whenever no + /// snapshot has been applied. Consumers that scan the file inventory use this + /// to tell a complete inventory from a provisional one. + @Published package private(set) var snapshotID: UUID? @Published package private(set) var isLoadingWorkspace = false @Published package private(set) var isRefreshingWorkspace = false @Published package private(set) var loadErrorMessage: String? @@ -184,6 +188,7 @@ package final class WorkspaceFeatureModel: ObservableObject { hasRestoredWorkspaceSession = false rootNode = nil projectFiles = [] + snapshotID = nil isLoadingWorkspace = false isRefreshingWorkspace = false loadErrorMessage = nil @@ -294,6 +299,7 @@ package final class WorkspaceFeatureModel: ObservableObject { loadErrorMessage = nil rootNode = snapshot.root projectFiles = snapshot.files + snapshotID = UUID() scheduleSearchIndexWarm(at: workspaceURL, rules: rules) // The tree is usable as soon as the shared snapshot is ready. Service diff --git a/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift b/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift index 062b3519..d8284010 100644 --- a/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift +++ b/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift @@ -66,10 +66,10 @@ struct ExecutionModuleTests { languageRunProviders: .standard(catalog: .compatibilityFallback) ) - #expect(!service.isProjectLoaded) + #expect(service.projectLoadState == .idle) await service.generateRunConfigurations() - #expect(service.generationState == .projectNotLoaded) + #expect(service.generationState == .projectNotReady) #expect(operations.generateCallCount == 0) #expect(service.configurationStatus == .missing) } @@ -93,10 +93,19 @@ struct ExecutionModuleTests { let root = URL(fileURLWithPath: "/workspace", isDirectory: true) await service.generateRunConfigurations() - #expect(service.generationState == .projectNotLoaded) + #expect(service.generationState == .projectNotReady) + // Binding without a snapshot only unlocks reading existing configuration. await service.loadProject(at: root, files: [], mavenProject: nil) - #expect(service.isProjectLoaded) + #expect(service.projectLoadState == .bound(workspace: root)) + #expect(!service.isProjectReady(for: root)) + await service.generateRunConfigurations() + #expect(service.generationState == .projectNotReady) + #expect(operations.generateCallCount == 0) + + let snapshotID = UUID() + await service.loadProject(at: root, files: [], mavenProject: nil, snapshotID: snapshotID) + #expect(service.projectLoadState == .ready(workspace: root, snapshotID: snapshotID)) await service.generateRunConfigurations() #expect(operations.generateCallCount == 1) @@ -104,6 +113,48 @@ struct ExecutionModuleTests { #expect(service.configurationStatus == .ready) } + /// Generation scans the inventory the service holds, so a workspace that was + /// bound before its snapshot arrived must not be scanned with the provisional + /// list. Doing so writes a configuration that omits real entry points. + @Test + func generationScansTheSnapshotInventoryAndNeverAProvisionalOne() async throws { + let operations = RecordingRunConfigurationOperations() + let service = RunService( + runtime: TestRuntime(), + process: TestStreamingProcess(), + processFactory: { TestStreamingProcess() }, + fileAccess: TestRunFileAccess(), + preferences: TestRunPreferences(), + serverPortParser: TestServerPortParser(), + runConfigurationOperations: operations, + executableResolver: TestExecutableResolver(), + languageProviderCatalog: .compatibilityFallback, + languageRunProviders: .standard(catalog: .compatibilityFallback) + ) + let root = URL(fileURLWithPath: "/workspace", isDirectory: true) + let source = root.appendingPathComponent("src/main/java/demo/App.java") + + // The workspace snapshot has not arrived, so the inventory is empty. + await service.loadProject(at: root, files: [], mavenProject: nil) + await service.generateRunConfigurations() + #expect(service.generationState == .projectNotReady) + #expect(operations.generatedInventories.isEmpty, "a provisional inventory must not be scanned") + + await service.loadProject( + at: root, + files: [source], + mavenProject: nil, + snapshotID: UUID() + ) + await service.generateRunConfigurations() + + #expect(service.generationState == .succeeded(entryCount: 1)) + #expect( + operations.generatedInventories == [[source]], + "generation must scan exactly the inventory the snapshot reported" + ) + } + @Test func currentGoFileRunsThroughExtensionOwnedSession() async throws { let builtInProcess = TestStreamingProcess() @@ -383,19 +434,22 @@ private struct TestRunConfigurationOperations: RunConfigurationOperations { func migrateLegacySettings(at projectURL: URL, configurationIDs: [String]) throws {} } -/// Counts generation attempts so a test can prove that an unloaded project -/// never reaches the store. +/// Records the file inventory each generation attempt was given, so a test can +/// prove both that a pending workspace never reaches the store and that a ready +/// one is scanned with the complete inventory. private final class RecordingRunConfigurationOperations: RunConfigurationOperations, @unchecked Sendable { - private(set) var generateCallCount = 0 + private(set) var generatedInventories: [[URL]] = [] + + var generateCallCount: Int { generatedInventories.count } func inspect(at projectURL: URL) -> ProjectRunConfigurationInspection { ProjectRunConfigurationInspection( - status: generateCallCount == 0 ? .missing : .ready, + status: generatedInventories.isEmpty ? .missing : .ready, diagnostics: [] ) } func generate(at projectURL: URL, files: [URL], modulePaths: [String]) throws -> RunConfigurationGenerationResult { - generateCallCount += 1 + generatedInventories.append(files) return RunConfigurationGenerationResult(entryCount: 1) } func resolve(at projectURL: URL, toolchainCandidates: [ProjectToolchainCandidate]) throws -> RunConfigurationResolution { diff --git a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift index 0e5b4446..4ee1fc1b 100644 --- a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -3078,10 +3078,13 @@ struct RunConfigurationIntegrationTests { generationEntryCount: 0 ) + // A snapshot identity marks the inventory complete, which is what lets + // generation run; an empty workspace legitimately has no entry point. await fixture.service.loadProject( at: fixture.root, files: [], - mavenProject: fixture.mavenProject + mavenProject: fixture.mavenProject, + snapshotID: UUID() ) await fixture.service.generateRunConfigurations() @@ -3667,10 +3670,10 @@ struct RunConfigurationIntegrationTests { runConfigurationOperations: operations ) - await service.loadProject(at: root, files: [], mavenProject: nil) + await service.loadProject(at: root, files: [], mavenProject: nil, snapshotID: UUID()) let generation = Task { await service.generateRunConfigurations() } #expect(await operations.waitUntilBlocked()) - await service.loadProject(at: root, files: [], mavenProject: nil) + await service.loadProject(at: root, files: [], mavenProject: nil, snapshotID: UUID()) operations.releaseGeneration() await generation.value diff --git a/macos/Tests/LitheTests/RunEntryPointTests.swift b/macos/Tests/LitheTests/RunEntryPointTests.swift index e6d654d1..dc92a3a6 100644 --- a/macos/Tests/LitheTests/RunEntryPointTests.swift +++ b/macos/Tests/LitheTests/RunEntryPointTests.swift @@ -5,68 +5,178 @@ import Testing @Suite("Run entry points") @MainActor struct RunEntryPointTests { - /// Run and Debug activate the execution module on demand, so they can reach - /// a run feature that no workspace snapshot has bound to a project yet. The - /// entry point has to load the project itself; otherwise identification hits - /// an unbound service and the confirmed dialog appears to do nothing. + /// Run activates the execution module on demand, so it can reach a run + /// feature before the workspace snapshot has been applied. Binding the + /// workspace there is not enough: generation scans the file inventory the + /// run service holds, so identifying a provisional inventory would write a + /// `generated.json` that omits entry points the workspace contains. /// - /// An unreadable workspace makes the snapshot unavailable, which keeps - /// `onSnapshotLoaded` from running and leaves the entry point as the only - /// path that can bind the project. + /// The snapshot is held until after Run is pressed, then released with a + /// real Java entry point, so the test observes both halves of the contract. @Test - func runningBeforeTheSnapshotLoadsBindsTheProject() async { - let model = makeAppModel() - model.openProjectDirectly(unreadableWorkspaceURL()) - #expect(model.runFeatureIfActive == nil) + func runBeforeTheSnapshotDefersGenerationUntilTheInventoryIsComplete() async throws { + let workspace = try JavaWorkspaceFixture() + defer { workspace.remove() } + let operations = GatedWorkspaceOperations(snapshot: workspace.snapshot) + let model = makeAppModel(workspaceOperations: operations) + model.openProjectDirectly(workspace.root) model.runSelectedConfiguration() + // The entry point binds the workspace so existing configuration is + // readable, but the pending snapshot must keep generation out. let bound = await awaitChange(on: model) { - model.runFeatureIfActive?.isProjectLoaded == true + model.runFeatureIfActive?.projectLoadState.workspace != nil } - #expect(bound, "the Run entry point did not bind the workspace to the run feature") + #expect(bound, "the Run entry point never bound the workspace") + #expect(model.runFeatureIfActive?.isProjectReady(for: workspace.root) == false) + + let runFeature = try #require(model.runFeatureIfActive) + await runFeature.generateRunConfigurations() + #expect(runFeature.generationState == .projectNotReady) + #expect(!workspace.hasGeneratedConfiguration, "a partial inventory must not be written") + + operations.releaseSnapshot() + + let ready = await awaitChange(on: model) { + model.runFeatureIfActive?.isProjectReady(for: workspace.root) == true + } + #expect(ready, "the applied snapshot never made the run project ready") + // Which paths generation then scans is asserted against the run + // configuration store in ExecutionModuleTests, because the Swift test + // binary does not link the Rust Core that performs the scan. } /// Debugging reaches the same run feature through its own entry point. @Test - func debuggingBeforeTheSnapshotLoadsBindsTheProject() async { - let model = makeAppModel() - model.openProjectDirectly(unreadableWorkspaceURL()) - #expect(model.runFeatureIfActive == nil) + func debugBeforeTheSnapshotDefersGenerationUntilTheInventoryIsComplete() async throws { + let workspace = try JavaWorkspaceFixture() + defer { workspace.remove() } + let operations = GatedWorkspaceOperations(snapshot: workspace.snapshot) + let model = makeAppModel(workspaceOperations: operations) + model.openProjectDirectly(workspace.root) model.startDebugging() let bound = await awaitChange(on: model) { - model.runFeatureIfActive?.isProjectLoaded == true + model.runFeatureIfActive?.projectLoadState.workspace != nil } - #expect(bound, "the Debug entry point did not bind the workspace to the run feature") + #expect(bound, "the Debug entry point never bound the workspace") + #expect(model.runFeatureIfActive?.isProjectReady(for: workspace.root) == false) + + operations.releaseSnapshot() + + let ready = await awaitChange(on: model) { + model.runFeatureIfActive?.isProjectReady(for: workspace.root) == true + } + #expect(ready, "the applied snapshot never made the run project ready") } - /// The tool-window entry point already loaded the project on demand. It must - /// keep doing so, because the other two now rely on the same contract. + /// Opening a project normally must reach the same ready state without any + /// entry point, so the tool-window path keeps working. @Test - func openingTheRunToolWindowBindsTheProject() async { - let model = makeAppModel() - model.openProjectDirectly(unreadableWorkspaceURL()) + func openingAProjectMakesTheRunProjectReadyOnItsOwn() async throws { + let workspace = try JavaWorkspaceFixture() + defer { workspace.remove() } + let operations = GatedWorkspaceOperations(snapshot: workspace.snapshot) + operations.releaseSnapshot() + let model = makeAppModel(workspaceOperations: operations) - model.toggleRun() + model.openProjectDirectly(workspace.root) - let bound = await awaitChange(on: model) { - model.runFeatureIfActive?.isProjectLoaded == true + let ready = await awaitChange(on: model) { + model.runFeatureIfActive?.isProjectReady(for: workspace.root) == true } - #expect(bound, "opening the Run tool window did not bind the workspace") + #expect(ready, "opening a project should make the run project ready") } - private func makeAppModel() -> AppModel { + private func makeAppModel(workspaceOperations: any WorkspaceOperations) -> AppModel { let store = RunEntryPointTestStore() let settings = AppSettings(store: store) - let services = MacServiceContainer(store: store, settings: settings).services + let services = MacServiceContainer( + store: store, + settings: settings, + workspaceOperations: workspaceOperations + ).services return AppModel(settings: settings, services: services) } +} + +/// A real workspace on disk holding one Java entry point, so generation runs +/// through the shared Core instead of a stubbed result. +@MainActor +private struct JavaWorkspaceFixture { + let root: URL + let sourceURL: URL + + init() throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-run-entry-\(UUID().uuidString)") + sourceURL = root.appendingPathComponent("src/main/java/demo/App.java") + try FileManager.default.createDirectory( + at: sourceURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try """ + package demo; + public class App { + public static void main(String[] args) {} + } + """.write(to: sourceURL, atomically: true, encoding: .utf8) + } + + var snapshot: WorkspaceSnapshot { + WorkspaceSnapshot( + root: FileNode(url: root, isDirectory: true, children: []), + files: [sourceURL] + ) + } + + var hasGeneratedConfiguration: Bool { + FileManager.default.fileExists( + atPath: root.appendingPathComponent(".lithe/run/generated.json").path + ) + } + + func remove() { + try? FileManager.default.removeItem(at: root) + } +} + +/// Holds the workspace snapshot until the test releases it, so a test can decide +/// exactly when the file inventory becomes complete. +private final class GatedWorkspaceOperations: WorkspaceOperations, @unchecked Sendable { + private let gate = TestGate() + private let lock = NSLock() + private let preparedSnapshot: WorkspaceSnapshot + + init(snapshot: WorkspaceSnapshot) { + preparedSnapshot = snapshot + } + + func releaseSnapshot() { + gate.open() + } + + func snapshot(at rootURL: URL, visibilityRules: FileVisibilityRules) -> WorkspaceSnapshot? { + // Runs on the workspace feature's detached scan task, never the main + // actor, and the bounded wait keeps a failing test from pinning it. + guard gate.waitSynchronously() else { return nil } + lock.lock() + defer { lock.unlock() } + return preparedSnapshot + } + + func readFile(at rootURL: URL, relativePath: String) -> String? { + try? String(contentsOf: rootURL.appendingPathComponent(relativePath), encoding: .utf8) + } - private func unreadableWorkspaceURL() -> URL { - FileManager.default.temporaryDirectory - .appendingPathComponent("lithe-unreadable-workspace-\(UUID().uuidString)") + func writeFile(_ text: String, at rootURL: URL, relativePath: String) -> Bool { + (try? text.write( + to: rootURL.appendingPathComponent(relativePath), + atomically: true, + encoding: .utf8 + )) != nil } } From 131e3ae1fa78903a8234b8f5ed900000c98d3faa Mon Sep 17 00:00:00 2001 From: fenghp Date: Sat, 29 Aug 2026 13:20:10 +0800 Subject: [PATCH 05/66] =?UTF-8?q?fix(macos):=20=E5=B0=B1=E7=BB=AA=E5=88=A4?= =?UTF-8?q?=E6=96=AD=E7=BA=B3=E5=85=A5=E5=BF=AB=E7=85=A7=E6=A0=87=E8=AF=86?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E8=AE=A9=E6=9C=AA=E5=B0=B1=E7=BB=AA=E7=9A=84?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=8E=A8=E8=BF=9F=E8=80=8C=E9=9D=9E=E9=99=8D?= =?UTF-8?q?=E7=BA=A7=E6=89=A7=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 处理评审的三点。 一、AppModel.swift 与 preview 合并后达到 1806 行,超过 verify-service-boundaries 的 1800 上限。把加载编排移到 AppModel+Development.swift,现为 1781 行。上一轮 本地验证没有先合并 base,所以漏掉了这个失败。 二、snapshotID 放进了 .ready 却没参与比较。同一工作区刷新时, WorkspaceFeatureModel 在发布新快照与调用 onSnapshotLoaded 之间隔着两个 await, 这段窗口里 RunService 仍持有上一份清单却报就绪,重新识别会漏掉新增的入口。 isReady 现在同时比较工作区与快照标识,snapshotID 为 nil 时一律视为未就绪。 三、加载可能只到 .bound,而运行入口只看 configurationStatus。磁盘上已有配置时 会在完整文件列表与 Maven 信息就绪前直接启动,工具链解析因此缺少 Maven 项目。 运行与调试入口改为要求就绪,未就绪时记下 pendingRunAction 并返回;工作区重建 必然以 loadProjectServices 收尾,由它恢复这次操作,因此不需要轮询或等待。 识别现在统一走 AppModel.generateRunConfigurations,先把服务提升到当前快照再执行。 这是 RunService 内部仍可用自身状态判断的前提。 Refs #300 --- .../AppModel/AppModel+Development.swift | 101 ++++++++++++++++-- .../AppModel/AppModel+FeatureState.swift | 3 + .../Lithe/Models/AppModel/AppModel.swift | 35 +----- .../Lithe/Views/Workbench/WorkbenchView.swift | 4 +- .../Execution/RunConfigurationContracts.swift | 16 ++- .../Application/ExecutionFeatureModels.swift | 8 +- .../Services/RunService.swift | 6 +- .../ExecutionModuleTests.swift | 5 +- .../Tests/LitheTests/RunEntryPointTests.swift | 49 ++++++++- 9 files changed, 168 insertions(+), 59 deletions(-) diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift index a3ef15da..b72dfe81 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift @@ -3,6 +3,12 @@ import LitheCoreContracts import LitheExecutionModule import LitheModuleAPI +/// An action deferred until the run feature holds the current workspace snapshot. +enum PendingRunAction { + case run + case debug +} + @MainActor extension AppModel { func toggleSpringEndpoints() { @@ -142,6 +148,19 @@ extension AppModel { runFeatureIfActive?.select(configuration) } + /// The single entry point for identification. + /// + /// Routing it through here is what keeps the run service from scanning a + /// superseded snapshot: the service can only compare its own state, so the + /// caller has to bring it up to the current snapshot first. + func generateRunConfigurations() async { + guard let runFeature = await activateExecutionModule()?.runFeature else { return } + // Report the pending workspace through the generation state when the + // snapshot has not arrived, which the run panel surfaces as a notice. + _ = await ensureRunProjectReady(runFeature) + await runFeature.generateRunConfigurations() + } + func openRunConfiguration(relativePath: String?) { guard let workspaceURL else { return } let url = workspaceURL.appendingPathComponent(relativePath ?? ".lithe/run/generated.json") @@ -153,22 +172,78 @@ extension AppModel { Task { [weak self] in await self?.runSelectedConfigurationAfterActivation() } } - /// Run and Debug can activate the execution module before the workspace - /// snapshot reaches the run feature, which leaves it without a project. The - /// tool-window entry points already load on demand; these do the same so - /// every entry point observes the same state. + /// Loads build-system and run state at the workspace boundary. The generic + /// run lifecycle is intentionally not owned by JavaFeatureModel. + /// + /// Spring indexing is scheduled rather than awaited. It scales with the + /// number of Java sources, and run configurations, test discovery, and the + /// Git refresh that follows this call must not wait for it. + func loadProjectServices(at workspaceURL: URL, files: [URL]) async { + prepareJavaLanguageServerForWorkspaceIfNeeded( + at: workspaceURL, + files: files + ) + springFeature.scheduleLoad( + workspaceURL: workspaceURL, + files: files, + textOverrides: Dictionary(uniqueKeysWithValues: openDocuments.map { + ($0.url.standardizedFileURL, $0.text) + }) + ) + guard let execution = await activateExecutionModule() else { return } + execution.tests.discover(workspaceURL: workspaceURL, files: files) + // The workspace feature owns snapshot identity. Passing it through lets + // the run service tell a complete file inventory from a provisional one, + // so generation never scans a partial workspace. + await execution.projectDevelopment.loadProject( + at: workspaceURL, + files: files, + snapshotID: workspaceSnapshotID + ) + resumePendingRunActionIfProjectBecameReady(execution.runFeature) + } + + /// Brings the run feature up to the workspace snapshot the workspace feature + /// currently holds, and reports whether it got there. /// - /// This does not make an unfinished snapshot ready. When the snapshot is - /// still pending, the load binds the workspace so existing configuration is - /// readable and generation keeps reporting `projectNotReady`. - private func loadProjectServicesIfRunProjectIsNotReady(_ runFeature: RunFeatureModel) async { - guard let workspaceURL, !runFeature.isProjectReady(for: workspaceURL) else { return } + /// Run, Debug, and identification all scan or launch from the file inventory + /// the run service holds, so each of them needs the inventory to match the + /// current snapshot rather than merely being bound to the workspace. + private func ensureRunProjectReady(_ runFeature: RunFeatureModel) async -> Bool { + guard let workspaceURL else { return false } + let snapshotID = workspaceSnapshotID + if runFeature.isProjectReady(for: workspaceURL, snapshotID: snapshotID) { return true } await loadProjectServices(at: workspaceURL, files: projectFiles) + return runFeature.isProjectReady( + for: workspaceURL, + snapshotID: workspaceSnapshotID + ) + } + + /// Continues an action that arrived before the snapshot did. The workspace + /// rebuild always finishes with `loadProjectServices`, so recording the + /// intent is enough to resume without polling or waiting. + private func resumePendingRunActionIfProjectBecameReady(_ runFeature: RunFeatureModel) { + guard let action = pendingRunAction, + let workspaceURL, + runFeature.isProjectReady(for: workspaceURL, snapshotID: workspaceSnapshotID) + else { return } + pendingRunAction = nil + switch action { + case .run: runSelectedConfiguration() + case .debug: startDebugging() + } } private func runSelectedConfigurationAfterActivation() async { guard let runFeature = await activateExecutionModule()?.runFeature else { return } - await loadProjectServicesIfRunProjectIsNotReady(runFeature) + guard await ensureRunProjectReady(runFeature) else { + // Launching from a provisional inventory resolves toolchains without + // the Maven project, so wait for the snapshot instead of running. + pendingRunAction = .run + return + } + pendingRunAction = nil guard runFeature.configurationStatus == .ready else { runFeature.requestRunConfigurationGeneration(intent: .run) return @@ -339,7 +414,11 @@ extension AppModel { guard let execution = await activateExecutionModule(), let debug = await activateDebugModule() else { return } let runFeature = execution.runFeature - await loadProjectServicesIfRunProjectIsNotReady(runFeature) + guard await ensureRunProjectReady(runFeature) else { + pendingRunAction = .debug + return + } + pendingRunAction = nil let debugFeature = debug.javaFeature javaFeature.configureRuntime( mavenFeature: execution.mavenFeature, diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift index 2b0a9c53..9b997739 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift @@ -9,6 +9,9 @@ extension AppModel { var isIndexingSpring: Bool { springFeature.isIndexing } var rootNode: FileNode? { workspaceFeature.rootNode } var projectFiles: [URL] { workspaceFeature.projectFiles } + /// Identifies the snapshot `projectFiles` came from, and is `nil` until one + /// has been applied. + var workspaceSnapshotID: UUID? { workspaceFeature.snapshotID } var javaEnvironmentReport: JavaEnvironmentReport? { runtimeFeature.javaEnvironmentReport } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index dc7dfc3e..6f70d1e3 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -146,6 +146,9 @@ final class AppModel: ObservableObject, Identifiable { private var workbenchBackgroundFeatureObservation: AnyCancellable? private var isProjectSessionActive = true private var fileVisibilityRulesObserverID: UUID? + /// Run or Debug that arrived before the workspace snapshot reached the run + /// feature. `loadProjectServices` resumes it once the inventory matches. + var pendingRunAction: PendingRunAction? private var requestProjectOpen: ((URL) -> Void)? private var didCloseProject: (() -> Void)? private var securityScopedWorkspaceURL: URL? @@ -749,36 +752,6 @@ final class AppModel: ObservableObject, Identifiable { } } - /// Loads build-system and run state at the workspace boundary. The generic - /// run lifecycle is intentionally not owned by JavaFeatureModel. - /// - /// Spring indexing is scheduled rather than awaited. It scales with the - /// number of Java sources, and run configurations, test discovery, and the - /// Git refresh that follows this call must not wait for it. - func loadProjectServices(at workspaceURL: URL, files: [URL]) async { - prepareJavaLanguageServerForWorkspaceIfNeeded( - at: workspaceURL, - files: files - ) - springFeature.scheduleLoad( - workspaceURL: workspaceURL, - files: files, - textOverrides: Dictionary(uniqueKeysWithValues: openDocuments.map { - ($0.url.standardizedFileURL, $0.text) - }) - ) - guard let execution = await activateExecutionModule() else { return } - execution.tests.discover(workspaceURL: workspaceURL, files: files) - // The workspace feature owns snapshot identity. Passing it through lets - // the run service tell a complete file inventory from a provisional one, - // so generation never scans a partial workspace. - await execution.projectDevelopment.loadProject( - at: workspaceURL, - files: files, - snapshotID: workspaceFeature.snapshotID - ) - } - var projectName: String { workspaceURL?.lastPathComponent ?? "Lithe" } @@ -944,6 +917,7 @@ final class AppModel: ObservableObject, Identifiable { runtimeFeature.openProject(at: normalizedURL) mavenFeatureIfActive?.reset() runFeatureIfActive?.reset() + pendingRunAction = nil debugFeatureIfActive?.reset() genericDebugFeatureIfActive?.reset() clearLanguageNavigationProjection() @@ -1054,6 +1028,7 @@ final class AppModel: ObservableObject, Identifiable { runtimeFeature.closeProject() mavenFeatureIfActive?.reset() runFeatureIfActive?.reset() + pendingRunAction = nil debugFeatureIfActive?.reset() genericDebugFeatureIfActive?.reset() javaFeature.stop() diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift index b0ad619b..68b6c2b4 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift @@ -827,7 +827,9 @@ struct WorkbenchView: View { guard let runFeature = model.runFeatureIfActive else { return } let intent = runFeature.generationIntent Task { - await runFeature.generateRunConfigurations() + // Routed through AppModel so the run service is brought up to the + // current workspace snapshot before it scans anything. + await model.generateRunConfigurations() guard runFeature.configurationStatus == .ready else { return } switch intent { case .identifyOnly: diff --git a/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift b/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift index 1c2bc233..66a2ef6c 100644 --- a/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift +++ b/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift @@ -76,10 +76,18 @@ package enum ProjectLoadState: Equatable, Sendable { } } - /// Whether the inventory for `workspace` is complete enough to generate from. - package func isReady(for workspace: URL) -> Bool { - guard case .ready(let boundWorkspace, _) = self else { return false } - return boundWorkspace == workspace.standardizedFileURL + /// Whether the inventory matches `snapshotID` for `workspace`, and is + /// therefore the current, complete inventory. + /// + /// Comparing the snapshot as well as the workspace is what rejects a + /// superseded snapshot of the same workspace: a refresh publishes a new + /// snapshot before the run service consumes it, and scanning the previous + /// inventory would miss entry points the refresh added. + package func isReady(for workspace: URL, snapshotID: UUID?) -> Bool { + guard let snapshotID, + case .ready(let boundWorkspace, let boundSnapshotID) = self + else { return false } + return boundWorkspace == workspace.standardizedFileURL && boundSnapshotID == snapshotID } } diff --git a/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift b/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift index 68ec554c..89ea2bb8 100644 --- a/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift +++ b/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift @@ -161,8 +161,8 @@ package final class RunFeatureModel: ObservableObject { package var projectLoadState: ProjectLoadState { service.projectLoadState } - package func isProjectReady(for workspace: URL) -> Bool { - service.isProjectReady(for: workspace) + package func isProjectReady(for workspace: URL, snapshotID: UUID?) -> Bool { + service.isProjectReady(for: workspace, snapshotID: snapshotID) } package func loadProject( @@ -221,8 +221,8 @@ package final class ProjectDevelopmentFeatureModel { self.runFeature = runFeature } - package func isRunProjectReady(for workspace: URL) -> Bool { - runFeature.isProjectReady(for: workspace) + package func isRunProjectReady(for workspace: URL, snapshotID: UUID?) -> Bool { + runFeature.isProjectReady(for: workspace, snapshotID: snapshotID) } package func loadProject(at workspaceURL: URL, files: [URL], snapshotID: UUID? = nil) async { diff --git a/macos/Sources/LitheExecutionModule/Services/RunService.swift b/macos/Sources/LitheExecutionModule/Services/RunService.swift index 7f18c4c6..860d777a 100644 --- a/macos/Sources/LitheExecutionModule/Services/RunService.swift +++ b/macos/Sources/LitheExecutionModule/Services/RunService.swift @@ -110,8 +110,8 @@ package final class RunService: ObservableObject { /// therefore complete enough to generate a configuration from. Entry points /// that activate the execution module on demand use this to decide whether /// the project still has to be loaded. - package func isProjectReady(for workspace: URL) -> Bool { - projectLoadState.isReady(for: workspace) + package func isProjectReady(for workspace: URL, snapshotID: UUID?) -> Bool { + projectLoadState.isReady(for: workspace, snapshotID: snapshotID) } @discardableResult @@ -223,7 +223,7 @@ package final class RunService: ObservableObject { // provisional inventory would write a configuration that omits entry // points the workspace contains. Dropping the request silently is also // indistinguishable from a broken button, so report the pending state. - guard let projectURL, projectLoadState.isReady(for: projectURL) else { + guard let projectURL, case .ready = projectLoadState else { generationState = .projectNotReady return } diff --git a/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift b/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift index d8284010..8500682a 100644 --- a/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift +++ b/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift @@ -98,7 +98,7 @@ struct ExecutionModuleTests { // Binding without a snapshot only unlocks reading existing configuration. await service.loadProject(at: root, files: [], mavenProject: nil) #expect(service.projectLoadState == .bound(workspace: root)) - #expect(!service.isProjectReady(for: root)) + #expect(!service.isProjectReady(for: root, snapshotID: UUID())) await service.generateRunConfigurations() #expect(service.generationState == .projectNotReady) #expect(operations.generateCallCount == 0) @@ -106,6 +106,9 @@ struct ExecutionModuleTests { let snapshotID = UUID() await service.loadProject(at: root, files: [], mavenProject: nil, snapshotID: snapshotID) #expect(service.projectLoadState == .ready(workspace: root, snapshotID: snapshotID)) + #expect(service.isProjectReady(for: root, snapshotID: snapshotID)) + // A superseded snapshot of the same workspace is not ready. + #expect(!service.isProjectReady(for: root, snapshotID: UUID())) await service.generateRunConfigurations() #expect(operations.generateCallCount == 1) diff --git a/macos/Tests/LitheTests/RunEntryPointTests.swift b/macos/Tests/LitheTests/RunEntryPointTests.swift index dc92a3a6..1ea9b5e7 100644 --- a/macos/Tests/LitheTests/RunEntryPointTests.swift +++ b/macos/Tests/LitheTests/RunEntryPointTests.swift @@ -29,7 +29,7 @@ struct RunEntryPointTests { model.runFeatureIfActive?.projectLoadState.workspace != nil } #expect(bound, "the Run entry point never bound the workspace") - #expect(model.runFeatureIfActive?.isProjectReady(for: workspace.root) == false) + #expect(model.runFeatureIfActive?.isProjectReady(for: workspace.root, snapshotID: model.workspaceSnapshotID) == false) let runFeature = try #require(model.runFeatureIfActive) await runFeature.generateRunConfigurations() @@ -39,7 +39,10 @@ struct RunEntryPointTests { operations.releaseSnapshot() let ready = await awaitChange(on: model) { - model.runFeatureIfActive?.isProjectReady(for: workspace.root) == true + model.runFeatureIfActive?.isProjectReady( + for: workspace.root, + snapshotID: model.workspaceSnapshotID + ) == true } #expect(ready, "the applied snapshot never made the run project ready") // Which paths generation then scans is asserted against the run @@ -47,6 +50,36 @@ struct RunEntryPointTests { // binary does not link the Rust Core that performs the scan. } + /// Launching from a provisional inventory resolves toolchains without the + /// Maven project, so Run has to defer rather than proceed on a bound-only + /// workspace. The deferred action is remembered and resumed by the load the + /// snapshot drives, which is what lets the user press Run once. + @Test + func runDefersAndResumesWhenTheSnapshotArrivesLater() async throws { + let workspace = try JavaWorkspaceFixture() + defer { workspace.remove() } + let operations = GatedWorkspaceOperations(snapshot: workspace.snapshot) + let model = makeAppModel(workspaceOperations: operations) + + model.openProjectDirectly(workspace.root) + model.runSelectedConfiguration() + + let deferred = await awaitChange(on: model) { model.pendingRunAction != nil } + #expect(deferred, "Run must be deferred while the inventory is provisional") + + operations.releaseSnapshot() + + let ready = await awaitChange(on: model) { + model.runFeatureIfActive?.isProjectReady( + for: workspace.root, + snapshotID: model.workspaceSnapshotID + ) == true + } + #expect(ready, "the applied snapshot never made the run project ready") + let resumed = await awaitChange(on: model) { model.pendingRunAction == nil } + #expect(resumed, "the deferred Run was never resumed") + } + /// Debugging reaches the same run feature through its own entry point. @Test func debugBeforeTheSnapshotDefersGenerationUntilTheInventoryIsComplete() async throws { @@ -62,12 +95,15 @@ struct RunEntryPointTests { model.runFeatureIfActive?.projectLoadState.workspace != nil } #expect(bound, "the Debug entry point never bound the workspace") - #expect(model.runFeatureIfActive?.isProjectReady(for: workspace.root) == false) + #expect(model.runFeatureIfActive?.isProjectReady(for: workspace.root, snapshotID: model.workspaceSnapshotID) == false) operations.releaseSnapshot() let ready = await awaitChange(on: model) { - model.runFeatureIfActive?.isProjectReady(for: workspace.root) == true + model.runFeatureIfActive?.isProjectReady( + for: workspace.root, + snapshotID: model.workspaceSnapshotID + ) == true } #expect(ready, "the applied snapshot never made the run project ready") } @@ -85,7 +121,10 @@ struct RunEntryPointTests { model.openProjectDirectly(workspace.root) let ready = await awaitChange(on: model) { - model.runFeatureIfActive?.isProjectReady(for: workspace.root) == true + model.runFeatureIfActive?.isProjectReady( + for: workspace.root, + snapshotID: model.workspaceSnapshotID + ) == true } #expect(ready, "opening a project should make the run project ready") } From a63d39a41fcde33f3e770348da97315a3d714524 Mon Sep 17 00:00:00 2001 From: fenghp Date: Sat, 29 Aug 2026 13:58:55 +0800 Subject: [PATCH 06/66] =?UTF-8?q?test(macos):=20=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E5=B7=B2=E6=9C=89=E9=85=8D=E7=BD=AE=E4=B8=94=E5=BF=AB=E7=85=A7?= =?UTF-8?q?=E9=98=BB=E5=A1=9E=E6=97=B6=E8=BF=90=E8=A1=8C=E5=BF=85=E9=A1=BB?= =?UTF-8?q?=E6=8E=A8=E8=BF=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一版的入口测试只证明了推迟与恢复,没能覆盖评审要的"已有配置 + 快照阻塞" 子场景:真实存储的 inspect 要经 Rust Core,而 Swift 测试二进制不链接它 (bridge.c 提供 weak 桩,isAvailable 为 false),所以 configurationStatus 无法在测试里变成 ready。 给 MacServiceContainer 增加 runConfigurationOperations 可选覆盖,与已有的 workspaceOperations、moduleStore 等注入缝同类。测试用一个直接报 ready 的替身 构造该前提,并记录 launchPlan 请求次数:快照阻塞期间断言运行被推迟、 configurationStatus 确为 ready、launchPlan 从未被请求;放行快照后断言恢复执行 且就绪。 未选择改为链接 Rust 的真实测试:CI 中没有任何任务在链接 Rust 的情况下执行 LitheTests(verify-rust-core.sh 只跑 cargo test、swift build 与独立的桥接验证 程序),因此这样的测试会永远跳过。仓库中已有 9 处依赖 isAvailable 的跳过测试, 不宜再添。 Refs #300 --- .../Platform/MacOS/MacServiceContainer.swift | 9 +- .../Tests/LitheTests/RunEntryPointTests.swift | 99 ++++++++++++++++++- 2 files changed, 104 insertions(+), 4 deletions(-) diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 6c4aaf53..0bc9fedf 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -53,6 +53,7 @@ final class MacServiceContainer { moduleLaunchMode: ModuleLaunchMode = .normal, moduleStore providedModuleStore: MacModuleConfigurationStore? = nil, workspaceOperations providedWorkspaceOperations: (any WorkspaceOperations)? = nil, + runConfigurationOperations providedRunConfigurationOperations: (any RunConfigurationOperations)? = nil, pluginRuntimeRecovery: MacPluginRuntimeRecoveryCoordinator? = nil, authorizationCallbackRouter providedAuthorizationCallbackRouter: MacExternalAuthorizationCallbackRouter? = nil ) { @@ -74,6 +75,10 @@ final class MacServiceContainer { preferences: store ) self.runConfigurationStore = runConfigurationStore + // Only the run-configuration boundary is overridable. The concrete store + // stays available for callers that need the macOS adapter itself. + let runConfigurationOperations: any RunConfigurationOperations = + providedRunConfigurationOperations ?? runConfigurationStore let fileOperations = MacWorkspaceFileOperations() let processRunner = MacProcessRunner() let secureStore = MacLocalSecretStore() @@ -320,7 +325,7 @@ final class MacServiceContainer { fileAccess: MacRunFileAccess(storage: fileStorage), preferences: MacRunPreferenceStore(store: store), serverPortParser: javaMavenOperations, - runConfigurationOperations: runConfigurationStore, + runConfigurationOperations: runConfigurationOperations, executableResolver: executableResolver, languageProviderCatalog: languagePackRegistry.catalog, languageRunProviders: languagePackRegistry.runProviders, @@ -395,7 +400,7 @@ final class MacServiceContainer { processFactory: { MacStreamingProcess(processRegistry: processRegistry, moduleID: .debug) }, fileStorage: fileStorage, javaMavenOperations: javaMavenOperations, - runConfigurationOperations: runConfigurationStore + runConfigurationOperations: runConfigurationOperations ), adapterSessions: adapterSessions ) diff --git a/macos/Tests/LitheTests/RunEntryPointTests.swift b/macos/Tests/LitheTests/RunEntryPointTests.swift index 1ea9b5e7..e90ae4d8 100644 --- a/macos/Tests/LitheTests/RunEntryPointTests.swift +++ b/macos/Tests/LitheTests/RunEntryPointTests.swift @@ -80,6 +80,48 @@ struct RunEntryPointTests { #expect(resumed, "the deferred Run was never resumed") } + /// A workspace that already has a configuration reports `configurationStatus + /// == .ready` as soon as it is bound, which used to be enough to launch. With + /// a provisional inventory the Maven project is absent, so toolchains resolve + /// without it. Readiness has to be checked before the configuration status. + @Test + func runWithExistingConfigurationLaunchesOnlyAfterTheSnapshotArrives() async throws { + let workspace = try JavaWorkspaceFixture() + defer { workspace.remove() } + let workspaceOperations = GatedWorkspaceOperations(snapshot: workspace.snapshot) + let runConfigurations = ReadyRunConfigurationOperations() + let model = makeAppModel( + workspaceOperations: workspaceOperations, + runConfigurationOperations: runConfigurations + ) + + model.openProjectDirectly(workspace.root) + model.runSelectedConfiguration() + + let deferred = await awaitChange(on: model) { model.pendingRunAction != nil } + #expect(deferred, "Run must be deferred while the file inventory is provisional") + let runFeature = try #require(model.runFeatureIfActive) + #expect( + runFeature.configurationStatus == .ready, + "the seeded configuration should already report ready" + ) + #expect( + runConfigurations.launchPlanCallCount == 0, + "Run must not build a launch plan from a provisional inventory" + ) + + workspaceOperations.releaseSnapshot() + + let resumed = await awaitChange(on: model) { model.pendingRunAction == nil } + #expect(resumed, "the deferred Run was never resumed") + #expect( + runFeature.isProjectReady( + for: workspace.root, + snapshotID: model.workspaceSnapshotID + ) + ) + } + /// Debugging reaches the same run feature through its own entry point. @Test func debugBeforeTheSnapshotDefersGenerationUntilTheInventoryIsComplete() async throws { @@ -129,13 +171,17 @@ struct RunEntryPointTests { #expect(ready, "opening a project should make the run project ready") } - private func makeAppModel(workspaceOperations: any WorkspaceOperations) -> AppModel { + private func makeAppModel( + workspaceOperations: any WorkspaceOperations, + runConfigurationOperations: (any RunConfigurationOperations)? = nil + ) -> AppModel { let store = RunEntryPointTestStore() let settings = AppSettings(store: store) let services = MacServiceContainer( store: store, settings: settings, - workspaceOperations: workspaceOperations + workspaceOperations: workspaceOperations, + runConfigurationOperations: runConfigurationOperations ).services return AppModel(settings: settings, services: services) } @@ -219,6 +265,55 @@ private final class GatedWorkspaceOperations: WorkspaceOperations, @unchecked Se } } +/// Reports a workspace that already carries a configuration, which the Swift test +/// binary cannot obtain from the real store because it does not link the Rust +/// Core. Records launch-plan requests so a test can prove no launch was built. +private final class ReadyRunConfigurationOperations: RunConfigurationOperations, @unchecked Sendable { + private let lock = NSLock() + private var launchPlanCalls = 0 + + var launchPlanCallCount: Int { + lock.lock() + defer { lock.unlock() } + return launchPlanCalls + } + + func inspect(at projectURL: URL) -> ProjectRunConfigurationInspection { + ProjectRunConfigurationInspection(status: .ready, diagnostics: []) + } + + func generate(at projectURL: URL, files: [URL], modulePaths: [String]) throws -> RunConfigurationGenerationResult { + RunConfigurationGenerationResult(entryCount: 1) + } + + func resolve(at projectURL: URL, toolchainCandidates: [ProjectToolchainCandidate]) throws -> RunConfigurationResolution { + RunConfigurationResolution( + configurations: [EffectiveRunConfiguration( + configuration: .currentFile, + options: RunOptions() + )], + diagnostics: [], + defaultConfigurationID: RunConfiguration.currentFileID + ) + } + + func launchPlan( + at projectURL: URL, + configurationID: String, + currentFile: String?, + classPath: String?, + debugPort: Int? + ) throws -> SharedLaunchPlan { + lock.lock() + launchPlanCalls += 1 + lock.unlock() + throw RunConfigurationOperationFailure(message: "Launching is out of scope for this test") + } + + func createConfiguration(_ draft: RunConfigurationDraft, at projectURL: URL) throws -> String { draft.name } + func migrateLegacySettings(at projectURL: URL, configurationIDs: [String]) throws {} +} + private final class RunEntryPointTestStore: KeyValueStore, @unchecked Sendable { private var values: [String: Any] = [:] From 9ec894703185be59e93c3368de0bb5d3c82e1f21 Mon Sep 17 00:00:00 2001 From: fenghp Date: Sat, 29 Aug 2026 16:19:36 +0800 Subject: [PATCH 07/66] =?UTF-8?q?fix(macos):=20=E5=BF=AB=E7=85=A7=E6=A0=87?= =?UTF-8?q?=E8=AF=86=E4=B8=8E=E6=96=87=E4=BB=B6=E6=B8=85=E5=8D=95=E5=90=8C?= =?UTF-8?q?=E6=BA=90=E4=BC=A0=E9=80=92=EF=BC=8C=E5=B9=B6=E4=BF=AE=E5=A5=BD?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E5=8A=A0=E8=BD=BD=E7=AB=9E=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 避免 await 前后快照错配,以及快照已消费后入口仍用旧捕获 defer 导致 Run 永久挂起。 --- .../AppModel/AppModel+Development.swift | 128 +++++++++--- .../AppModel/AppModel+FeatureState.swift | 7 +- .../Lithe/Models/AppModel/AppModel.swift | 13 +- .../Execution/RunConfigurationContracts.swift | 21 +- .../Workspace/WorkspaceModels.swift | 10 +- .../Services/RunService.swift | 4 + .../Application/WorkspaceFeatureModel.swift | 30 ++- .../ExecutionModuleTests.swift | 53 +++++ .../Tests/LitheTests/RunEntryPointTests.swift | 184 +++++++++++++++++- 9 files changed, 382 insertions(+), 68 deletions(-) diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift index b72dfe81..c9d777ea 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift @@ -4,9 +4,17 @@ import LitheExecutionModule import LitheModuleAPI /// An action deferred until the run feature holds the current workspace snapshot. -enum PendingRunAction { - case run - case debug +/// +/// The workspace it was deferred for is part of the value so a snapshot applied +/// for a different workspace cannot resume it. +struct PendingRunAction: Equatable { + enum Kind: Equatable { + case run + case debug + } + + let kind: Kind + let workspace: URL } @MainActor @@ -39,7 +47,7 @@ extension AppModel { guard let self else { return } guard await activateExecutionModule() != nil else { return } if let workspaceURL { - await loadProjectServices(at: workspaceURL, files: projectFiles) + await loadProjectServicesForAppliedSnapshot(at: workspaceURL) } } isTestsVisible = false @@ -62,7 +70,7 @@ extension AppModel { Task { [weak self] in guard let self, await activateExecutionModule() != nil, let workspaceURL else { return } - await loadProjectServices(at: workspaceURL, files: projectFiles) + await loadProjectServicesForAppliedSnapshot(at: workspaceURL) } isTestsVisible = false isGitLogVisible = false @@ -76,7 +84,7 @@ extension AppModel { guard let self else { return } let capability = await self.activateExecutionModule() if capability?.mavenFeature.project == nil { - await self.loadProjectServices(at: workspaceURL, files: self.projectFiles) + await self.loadProjectServicesForAppliedSnapshot(at: workspaceURL) } } } @@ -172,13 +180,37 @@ extension AppModel { Task { [weak self] in await self?.runSelectedConfigurationAfterActivation() } } + /// Loads project services for the scan currently applied to the workspace. + /// + /// Callers that only want "whatever the workspace has now" use this so the + /// file list and its identity are captured in a single read. + func loadProjectServicesForAppliedSnapshot(at workspaceURL: URL) async { + let applied = workspaceFeature.appliedSnapshot + await loadProjectServices( + at: workspaceURL, + files: applied?.files ?? [], + snapshotID: applied?.id + ) + } + /// Loads build-system and run state at the workspace boundary. The generic /// run lifecycle is intentionally not owned by JavaFeatureModel. /// /// Spring indexing is scheduled rather than awaited. It scales with the /// number of Java sources, and run configurations, test discovery, and the /// Git refresh that follows this call must not wait for it. - func loadProjectServices(at workspaceURL: URL, files: [URL]) async { + /// + /// `files` and `snapshotID` must describe the same scan; the caller captures + /// them together. `resumesDeferredRunAction` is set only by the workspace + /// snapshot callback, because a deferred Run waits for a snapshot and + /// resuming from any other load would either re-enter through + /// `ensureRunProjectReady` or fire the action from an unrelated reload. + func loadProjectServices( + at workspaceURL: URL, + files: [URL], + snapshotID: UUID?, + resumesDeferredRunAction: Bool = false + ) async { prepareJavaLanguageServerForWorkspaceIfNeeded( at: workspaceURL, files: files @@ -192,15 +224,20 @@ extension AppModel { ) guard let execution = await activateExecutionModule() else { return } execution.tests.discover(workspaceURL: workspaceURL, files: files) - // The workspace feature owns snapshot identity. Passing it through lets - // the run service tell a complete file inventory from a provisional one, - // so generation never scans a partial workspace. + // `files` and `snapshotID` are captured together by the caller. Reading + // the applied snapshot here instead would pair this file list with a + // newer scan's identity, which the readiness comparison cannot detect. await execution.projectDevelopment.loadProject( at: workspaceURL, files: files, - snapshotID: workspaceSnapshotID + snapshotID: snapshotID + ) + guard resumesDeferredRunAction else { return } + resumeDeferredRunAction( + execution.runFeature, + workspace: workspaceURL, + snapshotID: snapshotID ) - resumePendingRunActionIfProjectBecameReady(execution.runFeature) } /// Brings the run feature up to the workspace snapshot the workspace feature @@ -211,36 +248,69 @@ extension AppModel { /// current snapshot rather than merely being bound to the workspace. private func ensureRunProjectReady(_ runFeature: RunFeatureModel) async -> Bool { guard let workspaceURL else { return false } - let snapshotID = workspaceSnapshotID - if runFeature.isProjectReady(for: workspaceURL, snapshotID: snapshotID) { return true } - await loadProjectServices(at: workspaceURL, files: projectFiles) - return runFeature.isProjectReady( - for: workspaceURL, - snapshotID: workspaceSnapshotID + // One read, so the file list and the identity describe the same scan. + let applied = workspaceFeature.appliedSnapshot + if runFeature.isProjectReady(for: workspaceURL, snapshotID: applied?.id) { return true } + await loadProjectServices( + at: workspaceURL, + files: applied?.files ?? [], + snapshotID: applied?.id ) + // A snapshot may land and be fully consumed while this load is in + // flight, including its deferred-run resume with nothing pending yet. + // Comparing against the pre-await capture would then treat a ready + // project as not ready, defer the action, and leave it stranded. + let current = workspaceFeature.appliedSnapshot + return runFeature.isProjectReady(for: workspaceURL, snapshotID: current?.id) } /// Continues an action that arrived before the snapshot did. The workspace - /// rebuild always finishes with `loadProjectServices`, so recording the - /// intent is enough to resume without polling or waiting. - private func resumePendingRunActionIfProjectBecameReady(_ runFeature: RunFeatureModel) { - guard let action = pendingRunAction, - let workspaceURL, - runFeature.isProjectReady(for: workspaceURL, snapshotID: workspaceSnapshotID) - else { return } + /// rebuild always finishes by applying a snapshot, so recording the intent is + /// enough to resume without polling or waiting. + /// + /// The action is dropped when the applied snapshot belongs to a different + /// workspace, which is the case the user is no longer waiting on. + /// `snapshotID` is the scan this load just applied, not whatever the + /// workspace holds now. Reading the current identity here would compare the + /// run feature against a scan it has not consumed. + private func resumeDeferredRunAction( + _ runFeature: RunFeatureModel, + workspace: URL, + snapshotID: UUID? + ) { + guard let action = pendingRunAction else { return } + guard action.workspace == workspace.standardizedFileURL else { + pendingRunAction = nil + return + } + guard runFeature.isProjectReady(for: workspace, snapshotID: snapshotID) else { + return + } pendingRunAction = nil - switch action { + switch action.kind { case .run: runSelectedConfiguration() case .debug: startDebugging() } } + /// Records an action the current workspace snapshot is not ready for. + private func deferRunAction(_ kind: PendingRunAction.Kind) { + guard let workspaceURL else { + pendingRunAction = nil + return + } + pendingRunAction = PendingRunAction( + kind: kind, + workspace: workspaceURL.standardizedFileURL + ) + } + private func runSelectedConfigurationAfterActivation() async { guard let runFeature = await activateExecutionModule()?.runFeature else { return } guard await ensureRunProjectReady(runFeature) else { // Launching from a provisional inventory resolves toolchains without // the Maven project, so wait for the snapshot instead of running. - pendingRunAction = .run + deferRunAction(.run) return } pendingRunAction = nil @@ -393,7 +463,7 @@ extension AppModel { guard let self else { return } guard await activateExecutionModule() != nil else { return } if let workspaceURL { - await loadProjectServices(at: workspaceURL, files: projectFiles) + await loadProjectServicesForAppliedSnapshot(at: workspaceURL) } _ = await activateDebugModule() } @@ -415,7 +485,7 @@ extension AppModel { let debug = await activateDebugModule() else { return } let runFeature = execution.runFeature guard await ensureRunProjectReady(runFeature) else { - pendingRunAction = .debug + deferRunAction(.debug) return } pendingRunAction = nil diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift index 9b997739..3a7565e4 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift @@ -9,9 +9,10 @@ extension AppModel { var isIndexingSpring: Bool { springFeature.isIndexing } var rootNode: FileNode? { workspaceFeature.rootNode } var projectFiles: [URL] { workspaceFeature.projectFiles } - /// Identifies the snapshot `projectFiles` came from, and is `nil` until one - /// has been applied. - var workspaceSnapshotID: UUID? { workspaceFeature.snapshotID } + /// Identifies the scan `projectFiles` came from, and is `nil` until one has + /// been applied. Callers that also need the file list read + /// `workspaceFeature.appliedSnapshot` once instead of combining the two. + var workspaceSnapshotID: UUID? { workspaceFeature.appliedSnapshot?.id } var javaEnvironmentReport: JavaEnvironmentReport? { runtimeFeature.javaEnvironmentReport } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 6f70d1e3..8822dad9 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -508,7 +508,7 @@ final class AppModel: ObservableObject, Identifiable { }, reloadProjectServices: { [weak self] in guard let self, let workspaceURL = self.workspaceURL else { return } - await self.loadProjectServices(at: workspaceURL, files: self.projectFiles) + await self.loadProjectServicesForAppliedSnapshot(at: workspaceURL) }, refreshGit: { [weak self] in guard let feature = self?.gitFeatureIfActive else { return } @@ -521,7 +521,14 @@ final class AppModel: ObservableObject, Identifiable { onSnapshotLoaded: { [weak self] snapshot, isInitialLoad in guard let self, let workspaceURL = self.workspaceURL else { return } // WorkspaceFeatureModel requests the single Git refresh after this callback. - await self.loadProjectServices(at: workspaceURL, files: snapshot.files) + // The applied snapshot is the event a deferred Run waits for, and + // it carries its own identity so the pair cannot drift. + await self.loadProjectServices( + at: workspaceURL, + files: snapshot.files, + snapshotID: snapshot.id, + resumesDeferredRunAction: true + ) if isInitialLoad { self.projectHistoryFeatureIfActive?.seed(files: snapshot.files) } @@ -747,7 +754,7 @@ final class AppModel: ObservableObject, Identifiable { } Task { [weak self] in guard let self else { return } - await self.loadProjectServices(at: workspaceURL, files: self.projectFiles) + await self.loadProjectServicesForAppliedSnapshot(at: workspaceURL) } } } diff --git a/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift b/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift index 66a2ef6c..b05cf9b1 100644 --- a/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift +++ b/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift @@ -47,14 +47,21 @@ package struct ProjectRunConfigurationInspection: Equatable, Sendable { } } -/// How a run service is bound to a workspace. +/// How complete the workspace file inventory a run service holds is. /// /// Being bound to a workspace URL and holding a complete file inventory are /// different things. Reading an existing configuration only needs the URL, while /// generating one scans the inventory, so a provisional inventory would write a /// configuration that omits entry points the workspace actually contains. +/// +/// This describes the inventory only. Whether the configuration on disk is +/// readable is `ProjectRunConfigurationStatus`, and the two must stay separate: +/// a broken `generated.json` has to remain regenerable. package enum ProjectLoadState: Equatable, Sendable { case idle + /// A load is in flight. Entering this state before the load's first + /// suspension point is what stops a cancelled or superseded load from + /// leaving the previous `ready` inventory in place. case loading(workspace: URL) /// Bound to the workspace, but the file inventory is provisional because the /// workspace snapshot has not been applied yet. Existing configuration can be @@ -63,18 +70,6 @@ package enum ProjectLoadState: Equatable, Sendable { /// The inventory came from the identified workspace snapshot, so generation /// can scan it safely. case ready(workspace: URL, snapshotID: UUID) - case failed(workspace: URL, message: String) - - /// The workspace this state describes, when it describes one. - package var workspace: URL? { - switch self { - case .idle: nil - case .loading(let workspace): workspace - case .bound(let workspace): workspace - case .ready(let workspace, _): workspace - case .failed(let workspace, _): workspace - } - } /// Whether the inventory matches `snapshotID` for `workspace`, and is /// therefore the current, complete inventory. diff --git a/macos/Sources/LitheCoreContracts/Workspace/WorkspaceModels.swift b/macos/Sources/LitheCoreContracts/Workspace/WorkspaceModels.swift index 542bf7d4..14557f95 100644 --- a/macos/Sources/LitheCoreContracts/Workspace/WorkspaceModels.swift +++ b/macos/Sources/LitheCoreContracts/Workspace/WorkspaceModels.swift @@ -38,8 +38,16 @@ package struct FileNode: Identifiable, Hashable, Sendable { package struct WorkspaceSnapshot: Sendable { package let root: FileNode package let files: [URL] - package init(root: FileNode, files: [URL]) { + /// Distinguishes this scan of the workspace from any other. + /// + /// Consumers that scan `files` compare this identity to decide whether their + /// inventory is current. Carrying it in the snapshot is what keeps a file + /// list from ever being paired with a different scan's identity. + package let id: UUID + + package init(root: FileNode, files: [URL], id: UUID = UUID()) { self.root = root self.files = files + self.id = id } } diff --git a/macos/Sources/LitheExecutionModule/Services/RunService.swift b/macos/Sources/LitheExecutionModule/Services/RunService.swift index 860d777a..84d6a571 100644 --- a/macos/Sources/LitheExecutionModule/Services/RunService.swift +++ b/macos/Sources/LitheExecutionModule/Services/RunService.swift @@ -173,6 +173,10 @@ package final class RunService: ObservableObject { selectedConfigurationIDsByProject[currentProject.path] = selectedConfigurationID } self.projectURL = workspace + // Whether the existing configuration parses is `configurationStatus`, not + // this state. Keeping them apart is what lets a broken generated.json be + // regenerated: folding a parse failure in here would block generation, + // which is the only way to repair it. projectLoadState = snapshotID .map { .ready(workspace: workspace, snapshotID: $0) } ?? .bound(workspace: workspace) diff --git a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift index 09262903..43c698d7 100644 --- a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift +++ b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift @@ -12,11 +12,13 @@ package enum WorkspaceRebuildResult: Sendable { @MainActor package final class WorkspaceFeatureModel: ObservableObject { @Published package private(set) var rootNode: FileNode? - @Published package private(set) var projectFiles: [URL] = [] - /// Identifies the snapshot `projectFiles` came from, and is `nil` whenever no - /// snapshot has been applied. Consumers that scan the file inventory use this - /// to tell a complete inventory from a provisional one. - @Published package private(set) var snapshotID: UUID? + /// The scan currently applied to the workspace, or `nil` before one is. + /// + /// Consumers that scan the file inventory read this once so the file list and + /// its identity always come from the same scan. + @Published package private(set) var appliedSnapshot: WorkspaceSnapshot? + + package var projectFiles: [URL] { appliedSnapshot?.files ?? [] } @Published package private(set) var isLoadingWorkspace = false @Published package private(set) var isRefreshingWorkspace = false @Published package private(set) var loadErrorMessage: String? @@ -187,8 +189,7 @@ package final class WorkspaceFeatureModel: ObservableObject { workspaceURL = nil hasRestoredWorkspaceSession = false rootNode = nil - projectFiles = [] - snapshotID = nil + appliedSnapshot = nil isLoadingWorkspace = false isRefreshingWorkspace = false loadErrorMessage = nil @@ -298,8 +299,7 @@ package final class WorkspaceFeatureModel: ObservableObject { } loadErrorMessage = nil rootNode = snapshot.root - projectFiles = snapshot.files - snapshotID = UUID() + appliedSnapshot = snapshot scheduleSearchIndexWarm(at: workspaceURL, rules: rules) // The tree is usable as soon as the shared snapshot is ready. Service @@ -833,8 +833,18 @@ package final class WorkspaceFeatureModel: ObservableObject { } private func removeProjectItemFromSnapshot(_ targetURL: URL) { - projectFiles.removeAll { urlContains(targetURL, child: $0) } rootNode = rootNode.flatMap { removingProjectItem(targetURL, from: $0) } + // Dropping files changes the inventory, so the result is a new scan and + // needs a new identity. Reusing the old one would let a consumer treat a + // shortened inventory as the scan it had already accepted. + // + // The pruned tree is carried over too, so the applied snapshot never + // disagrees with `rootNode` about what the workspace contains. + guard let applied = appliedSnapshot else { return } + appliedSnapshot = WorkspaceSnapshot( + root: rootNode ?? applied.root, + files: applied.files.filter { !urlContains(targetURL, child: $0) } + ) } private func removingProjectItem(_ targetURL: URL, from node: FileNode) -> FileNode? { diff --git a/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift b/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift index 8500682a..f7a0bce6 100644 --- a/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift +++ b/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift @@ -158,6 +158,37 @@ struct ExecutionModuleTests { ) } + /// A broken configuration must stay regenerable. Inventory readiness and + /// configuration validity are separate concerns, so an unreadable + /// `generated.json` must not make the project un-ready and lock the user out + /// of the only action that repairs it. + @Test + func unreadableConfigurationStillAllowsRegeneration() async throws { + let operations = FailingInspectionRunConfigurationOperations() + let service = RunService( + runtime: TestRuntime(), + process: TestStreamingProcess(), + processFactory: { TestStreamingProcess() }, + fileAccess: TestRunFileAccess(), + preferences: TestRunPreferences(), + serverPortParser: TestServerPortParser(), + runConfigurationOperations: operations, + executableResolver: TestExecutableResolver(), + languageProviderCatalog: .compatibilityFallback, + languageRunProviders: .standard(catalog: .compatibilityFallback) + ) + let root = URL(fileURLWithPath: "/workspace", isDirectory: true) + let snapshotID = UUID() + + await service.loadProject(at: root, files: [], mavenProject: nil, snapshotID: snapshotID) + + #expect(service.configurationStatus == .invalid("generated.json is invalid")) + #expect(service.isProjectReady(for: root, snapshotID: snapshotID)) + + await service.generateRunConfigurations() + #expect(service.generationState != .projectNotReady) + } + @Test func currentGoFileRunsThroughExtensionOwnedSession() async throws { let builtInProcess = TestStreamingProcess() @@ -472,6 +503,28 @@ private final class RecordingRunConfigurationOperations: RunConfigurationOperati func migrateLegacySettings(at projectURL: URL, configurationIDs: [String]) throws {} } +/// Reports an unreadable configuration so a test can observe the failed state. +private struct FailingInspectionRunConfigurationOperations: RunConfigurationOperations { + func inspect(at projectURL: URL) -> ProjectRunConfigurationInspection { + ProjectRunConfigurationInspection( + status: .invalid("generated.json is invalid"), + diagnostics: [], + recoveryAction: .editConfiguration + ) + } + func generate(at projectURL: URL, files: [URL], modulePaths: [String]) throws -> RunConfigurationGenerationResult { + RunConfigurationGenerationResult(entryCount: 0) + } + func resolve(at projectURL: URL, toolchainCandidates: [ProjectToolchainCandidate]) throws -> RunConfigurationResolution { + RunConfigurationResolution(configurations: [], diagnostics: [], defaultConfigurationID: nil) + } + func launchPlan(at projectURL: URL, configurationID: String, currentFile: String?, classPath: String?, debugPort: Int?) throws -> SharedLaunchPlan { + throw RunConfigurationOperationFailure(message: "Unavailable in inspection test") + } + func createConfiguration(_ draft: RunConfigurationDraft, at projectURL: URL) throws -> String { draft.name } + func migrateLegacySettings(at projectURL: URL, configurationIDs: [String]) throws {} +} + private struct TestReadyRunConfigurationOperations: RunConfigurationOperations { func inspect(at projectURL: URL) -> ProjectRunConfigurationInspection { ProjectRunConfigurationInspection(status: .ready, diagnostics: []) diff --git a/macos/Tests/LitheTests/RunEntryPointTests.swift b/macos/Tests/LitheTests/RunEntryPointTests.swift index e90ae4d8..8c5a31fb 100644 --- a/macos/Tests/LitheTests/RunEntryPointTests.swift +++ b/macos/Tests/LitheTests/RunEntryPointTests.swift @@ -26,7 +26,7 @@ struct RunEntryPointTests { // The entry point binds the workspace so existing configuration is // readable, but the pending snapshot must keep generation out. let bound = await awaitChange(on: model) { - model.runFeatureIfActive?.projectLoadState.workspace != nil + model.runFeatureIfActive?.projectLoadState == .bound(workspace: workspace.root.standardizedFileURL) } #expect(bound, "the Run entry point never bound the workspace") #expect(model.runFeatureIfActive?.isProjectReady(for: workspace.root, snapshotID: model.workspaceSnapshotID) == false) @@ -64,7 +64,7 @@ struct RunEntryPointTests { model.openProjectDirectly(workspace.root) model.runSelectedConfiguration() - let deferred = await awaitChange(on: model) { model.pendingRunAction != nil } + let deferred = await awaitChange(on: model) { model.pendingRunAction?.kind == .run } #expect(deferred, "Run must be deferred while the inventory is provisional") operations.releaseSnapshot() @@ -98,7 +98,7 @@ struct RunEntryPointTests { model.openProjectDirectly(workspace.root) model.runSelectedConfiguration() - let deferred = await awaitChange(on: model) { model.pendingRunAction != nil } + let deferred = await awaitChange(on: model) { model.pendingRunAction?.kind == .run } #expect(deferred, "Run must be deferred while the file inventory is provisional") let runFeature = try #require(model.runFeatureIfActive) #expect( @@ -112,8 +112,14 @@ struct RunEntryPointTests { workspaceOperations.releaseSnapshot() - let resumed = await awaitChange(on: model) { model.pendingRunAction == nil } - #expect(resumed, "the deferred Run was never resumed") + // Production clears the deferred action before it re-issues Run, so + // waiting for the action to clear would pass even if the relaunch were + // dropped. The launch plan request is what proves Run actually ran. + let relaunched = await awaitChange(on: model) { + runConfigurations.launchPlanCallCount == 1 + } + #expect(relaunched, "the deferred Run was never actually re-issued") + #expect(model.pendingRunAction == nil) #expect( runFeature.isProjectReady( for: workspace.root, @@ -122,6 +128,64 @@ struct RunEntryPointTests { ) } + /// When Run's own provisional load is still in flight, the snapshot can land + /// and be fully consumed first — including the deferred-run resume, which + /// finds nothing pending. The entry point must then re-check the *current* + /// snapshot rather than the one it captured before that load, or it defers + /// an action nothing will ever resume. + @Test + func runResumesWhenTheSnapshotLandsDuringTheEntryPointsOwnLoad() async throws { + let workspace = try JavaWorkspaceFixture() + defer { workspace.remove() } + let workspaceOperations = GatedWorkspaceOperations(snapshot: workspace.snapshot) + let runConfigurations = InspectionGatedRunConfigurationOperations() + defer { runConfigurations.releaseAll() } + let model = makeAppModel( + workspaceOperations: workspaceOperations, + runConfigurationOperations: runConfigurations + ) + + model.openProjectDirectly(workspace.root) + model.runSelectedConfiguration() + + // The Run entry point starts its own load while the scan is gated, so it + // captures "no snapshot applied". + #expect( + await runConfigurations.inspectionEntered(1), + "the Run entry point never started its own load" + ) + + // The snapshot lands and is fully consumed while that load is suspended. + workspaceOperations.releaseSnapshot() + #expect( + await runConfigurations.inspectionEntered(2), + "the snapshot-driven load never started" + ) + runConfigurations.release(2) + let ready = await awaitChange(on: model) { + model.runFeatureIfActive?.isProjectReady( + for: workspace.root, + snapshotID: model.workspaceSnapshotID + ) == true + } + #expect(ready, "the snapshot-driven load never made the run project ready") + // Wait for the snapshot-driven load to finish completely, so its resume + // point has already run and found nothing deferred. + let snapshotLoadFinished = await awaitChange(on: model) { + model.runFeatureIfActive?.isLoadingProject == false + && runConfigurations.resolveCallCount == 1 + } + #expect(snapshotLoadFinished, "the snapshot-driven load never finished") + + runConfigurations.release(1) + + let relaunched = await awaitChange(on: model) { + runConfigurations.launchPlanCallCount == 1 + } + #expect(relaunched, "Run was neither launched nor resumed after the snapshot landed") + #expect(model.pendingRunAction == nil) + } + /// Debugging reaches the same run feature through its own entry point. @Test func debugBeforeTheSnapshotDefersGenerationUntilTheInventoryIsComplete() async throws { @@ -134,7 +198,7 @@ struct RunEntryPointTests { model.startDebugging() let bound = await awaitChange(on: model) { - model.runFeatureIfActive?.projectLoadState.workspace != nil + model.runFeatureIfActive?.projectLoadState == .bound(workspace: workspace.root.standardizedFileURL) } #expect(bound, "the Debug entry point never bound the workspace") #expect(model.runFeatureIfActive?.isProjectReady(for: workspace.root, snapshotID: model.workspaceSnapshotID) == false) @@ -246,7 +310,9 @@ private final class GatedWorkspaceOperations: WorkspaceOperations, @unchecked Se func snapshot(at rootURL: URL, visibilityRules: FileVisibilityRules) -> WorkspaceSnapshot? { // Runs on the workspace feature's detached scan task, never the main // actor, and the bounded wait keeps a failing test from pinning it. - guard gate.waitSynchronously() else { return nil } + // The race test holds this gate while an inspect is suspended, so the + // deadline must outlast that coordination window. + guard gate.waitSynchronously(timeout: 30) else { return nil } lock.lock() defer { lock.unlock() } return preparedSnapshot @@ -286,14 +352,114 @@ private final class ReadyRunConfigurationOperations: RunConfigurationOperations, RunConfigurationGenerationResult(entryCount: 1) } + /// A configuration that does not depend on the active editor file, so a + /// resumed Run reaches the launch plan instead of stopping at "no open file". + static let entryPoint = RunConfiguration( + id: "java-main:demo.App", + name: "App", + kind: .javaMain, + execution: .application, + modulePath: nil, + mainClass: "demo.App" + ) + func resolve(at projectURL: URL, toolchainCandidates: [ProjectToolchainCandidate]) throws -> RunConfigurationResolution { RunConfigurationResolution( configurations: [EffectiveRunConfiguration( - configuration: .currentFile, + configuration: Self.entryPoint, + options: RunOptions() + )], + diagnostics: [], + defaultConfigurationID: Self.entryPoint.id + ) + } + + func launchPlan( + at projectURL: URL, + configurationID: String, + currentFile: String?, + classPath: String?, + debugPort: Int? + ) throws -> SharedLaunchPlan { + lock.lock() + launchPlanCalls += 1 + lock.unlock() + throw RunConfigurationOperationFailure(message: "Launching is out of scope for this test") + } + + func createConfiguration(_ draft: RunConfigurationDraft, at projectURL: URL) throws -> String { draft.name } + func migrateLegacySettings(at projectURL: URL, configurationIDs: [String]) throws {} +} + +/// Reports a workspace that already carries a configuration, and lets a test +/// release each `inspect` individually so it can decide what happens while a +/// specific project load is suspended. +private final class InspectionGatedRunConfigurationOperations: RunConfigurationOperations, @unchecked Sendable { + private let entered: [TestGate] + private let releases: [TestGate] + private let lock = NSLock() + private var inspectCalls = 0 + private var launchPlanCalls = 0 + private var resolveCalls = 0 + + init(capacity: Int = 8) { + entered = (0.. Bool { + await entered[ordinal - 1].waitUntilOpen(timeout: .seconds(5)) + } + + func release(_ ordinal: Int) { + releases[ordinal - 1].open() + } + + func releaseAll() { + releases.forEach { $0.open() } + } + + func inspect(at projectURL: URL) -> ProjectRunConfigurationInspection { + lock.lock() + inspectCalls += 1 + let ordinal = inspectCalls + lock.unlock() + // Runs on the run service's utility queue, never the cooperative + // executor. The race test holds the first gate across a full + // snapshot-driven load, so the deadline must cover that window. + entered[ordinal - 1].open() + _ = releases[ordinal - 1].waitSynchronously(timeout: 30) + return ProjectRunConfigurationInspection(status: .ready, diagnostics: []) + } + + func generate(at projectURL: URL, files: [URL], modulePaths: [String]) throws -> RunConfigurationGenerationResult { + RunConfigurationGenerationResult(entryCount: 1) + } + + func resolve(at projectURL: URL, toolchainCandidates: [ProjectToolchainCandidate]) throws -> RunConfigurationResolution { + lock.lock() + resolveCalls += 1 + lock.unlock() + return RunConfigurationResolution( + configurations: [EffectiveRunConfiguration( + configuration: ReadyRunConfigurationOperations.entryPoint, options: RunOptions() )], diagnostics: [], - defaultConfigurationID: RunConfiguration.currentFileID + defaultConfigurationID: ReadyRunConfigurationOperations.entryPoint.id ) } From fa807e1c8e379b8a40f3cd47299b0d3f1674a324 Mon Sep 17 00:00:00 2001 From: fenghp Date: Sat, 29 Aug 2026 17:42:34 +0800 Subject: [PATCH 08/66] =?UTF-8?q?fix(macos):=20=E6=94=B6=E5=8F=A3=E5=BF=AB?= =?UTF-8?q?=E7=85=A7=E5=9B=9E=E8=B0=83=E8=BA=AB=E4=BB=BD=E4=B8=8E=E6=9C=AA?= =?UTF-8?q?=E5=B0=B1=E7=BB=AA=E7=9A=84=E7=94=9F=E6=88=90/=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 让 rebuild 在挂起点后再次拒绝过期 workspace,并把 workspace 与 snapshot 一并传给 onSnapshotLoaded;ensure 失败时停止生成;直接启动与批量服务入口统一走 readiness 守卫并保留 pending 意图。 --- .../Features/WorkspaceFeatureModel.swift | 2 +- .../AppModel/AppModel+Development.swift | 43 +- .../Lithe/Models/AppModel/AppModel.swift | 10 +- .../Platform/MacOS/MacServiceContainer.swift | 4 +- .../Application/ExecutionFeatureModels.swift | 5 + .../Services/RunService.swift | 9 + .../Application/WorkspaceFeatureModel.swift | 15 +- .../GitStatusObservationTests.swift | 2 +- .../LitheTests/LitheCoreLogicTests.swift | 79 +++- .../Tests/LitheTests/RunEntryPointTests.swift | 368 +++++++++++++++++- 10 files changed, 507 insertions(+), 30 deletions(-) diff --git a/macos/Sources/Lithe/Application/Features/WorkspaceFeatureModel.swift b/macos/Sources/Lithe/Application/Features/WorkspaceFeatureModel.swift index dc411bcb..51f32666 100644 --- a/macos/Sources/Lithe/Application/Features/WorkspaceFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/WorkspaceFeatureModel.swift @@ -42,7 +42,7 @@ extension LitheWorkspaceModule.WorkspaceFeatureModel { reloadProjectServices: @escaping @MainActor @Sendable () async -> Void, refreshGit: @escaping @MainActor @Sendable () async -> Void, updateHistoryVisibilityRules: @escaping @MainActor @Sendable (FileVisibilityRules) async -> Void, - onSnapshotLoaded: @escaping @MainActor @Sendable (WorkspaceSnapshot, Bool) async -> Void + onSnapshotLoaded: @escaping @MainActor @Sendable (URL, WorkspaceSnapshot, Bool) async -> Void ) { configureProjection( documentsProvider: { diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift index c9d777ea..cd638dd5 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift @@ -6,11 +6,15 @@ import LitheModuleAPI /// An action deferred until the run feature holds the current workspace snapshot. /// /// The workspace it was deferred for is part of the value so a snapshot applied -/// for a different workspace cannot resume it. +/// for a different workspace cannot resume it. Direct launch entry points also +/// keep the concrete configuration (or the all-services intent) so resume can +/// re-issue the same action the UI asked for. struct PendingRunAction: Equatable { enum Kind: Equatable { case run case debug + case startConfiguration(RunConfiguration) + case runAllServices } let kind: Kind @@ -160,12 +164,18 @@ extension AppModel { /// /// Routing it through here is what keeps the run service from scanning a /// superseded snapshot: the service can only compare its own state, so the - /// caller has to bring it up to the current snapshot first. + /// caller has to bring it up to the current snapshot first. When that fails, + /// generation must stop — the service may still hold an older `.ready` + /// inventory, and scanning it would overwrite `generated.json` with stale + /// entry points. func generateRunConfigurations() async { guard let runFeature = await activateExecutionModule()?.runFeature else { return } // Report the pending workspace through the generation state when the // snapshot has not arrived, which the run panel surfaces as a notice. - _ = await ensureRunProjectReady(runFeature) + guard await ensureRunProjectReady(runFeature) else { + runFeature.reportGenerationProjectNotReady() + return + } await runFeature.generateRunConfigurations() } @@ -290,6 +300,8 @@ extension AppModel { switch action.kind { case .run: runSelectedConfiguration() case .debug: startDebugging() + case .startConfiguration(let configuration): startRunConfiguration(configuration) + case .runAllServices: runAllServiceConfigurations() } } @@ -367,12 +379,20 @@ extension AppModel { func startRunConfiguration(_ configuration: RunConfiguration) { Task { [weak self] in guard let self, - let runFeature = await activateExecutionModule()?.runFeature, - await activateLanguageRunExtensionIfNeeded( - for: configuration, - currentFileURL: activeDocument?.url, - runFeature: runFeature - ) else { return } + let runFeature = await activateExecutionModule()?.runFeature else { return } + guard await ensureRunProjectReady(runFeature) else { + // Direct play buttons reach here without going through + // `runSelectedConfiguration`, so they need the same readiness + // gate and must remember which configuration to resume. + deferRunAction(.startConfiguration(configuration)) + return + } + pendingRunAction = nil + guard await activateLanguageRunExtensionIfNeeded( + for: configuration, + currentFileURL: activeDocument?.url, + runFeature: runFeature + ) else { return } runFeature.startConfiguration(configuration) } } @@ -381,6 +401,11 @@ extension AppModel { Task { [weak self] in guard let self, let runFeature = await activateExecutionModule()?.runFeature else { return } + guard await ensureRunProjectReady(runFeature) else { + deferRunAction(.runAllServices) + return + } + pendingRunAction = nil for configuration in runFeature.configurations where configuration.execution == .service { guard await activateLanguageRunExtensionIfNeeded( for: configuration, diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 8822dad9..f65016af 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -518,11 +518,11 @@ final class AppModel: ObservableObject, Identifiable { guard let feature = await self?.activateHistoryModule() else { return } await feature.updateVisibilityRules(rules.localHistoryRules) }, - onSnapshotLoaded: { [weak self] snapshot, isInitialLoad in - guard let self, let workspaceURL = self.workspaceURL else { return } - // WorkspaceFeatureModel requests the single Git refresh after this callback. - // The applied snapshot is the event a deferred Run waits for, and - // it carries its own identity so the pair cannot drift. + onSnapshotLoaded: { [weak self] workspaceURL, snapshot, isInitialLoad in + guard let self else { return } + // The rebuild already rejected a stale workspace before calling + // us, and it passes that workspace with the snapshot so this + // load cannot pair A.files with B's URL after a project switch. await self.loadProjectServices( at: workspaceURL, files: snapshot.files, diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 0bc9fedf..7052f502 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -54,6 +54,7 @@ final class MacServiceContainer { moduleStore providedModuleStore: MacModuleConfigurationStore? = nil, workspaceOperations providedWorkspaceOperations: (any WorkspaceOperations)? = nil, runConfigurationOperations providedRunConfigurationOperations: (any RunConfigurationOperations)? = nil, + gitWatchContextProvider providedGitWatchContextProvider: (any GitWatchContextProviding)? = nil, pluginRuntimeRecovery: MacPluginRuntimeRecoveryCoordinator? = nil, authorizationCallbackRouter providedAuthorizationCallbackRouter: MacExternalAuthorizationCallbackRouter? = nil ) { @@ -503,7 +504,8 @@ final class MacServiceContainer { fileOperations: fileOperations, binaryFileViewerRegistry: binaryFileViewerRegistry, projectRuntimeService: runtimeService, - gitWatchContextProvider: RustGitWatchContextProvider(core: rustCore), + gitWatchContextProvider: providedGitWatchContextProvider + ?? RustGitWatchContextProvider(core: rustCore), githubService: githubService, secureStore: secureStore, databaseSecureStore: databaseSecureStore, diff --git a/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift b/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift index 89ea2bb8..0cda6c48 100644 --- a/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift +++ b/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift @@ -165,6 +165,11 @@ package final class RunFeatureModel: ObservableObject { service.isProjectReady(for: workspace, snapshotID: snapshotID) } + package func reportGenerationProjectNotReady() { + isGenerationConfirmationPresented = false + service.reportGenerationProjectNotReady() + } + package func loadProject( at workspaceURL: URL, files: [URL], diff --git a/macos/Sources/LitheExecutionModule/Services/RunService.swift b/macos/Sources/LitheExecutionModule/Services/RunService.swift index 84d6a571..d97df864 100644 --- a/macos/Sources/LitheExecutionModule/Services/RunService.swift +++ b/macos/Sources/LitheExecutionModule/Services/RunService.swift @@ -114,6 +114,15 @@ package final class RunService: ObservableObject { projectLoadState.isReady(for: workspace, snapshotID: snapshotID) } + /// Surfaces the "project still loading" generation notice without scanning. + /// + /// AppModel uses this when readiness cannot be established for the current + /// snapshot: the service may still hold an older `.ready` inventory, and + /// calling `generateRunConfigurations` would scan that stale list. + package func reportGenerationProjectNotReady() { + generationState = .projectNotReady + } + @discardableResult package func registerLanguageRunExtension( _ provider: any LanguageRunExtensionProviding, diff --git a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift index 43c698d7..f78595d0 100644 --- a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift +++ b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift @@ -66,7 +66,7 @@ package final class WorkspaceFeatureModel: ObservableObject { private var reloadProjectServices: (@MainActor () async -> Void)? private var refreshGit: (@MainActor () async -> Void)? private var updateHistoryVisibilityRules: (@MainActor (FileVisibilityRules) async -> Void)? - private var onSnapshotLoaded: (@MainActor (WorkspaceSnapshot, Bool) async -> Void)? + private var onSnapshotLoaded: (@MainActor (URL, WorkspaceSnapshot, Bool) async -> Void)? private var warmSearchIndex: (@MainActor (URL, FileVisibilityRules) -> Void)? private var updateSearchIndex: (@MainActor (URL, [String], FileVisibilityRules) async -> Void)? private var invalidateSearchIndex: (@MainActor (URL, FileVisibilityRules) -> Void)? @@ -102,7 +102,7 @@ package final class WorkspaceFeatureModel: ObservableObject { reloadProjectServices: @escaping @MainActor () async -> Void, refreshGit: @escaping @MainActor () async -> Void, updateHistoryVisibilityRules: @escaping @MainActor (FileVisibilityRules) async -> Void, - onSnapshotLoaded: @escaping @MainActor (WorkspaceSnapshot, Bool) async -> Void, + onSnapshotLoaded: @escaping @MainActor (URL, WorkspaceSnapshot, Bool) async -> Void, warmSearchIndex: @escaping @MainActor (URL, FileVisibilityRules) -> Void, updateSearchIndex: @escaping @MainActor (URL, [String], FileVisibilityRules) async -> Void, invalidateSearchIndex: @escaping @MainActor (URL, FileVisibilityRules) -> Void @@ -310,14 +310,23 @@ package final class WorkspaceFeatureModel: ObservableObject { isRefreshingWorkspace = false } + // Session restore and watch setup can suspend. A project switch in that + // window must not let this rebuild keep mutating the new workspace or + // deliver this scan under the new root's identity. if !hasRestoredWorkspaceSession { if let restoreSession, let session = workspaceSessionStore.load(for: workspaceURL) { await restoreSession(session, snapshot.files) } + guard isCurrent() else { return .stale } hasRestoredWorkspaceSession = true } + guard isCurrent() else { return .stale } await updateWatchConfiguration() - await onSnapshotLoaded?(snapshot, isInitialLoad) + guard isCurrent() else { return .stale } + // Pass the rebuild's workspace with the snapshot so the callback never + // re-reads a global URL that may already belong to a different project. + await onSnapshotLoaded?(workspaceURL, snapshot, isInitialLoad) + guard isCurrent() else { return .stale } await requestGitRefreshNow() if pendingFullRescan || pendingWatchRootsChanged { scheduleRecovery() diff --git a/macos/Tests/LitheTests/GitStatusObservationTests.swift b/macos/Tests/LitheTests/GitStatusObservationTests.swift index f08b9bd5..f4886a4d 100644 --- a/macos/Tests/LitheTests/GitStatusObservationTests.swift +++ b/macos/Tests/LitheTests/GitStatusObservationTests.swift @@ -563,7 +563,7 @@ private func makeObservationModel(recorder: GitObservationRecorder) -> Workspace recorder.gitRefreshCount += 1 }, updateHistoryVisibilityRules: { _ in }, - onSnapshotLoaded: { _, _ in } + onSnapshotLoaded: { _, _, _ in } ) return model } diff --git a/macos/Tests/LitheTests/LitheCoreLogicTests.swift b/macos/Tests/LitheTests/LitheCoreLogicTests.swift index 00efa217..a549e213 100644 --- a/macos/Tests/LitheTests/LitheCoreLogicTests.swift +++ b/macos/Tests/LitheTests/LitheCoreLogicTests.swift @@ -3148,7 +3148,7 @@ struct EditorDocumentTests { reloadProjectServices: {}, refreshGit: {}, updateHistoryVisibilityRules: { _ in }, - onSnapshotLoaded: { _, _ in } + onSnapshotLoaded: { _, _, _ in } ) let workspace = URL(fileURLWithPath: "/tmp/retry-workspace") @@ -3202,7 +3202,7 @@ struct EditorDocumentTests { reloadProjectServices: {}, refreshGit: { gitRefreshCount += 1 }, updateHistoryVisibilityRules: { _ in }, - onSnapshotLoaded: { _, _ in snapshotLoadCount += 1 } + onSnapshotLoaded: { _, _, _ in snapshotLoadCount += 1 } ) let workspace = URL(fileURLWithPath: "/tmp/lithe-initial-refresh") model.beginWorkspace(at: workspace, visibilityRules: .default) @@ -3216,6 +3216,77 @@ struct EditorDocumentTests { #expect(gitRefreshCount == 1) } + /// After the snapshot is published, restoreSession and watch setup can still + /// suspend. A project switch in that window must not deliver the old scan + /// through onSnapshotLoaded under the new workspace identity. + @Test + @MainActor + func rebuildRejectsStaleWorkspaceBeforeSnapshotCallback() async { + let enteredRestore = TestGate() + let releaseRestore = TestGate() + defer { releaseRestore.open() } + + let operations = SequencedWorkspaceOperations(snapshotAvailability: [true]) + let sessionStore = WorkspaceSessionStore(store: MutableKeyValueStore()) + let workspace = URL(fileURLWithPath: "/tmp/lithe-stale-snapshot-callback") + sessionStore.save( + WorkspaceSession(openPaths: [], activePath: nil, selectedSidebar: "project"), + for: workspace + ) + + var snapshotLoadCount = 0 + var isCurrent = true + let model = WorkspaceFeatureModel( + operations: operations, + fileOperations: EmptyWorkspaceFileOperations(), + fileStorage: InMemoryFileStorage(), + gitWatchContextProvider: SequencedGitWatchContextProvider([nil]), + directoryWatcherFactory: TestDirectoryWatcherFactory(), + workspaceSessionStore: sessionStore + ) + model.configure( + documentsProvider: { [] }, + activeDocumentProvider: { nil }, + selectedSidebarProvider: { "project" }, + setSelectedSidebar: { _ in }, + restoreSession: { _, _ in + enteredRestore.open() + _ = await releaseRestore.waitUntilOpen(timeout: .seconds(5)) + }, + openFile: { _ in }, + notify: { _ in }, + recordHistory: { _, _ in }, + relocateHistory: { _, _ in }, + relocateOpenDocuments: { _, _ in }, + closeDocuments: { _ in }, + processExternalChanges: { _ in false }, + reloadProjectServices: {}, + refreshGit: {}, + updateHistoryVisibilityRules: { _ in }, + onSnapshotLoaded: { _, _, _ in snapshotLoadCount += 1 } + ) + + model.beginWorkspace(at: workspace, visibilityRules: .default) + let rebuildTask = Task { + await model.rebuild( + at: workspace, + rules: .default, + isCurrent: { isCurrent } + ) + } + + #expect(await enteredRestore.waitUntilOpen(timeout: .seconds(5))) + #expect(model.appliedSnapshot != nil, "the snapshot should already be published") + isCurrent = false + releaseRestore.open() + + let result = await rebuildTask.value + if case .stale = result {} else { + Issue.record("A rebuild that lost isCurrent before the callback should report stale") + } + #expect(snapshotLoadCount == 0, "the stale rebuild must not deliver onSnapshotLoaded") + } + @Test @MainActor func capturedProjectDeletionSurvivesConfirmationDialogDismissal() async throws { @@ -3361,7 +3432,7 @@ struct EditorDocumentTests { reloadProjectServices: {}, refreshGit: { refreshCount += 1 }, updateHistoryVisibilityRules: { _ in }, - onSnapshotLoaded: { _, _ in } + onSnapshotLoaded: { _, _, _ in } ) let workspace = URL(fileURLWithPath: "/tmp/frozen-workspace") @@ -3984,7 +4055,7 @@ private func makeWorkspaceObservationUnitModel( reloadProjectServices: reloadProjectServices, refreshGit: refreshGit, updateHistoryVisibilityRules: { _ in }, - onSnapshotLoaded: { _, _ in } + onSnapshotLoaded: { _, _, _ in } ) return model } diff --git a/macos/Tests/LitheTests/RunEntryPointTests.swift b/macos/Tests/LitheTests/RunEntryPointTests.swift index 8c5a31fb..98510ebf 100644 --- a/macos/Tests/LitheTests/RunEntryPointTests.swift +++ b/macos/Tests/LitheTests/RunEntryPointTests.swift @@ -235,9 +235,177 @@ struct RunEntryPointTests { #expect(ready, "opening a project should make the run project ready") } + /// The Run panel's play buttons call `startRunConfiguration`, not + /// `runSelectedConfiguration`. That path must defer under a provisional + /// inventory and remember the concrete configuration for resume. + @Test + func startRunConfigurationDefersAndResumesAfterTheSnapshotArrives() async throws { + let workspace = try JavaWorkspaceFixture() + defer { workspace.remove() } + let workspaceOperations = GatedWorkspaceOperations(snapshot: workspace.snapshot) + let runConfigurations = ReadyRunConfigurationOperations() + let model = makeAppModel( + workspaceOperations: workspaceOperations, + runConfigurationOperations: runConfigurations + ) + let configuration = ReadyRunConfigurationOperations.entryPoint + + model.openProjectDirectly(workspace.root) + model.startRunConfiguration(configuration) + + let deferred = await awaitChange(on: model) { + model.pendingRunAction?.kind == .startConfiguration(configuration) + } + #expect(deferred, "direct start must be deferred while the inventory is provisional") + #expect( + runConfigurations.launchPlanCallCount == 0, + "direct start must not build a launch plan from a provisional inventory" + ) + + workspaceOperations.releaseSnapshot() + + let relaunched = await awaitChange(on: model) { + runConfigurations.launchPlanCallCount == 1 + } + #expect(relaunched, "the deferred direct start was never actually re-issued") + #expect(model.pendingRunAction == nil) + } + + /// The Run panel's "Run All Services" button calls `runAllServiceConfigurations`, + /// which must defer under a provisional inventory and remember that batch + /// intent — not collapse into a generic `.run`. + @Test + func runAllServicesDefersAndResumesAfterTheSnapshotArrives() async throws { + let workspace = try JavaWorkspaceFixture() + defer { workspace.remove() } + let workspaceOperations = GatedWorkspaceOperations(snapshot: workspace.snapshot) + let runConfigurations = ReadyRunConfigurationOperations() + let model = makeAppModel( + workspaceOperations: workspaceOperations, + runConfigurationOperations: runConfigurations + ) + + model.openProjectDirectly(workspace.root) + model.runAllServiceConfigurations() + + let deferred = await awaitChange(on: model) { + model.pendingRunAction?.kind == .runAllServices + } + #expect(deferred, "run-all-services must be deferred while the inventory is provisional") + #expect( + runConfigurations.launchPlanCallCount == 0, + "run-all-services must not build a launch plan from a provisional inventory" + ) + + workspaceOperations.releaseSnapshot() + + let relaunched = await awaitChange(on: model) { + runConfigurations.launchPlanCallCount == 1 + } + #expect(relaunched, "the deferred run-all-services was never actually re-issued") + #expect(model.pendingRunAction == nil) + } + + /// When ensure loads snapshot A and snapshot B is published before that load + /// finishes — and B's callback has not yet consumed into the run service — + /// generation must stop rather than scan A's still-ready inventory. + @Test + func generateRefusesStaleReadyInventoryWhenSnapshotAdvancesDuringLoad() async throws { + let workspace = try JavaWorkspaceFixture() + defer { workspace.remove() } + + let first = workspace.snapshot + let secondSource = workspace.root.appendingPathComponent("src/main/java/demo/Other.java") + try """ + package demo; + public class Other { + public static void main(String[] args) {} + } + """.write(to: secondSource, atomically: true, encoding: .utf8) + let second = WorkspaceSnapshot( + root: first.root, + files: [workspace.sourceURL, secondSource], + id: UUID() + ) + + let workspaceOperations = SequencedGatedWorkspaceOperations(snapshots: [first, second]) + let watchContext = GatedGitWatchContextProvider() + defer { watchContext.releaseAll() } + let runConfigurations = InventoryRecordingGatedRunConfigurationOperations() + defer { runConfigurations.releaseAll() } + let model = makeAppModel( + workspaceOperations: workspaceOperations, + runConfigurationOperations: runConfigurations, + gitWatchContextProvider: watchContext + ) + + model.openProjectDirectly(workspace.root) + workspaceOperations.releaseNext() + // Rebuild publishes the snapshot, then waits in updateWatchConfiguration + // before onSnapshotLoaded can start the run-service load. + #expect(await watchContext.entered(1)) + watchContext.release(1) + #expect(await runConfigurations.inspectionEntered(1)) + runConfigurations.release(1) + + let readyForFirst = await awaitChange(on: model) { + model.runFeatureIfActive?.isProjectReady( + for: workspace.root, + snapshotID: first.id + ) == true + } + #expect(readyForFirst, "the first snapshot never made the run project ready") + + let runFeature = try #require(model.runFeatureIfActive) + // Force a reload path so ensure captures the first snapshot, then waits. + let bindTask = Task { + await runFeature.loadProject( + at: workspace.root, + files: [], + mavenProject: nil, + snapshotID: nil + ) + } + #expect(await runConfigurations.inspectionEntered(2)) + runConfigurations.release(2) + await bindTask.value + #expect(runFeature.projectLoadState == .bound(workspace: workspace.root.standardizedFileURL)) + + let generateTask = Task { await model.generateRunConfigurations() } + #expect(await runConfigurations.inspectionEntered(3)) + + // Publish B, but hold its watch-context await so onSnapshotLoaded cannot + // consume B into the run service before ensure finishes loading A. + let refreshTask = Task { await model.workspaceFeature.refreshCurrent() } + workspaceOperations.releaseNext() + #expect(await watchContext.entered(2)) + let advanced = await awaitChange(on: model) { + model.workspaceSnapshotID == second.id + } + #expect(advanced, "the refreshed snapshot was never published") + + runConfigurations.release(3) + await generateTask.value + #expect(runFeature.generationState == .projectNotReady) + #expect( + runConfigurations.generatedInventories.isEmpty, + "generation must not scan the superseded inventory" + ) + #expect( + runFeature.isProjectReady(for: workspace.root, snapshotID: first.id), + "ensure should have finished loading A while B was only published" + ) + + watchContext.release(2) + #expect(await runConfigurations.inspectionEntered(4)) + runConfigurations.release(4) + _ = await refreshTask.value + } + private func makeAppModel( workspaceOperations: any WorkspaceOperations, - runConfigurationOperations: (any RunConfigurationOperations)? = nil + runConfigurationOperations: (any RunConfigurationOperations)? = nil, + gitWatchContextProvider: (any GitWatchContextProviding)? = nil ) -> AppModel { let store = RunEntryPointTestStore() let settings = AppSettings(store: store) @@ -245,7 +413,8 @@ struct RunEntryPointTests { store: store, settings: settings, workspaceOperations: workspaceOperations, - runConfigurationOperations: runConfigurationOperations + runConfigurationOperations: runConfigurationOperations, + gitWatchContextProvider: gitWatchContextProvider ).services return AppModel(settings: settings, services: services) } @@ -331,6 +500,53 @@ private final class GatedWorkspaceOperations: WorkspaceOperations, @unchecked Se } } +/// Releases workspace snapshots one at a time so a refresh can publish a newer +/// scan while an older project load is still in flight. +private final class SequencedGatedWorkspaceOperations: WorkspaceOperations, @unchecked Sendable { + private let lock = NSLock() + private var remaining: [WorkspaceSnapshot] + private let gates: [TestGate] + private var nextReleaseIndex = 0 + + init(snapshots: [WorkspaceSnapshot]) { + remaining = snapshots + gates = snapshots.map { _ in TestGate() } + } + + func releaseNext() { + lock.lock() + let index = nextReleaseIndex + nextReleaseIndex += 1 + let gate = index < gates.count ? gates[index] : nil + lock.unlock() + gate?.open() + } + + func snapshot(at rootURL: URL, visibilityRules: FileVisibilityRules) -> WorkspaceSnapshot? { + lock.lock() + let consumed = gates.count - remaining.count + let gate = consumed < gates.count ? gates[consumed] : nil + lock.unlock() + guard let gate, gate.waitSynchronously(timeout: 30) else { return nil } + lock.lock() + defer { lock.unlock() } + guard !remaining.isEmpty else { return nil } + return remaining.removeFirst() + } + + func readFile(at rootURL: URL, relativePath: String) -> String? { + try? String(contentsOf: rootURL.appendingPathComponent(relativePath), encoding: .utf8) + } + + func writeFile(_ text: String, at rootURL: URL, relativePath: String) -> Bool { + (try? text.write( + to: rootURL.appendingPathComponent(relativePath), + atomically: true, + encoding: .utf8 + )) != nil + } +} + /// Reports a workspace that already carries a configuration, which the Swift test /// binary cannot obtain from the real store because it does not link the Rust /// Core. Records launch-plan requests so a test can prove no launch was built. @@ -363,12 +579,29 @@ private final class ReadyRunConfigurationOperations: RunConfigurationOperations, mainClass: "demo.App" ) + /// A service configuration so `runAllServiceConfigurations` has something to + /// launch after a deferred resume. + static let serviceEntryPoint = RunConfiguration( + id: "spring-boot:demo.App", + name: "App (Spring Boot)", + kind: .mavenFramework(.springBoot), + execution: .service, + modulePath: nil, + mainClass: "demo.App" + ) + func resolve(at projectURL: URL, toolchainCandidates: [ProjectToolchainCandidate]) throws -> RunConfigurationResolution { RunConfigurationResolution( - configurations: [EffectiveRunConfiguration( - configuration: Self.entryPoint, - options: RunOptions() - )], + configurations: [ + EffectiveRunConfiguration( + configuration: Self.entryPoint, + options: RunOptions() + ), + EffectiveRunConfiguration( + configuration: Self.serviceEntryPoint, + options: RunOptions() + ), + ], diagnostics: [], defaultConfigurationID: Self.entryPoint.id ) @@ -480,6 +713,129 @@ private final class InspectionGatedRunConfigurationOperations: RunConfigurationO func migrateLegacySettings(at projectURL: URL, configurationIDs: [String]) throws {} } +/// Holds `updateWatchConfiguration`'s git-context fetch so a test can publish a +/// newer snapshot without letting `onSnapshotLoaded` consume it yet. +private final class GatedGitWatchContextProvider: GitWatchContextProviding, @unchecked Sendable { + private let entered: [TestGate] + private let releases: [TestGate] + private let queue = DispatchQueue(label: "lithe.tests.gated-git-watch-context") + private var calls = 0 + + init(capacity: Int = 8) { + entered = (0.. Bool { + await entered[ordinal - 1].waitUntilOpen(timeout: .seconds(5)) + } + + func release(_ ordinal: Int) { + releases[ordinal - 1].open() + } + + func releaseAll() { + releases.forEach { $0.open() } + } + + func watchContext(for workspace: URL) async -> GitWatchContext? { + let ordinal: Int = await withCheckedContinuation { continuation in + queue.async { + self.calls += 1 + continuation.resume(returning: self.calls) + } + } + entered[ordinal - 1].open() + _ = await releases[ordinal - 1].waitUntilOpen(timeout: .seconds(30)) + return nil + } +} + +/// Like `InspectionGatedRunConfigurationOperations`, but also records every +/// generate inventory so a superseded-snapshot test can prove generation never +/// scanned the stale file list. +private final class InventoryRecordingGatedRunConfigurationOperations: RunConfigurationOperations, @unchecked Sendable { + private let entered: [TestGate] + private let releases: [TestGate] + private let lock = NSLock() + private var inspectCalls = 0 + private var launchPlanCalls = 0 + private var inventories: [[URL]] = [] + + init(capacity: Int = 8) { + entered = (0.. Bool { + await entered[ordinal - 1].waitUntilOpen(timeout: .seconds(5)) + } + + func release(_ ordinal: Int) { + releases[ordinal - 1].open() + } + + func releaseAll() { + releases.forEach { $0.open() } + } + + func inspect(at projectURL: URL) -> ProjectRunConfigurationInspection { + lock.lock() + inspectCalls += 1 + let ordinal = inspectCalls + lock.unlock() + entered[ordinal - 1].open() + _ = releases[ordinal - 1].waitSynchronously(timeout: 30) + return ProjectRunConfigurationInspection(status: .ready, diagnostics: []) + } + + func generate(at projectURL: URL, files: [URL], modulePaths: [String]) throws -> RunConfigurationGenerationResult { + lock.lock() + inventories.append(files) + lock.unlock() + return RunConfigurationGenerationResult(entryCount: files.count) + } + + func resolve(at projectURL: URL, toolchainCandidates: [ProjectToolchainCandidate]) throws -> RunConfigurationResolution { + RunConfigurationResolution( + configurations: [EffectiveRunConfiguration( + configuration: ReadyRunConfigurationOperations.entryPoint, + options: RunOptions() + )], + diagnostics: [], + defaultConfigurationID: ReadyRunConfigurationOperations.entryPoint.id + ) + } + + func launchPlan( + at projectURL: URL, + configurationID: String, + currentFile: String?, + classPath: String?, + debugPort: Int? + ) throws -> SharedLaunchPlan { + lock.lock() + launchPlanCalls += 1 + lock.unlock() + throw RunConfigurationOperationFailure(message: "Launching is out of scope for this test") + } + + func createConfiguration(_ draft: RunConfigurationDraft, at projectURL: URL) throws -> String { draft.name } + func migrateLegacySettings(at projectURL: URL, configurationIDs: [String]) throws {} +} + private final class RunEntryPointTestStore: KeyValueStore, @unchecked Sendable { private var values: [String: Any] = [:] From 82169547e1df4be74a00033c2074357891921c85 Mon Sep 17 00:00:00 2001 From: fenghp Date: Sat, 29 Aug 2026 18:50:39 +0800 Subject: [PATCH 09/66] =?UTF-8?q?fix(macos):=20Restart=20=E4=B8=8E?= =?UTF-8?q?=E8=B7=A8=E5=B7=A5=E7=A8=8B=E5=85=A5=E5=8F=A3=E5=85=B1=E7=94=A8?= =?UTF-8?q?=E5=B0=B1=E7=BB=AA=E6=BC=8F=E6=96=97=E5=B9=B6=E4=BF=9D=E6=8A=A4?= =?UTF-8?q?=20pending?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restart 走 ensureRunProjectReady/pending 恢复;入口捕获 workspace 区分 stale 与 waiting,避免旧任务 defer 到新工程或清掉对方 pending。 --- .../AppModel/AppModel+Development.swift | 208 +++++++++--- .../Lithe/Models/AppModel/AppModel.swift | 2 + .../Execution/RunConfigurationContracts.swift | 12 + .../Application/ExecutionFeatureModels.swift | 4 + .../Services/RunService.swift | 7 + .../Tests/LitheTests/RunEntryPointTests.swift | 320 ++++++++++++++---- 6 files changed, 439 insertions(+), 114 deletions(-) diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift index cd638dd5..025ecd4d 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift @@ -15,12 +15,24 @@ struct PendingRunAction: Equatable { case debug case startConfiguration(RunConfiguration) case runAllServices + case restart } let kind: Kind let workspace: URL } +/// Result of bringing the run feature up to a specific workspace's snapshot. +/// +/// Distinguishing "still waiting on this workspace" from "this entry task is +/// stale" is what stops a cross-workspace await from re-recording the old +/// action against the new project's pending slot. +private enum RunProjectReadiness: Equatable { + case ready + case waitingForSnapshot(workspace: URL) + case stale +} + @MainActor extension AppModel { func toggleSpringEndpoints() { @@ -169,14 +181,19 @@ extension AppModel { /// inventory, and scanning it would overwrite `generated.json` with stale /// entry points. func generateRunConfigurations() async { + guard let workspace = workspaceURL?.standardizedFileURL else { return } guard let runFeature = await activateExecutionModule()?.runFeature else { return } - // Report the pending workspace through the generation state when the - // snapshot has not arrived, which the run panel surfaces as a notice. - guard await ensureRunProjectReady(runFeature) else { + guard isCurrentWorkspace(workspace) else { return } + switch await ensureRunProjectReady(runFeature, for: workspace) { + case .ready: + await runFeature.generateRunConfigurations() + case .waitingForSnapshot: + // Report the pending workspace through the generation state when the + // snapshot has not arrived, which the run panel surfaces as a notice. runFeature.reportGenerationProjectNotReady() + case .stale: return } - await runFeature.generateRunConfigurations() } func openRunConfiguration(relativePath: String?) { @@ -221,48 +238,75 @@ extension AppModel { snapshotID: UUID?, resumesDeferredRunAction: Bool = false ) async { + let target = workspaceURL.standardizedFileURL + guard isCurrentWorkspace(target) else { return } prepareJavaLanguageServerForWorkspaceIfNeeded( - at: workspaceURL, + at: target, files: files ) springFeature.scheduleLoad( - workspaceURL: workspaceURL, + workspaceURL: target, files: files, textOverrides: Dictionary(uniqueKeysWithValues: openDocuments.map { ($0.url.standardizedFileURL, $0.text) }) ) guard let execution = await activateExecutionModule() else { return } - execution.tests.discover(workspaceURL: workspaceURL, files: files) + // Module activation suspends; a project switch must not let this load + // write the captured inventory into the new workspace's run service. + guard isCurrentWorkspace(target) else { return } + execution.tests.discover(workspaceURL: target, files: files) // `files` and `snapshotID` are captured together by the caller. Reading // the applied snapshot here instead would pair this file list with a // newer scan's identity, which the readiness comparison cannot detect. await execution.projectDevelopment.loadProject( - at: workspaceURL, + at: target, files: files, snapshotID: snapshotID ) + guard isCurrentWorkspace(target) else { return } guard resumesDeferredRunAction else { return } resumeDeferredRunAction( execution.runFeature, - workspace: workspaceURL, + workspace: target, snapshotID: snapshotID ) } - /// Brings the run feature up to the workspace snapshot the workspace feature - /// currently holds, and reports whether it got there. + private func isCurrentWorkspace(_ workspace: URL) -> Bool { + workspaceURL?.standardizedFileURL == workspace.standardizedFileURL + } + + /// Brings the run feature up to the snapshot for a captured workspace. /// - /// Run, Debug, and identification all scan or launch from the file inventory - /// the run service holds, so each of them needs the inventory to match the - /// current snapshot rather than merely being bound to the workspace. - private func ensureRunProjectReady(_ runFeature: RunFeatureModel) async -> Bool { - guard let workspaceURL else { return false } + /// Entry tasks capture the workspace before any await so a cross-project + /// switch can be reported as `.stale` instead of being re-deferred against + /// whatever URL is current when the load finishes. + /// + /// When a newer snapshot is published but the run service still holds an + /// older `.ready` inventory for this workspace, the snapshot callback owns + /// the transition. Loading from the entry path would race that callback and + /// let Restart proceed from a half-applied refresh. + /// + /// When the run service is not already ready for this workspace, the entry + /// path applies the published scan itself (open-before-run, prune, tool + /// window) so readiness does not wait on a callback that may never arrive. + private func ensureRunProjectReady( + _ runFeature: RunFeatureModel, + for workspace: URL + ) async -> RunProjectReadiness { + let target = workspace.standardizedFileURL + guard isCurrentWorkspace(target) else { return .stale } // One read, so the file list and the identity describe the same scan. let applied = workspaceFeature.appliedSnapshot - if runFeature.isProjectReady(for: workspaceURL, snapshotID: applied?.id) { return true } + if runFeature.isProjectReady(for: target, snapshotID: applied?.id) { return .ready } + if applied != nil, runFeature.hasReadyInventory(for: target) { + return .waitingForSnapshot(workspace: target) + } + // No matching ready inventory: bind provisionally, or apply the + // published scan when one already exists. await loadProjectServices( - at: workspaceURL, + at: target, files: applied?.files ?? [], snapshotID: applied?.id ) @@ -270,16 +314,21 @@ extension AppModel { // flight, including its deferred-run resume with nothing pending yet. // Comparing against the pre-await capture would then treat a ready // project as not ready, defer the action, and leave it stranded. + guard isCurrentWorkspace(target) else { return .stale } let current = workspaceFeature.appliedSnapshot - return runFeature.isProjectReady(for: workspaceURL, snapshotID: current?.id) + if runFeature.isProjectReady(for: target, snapshotID: current?.id) { + return .ready + } + return .waitingForSnapshot(workspace: target) } /// Continues an action that arrived before the snapshot did. The workspace /// rebuild always finishes by applying a snapshot, so recording the intent is /// enough to resume without polling or waiting. /// - /// The action is dropped when the applied snapshot belongs to a different - /// workspace, which is the case the user is no longer waiting on. + /// A load for workspace A must not clear a pending action that belongs to B: + /// openProject already cleared A's pending on the switch, and a stale A + /// callback arriving later would otherwise wipe B's newly recorded intent. /// `snapshotID` is the scan this load just applied, not whatever the /// workspace holds now. Reading the current identity here would compare the /// run feature against a scan it has not consumed. @@ -289,43 +338,55 @@ extension AppModel { snapshotID: UUID? ) { guard let action = pendingRunAction else { return } - guard action.workspace == workspace.standardizedFileURL else { - pendingRunAction = nil - return - } + guard action.workspace == workspace.standardizedFileURL else { return } guard runFeature.isProjectReady(for: workspace, snapshotID: snapshotID) else { return } - pendingRunAction = nil + setPendingRunAction(nil) switch action.kind { case .run: runSelectedConfiguration() case .debug: startDebugging() case .startConfiguration(let configuration): startRunConfiguration(configuration) case .runAllServices: runAllServiceConfigurations() + case .restart: restartSelectedRun() } } - /// Records an action the current workspace snapshot is not ready for. - private func deferRunAction(_ kind: PendingRunAction.Kind) { - guard let workspaceURL else { - pendingRunAction = nil - return - } - pendingRunAction = PendingRunAction( - kind: kind, - workspace: workspaceURL.standardizedFileURL - ) + /// Records an action for the workspace the entry task captured, not whatever + /// URL happens to be current after an await. + private func clearPendingRunAction(for workspace: URL) { + guard pendingRunAction?.workspace == workspace.standardizedFileURL else { return } + setPendingRunAction(nil) + } + + private func setPendingRunAction(_ action: PendingRunAction?) { + pendingRunAction = action + // pendingRunAction is not @Published; relay so tests and any UI that + // observes AppModel learn about defer/resume without a feature load. + scheduleObjectWillChangeRelay() + } + + private func deferRunAction(_ kind: PendingRunAction.Kind, for workspace: URL) { + let target = workspace.standardizedFileURL + guard isCurrentWorkspace(target) else { return } + setPendingRunAction(PendingRunAction(kind: kind, workspace: target)) } private func runSelectedConfigurationAfterActivation() async { + guard let workspace = workspaceURL?.standardizedFileURL else { return } guard let runFeature = await activateExecutionModule()?.runFeature else { return } - guard await ensureRunProjectReady(runFeature) else { + guard isCurrentWorkspace(workspace) else { return } + switch await ensureRunProjectReady(runFeature, for: workspace) { + case .ready: + clearPendingRunAction(for: workspace) + case .waitingForSnapshot(let waitingWorkspace): // Launching from a provisional inventory resolves toolchains without // the Maven project, so wait for the snapshot instead of running. - deferRunAction(.run) + deferRunAction(.run, for: waitingWorkspace) + return + case .stale: return } - pendingRunAction = nil guard runFeature.configurationStatus == .ready else { runFeature.requestRunConfigurationGeneration(intent: .run) return @@ -338,6 +399,7 @@ extension AppModel { )) { return } + guard isCurrentWorkspace(workspace) else { return } if configuration.usesCurrentEditorFile, let activeDocument, activeDocument.isDirty { @@ -350,6 +412,7 @@ extension AppModel { return } } + guard isCurrentWorkspace(workspace) else { return } runFeature.runSelected(currentFileURL: activeDocument?.url) isRunVisible = true isGitLogVisible = false @@ -362,8 +425,20 @@ extension AppModel { func restartSelectedRun() { isRunVisible = true Task { [weak self] in - guard let self, - let runFeature = await activateExecutionModule()?.runFeature else { return } + guard let self else { return } + guard let workspace = workspaceURL?.standardizedFileURL else { return } + guard let runFeature = await activateExecutionModule()?.runFeature else { return } + guard isCurrentWorkspace(workspace) else { return } + guard runFeature.lastConfiguration != nil else { return } + switch await ensureRunProjectReady(runFeature, for: workspace) { + case .ready: + clearPendingRunAction(for: workspace) + case .waitingForSnapshot(let waitingWorkspace): + deferRunAction(.restart, for: waitingWorkspace) + return + case .stale: + return + } guard let configuration = runFeature.lastConfiguration else { return } if !(await activateLanguageRunExtensionIfNeeded( for: configuration, @@ -372,46 +447,63 @@ extension AppModel { )) { return } + guard isCurrentWorkspace(workspace) else { return } runFeature.restart() } } func startRunConfiguration(_ configuration: RunConfiguration) { Task { [weak self] in - guard let self, - let runFeature = await activateExecutionModule()?.runFeature else { return } - guard await ensureRunProjectReady(runFeature) else { + guard let self else { return } + guard let workspace = workspaceURL?.standardizedFileURL else { return } + guard let runFeature = await activateExecutionModule()?.runFeature else { return } + guard isCurrentWorkspace(workspace) else { return } + switch await ensureRunProjectReady(runFeature, for: workspace) { + case .ready: + clearPendingRunAction(for: workspace) + case .waitingForSnapshot(let waitingWorkspace): // Direct play buttons reach here without going through // `runSelectedConfiguration`, so they need the same readiness - // gate and must remember which configuration to resume. - deferRunAction(.startConfiguration(configuration)) + // gate and must remember which configuration to resume — bound + // to the workspace this task started for, not the URL after an + // await. + deferRunAction(.startConfiguration(configuration), for: waitingWorkspace) + return + case .stale: return } - pendingRunAction = nil guard await activateLanguageRunExtensionIfNeeded( for: configuration, currentFileURL: activeDocument?.url, runFeature: runFeature ) else { return } + guard isCurrentWorkspace(workspace) else { return } runFeature.startConfiguration(configuration) } } func runAllServiceConfigurations() { Task { [weak self] in - guard let self, - let runFeature = await activateExecutionModule()?.runFeature else { return } - guard await ensureRunProjectReady(runFeature) else { - deferRunAction(.runAllServices) + guard let self else { return } + guard let workspace = workspaceURL?.standardizedFileURL else { return } + guard let runFeature = await activateExecutionModule()?.runFeature else { return } + guard isCurrentWorkspace(workspace) else { return } + switch await ensureRunProjectReady(runFeature, for: workspace) { + case .ready: + clearPendingRunAction(for: workspace) + case .waitingForSnapshot(let waitingWorkspace): + deferRunAction(.runAllServices, for: waitingWorkspace) + return + case .stale: return } - pendingRunAction = nil for configuration in runFeature.configurations where configuration.execution == .service { guard await activateLanguageRunExtensionIfNeeded( for: configuration, currentFileURL: nil, runFeature: runFeature ) else { return } + guard isCurrentWorkspace(workspace) else { return } } runFeature.runAllServices() } @@ -506,14 +598,20 @@ extension AppModel { } private func startDebuggingAfterActivation() async { + guard let workspace = workspaceURL?.standardizedFileURL else { return } guard let execution = await activateExecutionModule(), let debug = await activateDebugModule() else { return } + guard isCurrentWorkspace(workspace) else { return } let runFeature = execution.runFeature - guard await ensureRunProjectReady(runFeature) else { - deferRunAction(.debug) + switch await ensureRunProjectReady(runFeature, for: workspace) { + case .ready: + clearPendingRunAction(for: workspace) + case .waitingForSnapshot(let waitingWorkspace): + deferRunAction(.debug, for: waitingWorkspace) + return + case .stale: return } - pendingRunAction = nil let debugFeature = debug.javaFeature javaFeature.configureRuntime( mavenFeature: execution.mavenFeature, diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index f65016af..05e7b2f3 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -925,6 +925,7 @@ final class AppModel: ObservableObject, Identifiable { mavenFeatureIfActive?.reset() runFeatureIfActive?.reset() pendingRunAction = nil + scheduleObjectWillChangeRelay() debugFeatureIfActive?.reset() genericDebugFeatureIfActive?.reset() clearLanguageNavigationProjection() @@ -1036,6 +1037,7 @@ final class AppModel: ObservableObject, Identifiable { mavenFeatureIfActive?.reset() runFeatureIfActive?.reset() pendingRunAction = nil + scheduleObjectWillChangeRelay() debugFeatureIfActive?.reset() genericDebugFeatureIfActive?.reset() javaFeature.stop() diff --git a/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift b/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift index b05cf9b1..dd6c3334 100644 --- a/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift +++ b/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift @@ -84,6 +84,18 @@ package enum ProjectLoadState: Equatable, Sendable { else { return false } return boundWorkspace == workspace.standardizedFileURL && boundSnapshotID == snapshotID } + + /// Whether a complete inventory for `workspace` is already applied, whichever + /// snapshot produced it. + /// + /// A refresh publishes its snapshot before this state consumes it, so in that + /// window the inventory is complete but superseded. Distinguishing it from a + /// provisional or foreign binding is what lets a caller leave the transition + /// to the snapshot callback instead of loading the scan itself. + package func hasReadyInventory(for workspace: URL) -> Bool { + guard case .ready(let boundWorkspace, _) = self else { return false } + return boundWorkspace == workspace.standardizedFileURL + } } package enum RunConfigurationGenerationState: Equatable, Sendable { diff --git a/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift b/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift index 0cda6c48..0a0afffd 100644 --- a/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift +++ b/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift @@ -165,6 +165,10 @@ package final class RunFeatureModel: ObservableObject { service.isProjectReady(for: workspace, snapshotID: snapshotID) } + package func hasReadyInventory(for workspace: URL) -> Bool { + service.hasReadyInventory(for: workspace) + } + package func reportGenerationProjectNotReady() { isGenerationConfirmationPresented = false service.reportGenerationProjectNotReady() diff --git a/macos/Sources/LitheExecutionModule/Services/RunService.swift b/macos/Sources/LitheExecutionModule/Services/RunService.swift index d97df864..a1b7f4fc 100644 --- a/macos/Sources/LitheExecutionModule/Services/RunService.swift +++ b/macos/Sources/LitheExecutionModule/Services/RunService.swift @@ -114,6 +114,13 @@ package final class RunService: ObservableObject { projectLoadState.isReady(for: workspace, snapshotID: snapshotID) } + /// Whether a complete inventory for `workspace` is already loaded, even if a + /// newer snapshot has since been published. Entry points use this to tell a + /// superseded inventory apart from one that was never loaded. + package func hasReadyInventory(for workspace: URL) -> Bool { + projectLoadState.hasReadyInventory(for: workspace) + } + /// Surfaces the "project still loading" generation notice without scanning. /// /// AppModel uses this when readiness cannot be established for the current diff --git a/macos/Tests/LitheTests/RunEntryPointTests.swift b/macos/Tests/LitheTests/RunEntryPointTests.swift index 98510ebf..5ec817c8 100644 --- a/macos/Tests/LitheTests/RunEntryPointTests.swift +++ b/macos/Tests/LitheTests/RunEntryPointTests.swift @@ -25,7 +25,7 @@ struct RunEntryPointTests { // The entry point binds the workspace so existing configuration is // readable, but the pending snapshot must keep generation out. - let bound = await awaitChange(on: model) { + let bound = await awaitLoadDrivenChange(on: model) { model.runFeatureIfActive?.projectLoadState == .bound(workspace: workspace.root.standardizedFileURL) } #expect(bound, "the Run entry point never bound the workspace") @@ -38,7 +38,7 @@ struct RunEntryPointTests { operations.releaseSnapshot() - let ready = await awaitChange(on: model) { + let ready = await awaitLoadDrivenChange(on: model) { model.runFeatureIfActive?.isProjectReady( for: workspace.root, snapshotID: model.workspaceSnapshotID @@ -64,19 +64,19 @@ struct RunEntryPointTests { model.openProjectDirectly(workspace.root) model.runSelectedConfiguration() - let deferred = await awaitChange(on: model) { model.pendingRunAction?.kind == .run } + let deferred = await awaitLoadDrivenChange(on: model) { model.pendingRunAction?.kind == .run } #expect(deferred, "Run must be deferred while the inventory is provisional") operations.releaseSnapshot() - let ready = await awaitChange(on: model) { + let ready = await awaitLoadDrivenChange(on: model) { model.runFeatureIfActive?.isProjectReady( for: workspace.root, snapshotID: model.workspaceSnapshotID ) == true } #expect(ready, "the applied snapshot never made the run project ready") - let resumed = await awaitChange(on: model) { model.pendingRunAction == nil } + let resumed = await awaitLoadDrivenChange(on: model) { model.pendingRunAction == nil } #expect(resumed, "the deferred Run was never resumed") } @@ -98,7 +98,7 @@ struct RunEntryPointTests { model.openProjectDirectly(workspace.root) model.runSelectedConfiguration() - let deferred = await awaitChange(on: model) { model.pendingRunAction?.kind == .run } + let deferred = await awaitLoadDrivenChange(on: model) { model.pendingRunAction?.kind == .run } #expect(deferred, "Run must be deferred while the file inventory is provisional") let runFeature = try #require(model.runFeatureIfActive) #expect( @@ -115,9 +115,7 @@ struct RunEntryPointTests { // Production clears the deferred action before it re-issues Run, so // waiting for the action to clear would pass even if the relaunch were // dropped. The launch plan request is what proves Run actually ran. - let relaunched = await awaitChange(on: model) { - runConfigurations.launchPlanCallCount == 1 - } + let relaunched = await runConfigurations.launchPlanRequested(1) #expect(relaunched, "the deferred Run was never actually re-issued") #expect(model.pendingRunAction == nil) #expect( @@ -162,7 +160,7 @@ struct RunEntryPointTests { "the snapshot-driven load never started" ) runConfigurations.release(2) - let ready = await awaitChange(on: model) { + let ready = await awaitLoadDrivenChange(on: model) { model.runFeatureIfActive?.isProjectReady( for: workspace.root, snapshotID: model.workspaceSnapshotID @@ -171,7 +169,7 @@ struct RunEntryPointTests { #expect(ready, "the snapshot-driven load never made the run project ready") // Wait for the snapshot-driven load to finish completely, so its resume // point has already run and found nothing deferred. - let snapshotLoadFinished = await awaitChange(on: model) { + let snapshotLoadFinished = await awaitLoadDrivenChange(on: model) { model.runFeatureIfActive?.isLoadingProject == false && runConfigurations.resolveCallCount == 1 } @@ -179,9 +177,7 @@ struct RunEntryPointTests { runConfigurations.release(1) - let relaunched = await awaitChange(on: model) { - runConfigurations.launchPlanCallCount == 1 - } + let relaunched = await runConfigurations.launchPlanRequested(1) #expect(relaunched, "Run was neither launched nor resumed after the snapshot landed") #expect(model.pendingRunAction == nil) } @@ -197,7 +193,7 @@ struct RunEntryPointTests { model.openProjectDirectly(workspace.root) model.startDebugging() - let bound = await awaitChange(on: model) { + let bound = await awaitLoadDrivenChange(on: model) { model.runFeatureIfActive?.projectLoadState == .bound(workspace: workspace.root.standardizedFileURL) } #expect(bound, "the Debug entry point never bound the workspace") @@ -205,7 +201,7 @@ struct RunEntryPointTests { operations.releaseSnapshot() - let ready = await awaitChange(on: model) { + let ready = await awaitLoadDrivenChange(on: model) { model.runFeatureIfActive?.isProjectReady( for: workspace.root, snapshotID: model.workspaceSnapshotID @@ -226,7 +222,7 @@ struct RunEntryPointTests { model.openProjectDirectly(workspace.root) - let ready = await awaitChange(on: model) { + let ready = await awaitLoadDrivenChange(on: model) { model.runFeatureIfActive?.isProjectReady( for: workspace.root, snapshotID: model.workspaceSnapshotID @@ -253,7 +249,7 @@ struct RunEntryPointTests { model.openProjectDirectly(workspace.root) model.startRunConfiguration(configuration) - let deferred = await awaitChange(on: model) { + let deferred = await awaitLoadDrivenChange(on: model) { model.pendingRunAction?.kind == .startConfiguration(configuration) } #expect(deferred, "direct start must be deferred while the inventory is provisional") @@ -264,9 +260,7 @@ struct RunEntryPointTests { workspaceOperations.releaseSnapshot() - let relaunched = await awaitChange(on: model) { - runConfigurations.launchPlanCallCount == 1 - } + let relaunched = await runConfigurations.launchPlanRequested(1) #expect(relaunched, "the deferred direct start was never actually re-issued") #expect(model.pendingRunAction == nil) } @@ -288,7 +282,7 @@ struct RunEntryPointTests { model.openProjectDirectly(workspace.root) model.runAllServiceConfigurations() - let deferred = await awaitChange(on: model) { + let deferred = await awaitLoadDrivenChange(on: model) { model.pendingRunAction?.kind == .runAllServices } #expect(deferred, "run-all-services must be deferred while the inventory is provisional") @@ -299,16 +293,155 @@ struct RunEntryPointTests { workspaceOperations.releaseSnapshot() - let relaunched = await awaitChange(on: model) { - runConfigurations.launchPlanCallCount == 1 - } + let relaunched = await runConfigurations.launchPlanRequested(1) #expect(relaunched, "the deferred run-all-services was never actually re-issued") #expect(model.pendingRunAction == nil) } - /// When ensure loads snapshot A and snapshot B is published before that load - /// finishes — and B's callback has not yet consumed into the run service — - /// generation must stop rather than scan A's still-ready inventory. + /// Restart must use the same readiness funnel as direct start. A published + /// but not-yet-consumed refresh still leaves the run service on the old + /// inventory; restarting then would rebuild a launch plan from that stale + /// scan. + @Test + func restartDefersWhenANewerSnapshotIsPublishedButNotYetConsumed() async throws { + let workspace = try JavaWorkspaceFixture() + defer { workspace.remove() } + + let first = workspace.snapshot + let secondSource = workspace.root.appendingPathComponent("src/main/java/demo/Other.java") + try """ + package demo; + public class Other { + public static void main(String[] args) {} + } + """.write(to: secondSource, atomically: true, encoding: .utf8) + let second = WorkspaceSnapshot( + root: first.root, + files: [workspace.sourceURL, secondSource], + id: UUID() + ) + + let workspaceOperations = SequencedGatedWorkspaceOperations(snapshots: [first, second]) + let watchContext = GatedGitWatchContextProvider() + defer { watchContext.releaseAll() } + let runConfigurations = ReadyRunConfigurationOperations() + let model = makeAppModel( + workspaceOperations: workspaceOperations, + runConfigurationOperations: runConfigurations, + gitWatchContextProvider: watchContext + ) + + // Establish lastConfiguration through the same deferred-run path the + // existing entry tests already cover, then refresh to a newer snapshot + // without letting the run service consume it. + model.openProjectDirectly(workspace.root) + model.runSelectedConfiguration() + let deferredRun = await awaitLoadDrivenChange(on: model) { model.pendingRunAction?.kind == .run } + #expect(deferredRun, "the initial run must defer until the first snapshot arrives") + + workspaceOperations.releaseNext() + #expect(await watchContext.entered(1)) + watchContext.release(1) + let launched = await runConfigurations.launchPlanRequested(1) + #expect(launched, "the initial run never requested a launch plan") + #expect(model.runFeatureIfActive?.lastConfiguration != nil) + #expect(model.pendingRunAction == nil) + + let refreshTask = Task { await model.workspaceFeature.refreshCurrent() } + workspaceOperations.releaseNext() + #expect(await watchContext.entered(2)) + let advanced = await awaitLoadDrivenChange(on: model) { + model.workspaceSnapshotID == second.id + } + #expect(advanced, "the refreshed snapshot was never published") + #expect( + model.runFeatureIfActive?.isProjectReady(for: workspace.root, snapshotID: first.id) == true, + "the run service should still hold the first snapshot while B's callback is held" + ) + + model.restartSelectedRun() + let deferredRestart = await awaitLoadDrivenChange(on: model) { + model.pendingRunAction?.kind == .restart + } + #expect(deferredRestart, "Restart must defer while the newer snapshot is unpublished to the run service") + #expect( + runConfigurations.launchPlanCallCount == 1, + "Restart must not rebuild a launch plan from the superseded inventory" + ) + + watchContext.release(2) + let relaunched = await runConfigurations.launchPlanRequested(2) + #expect(relaunched, "the deferred Restart was never actually re-issued") + #expect(model.pendingRunAction == nil) + _ = await refreshTask.value + } + + /// An entry task that started for workspace A must not re-record its action + /// against B after a project switch, and must not wipe B's own pending. + @Test + func directStartFromTheOldWorkspaceIsNotDeferredIntoTheNewWorkspace() async throws { + let workspaceA = try JavaWorkspaceFixture() + let workspaceB = try JavaWorkspaceFixture() + defer { + workspaceA.remove() + workspaceB.remove() + } + + let workspaceOperations = MultiRootGatedWorkspaceOperations(snapshotsByRoot: [ + workspaceA.root: workspaceA.snapshot, + workspaceB.root: workspaceB.snapshot, + ]) + defer { + workspaceOperations.release(workspaceA.root) + workspaceOperations.release(workspaceB.root) + } + // Hold both scans until the test decides so B's direct start defers. + let runConfigurations = InspectionGatedRunConfigurationOperations() + defer { runConfigurations.releaseAll() } + let model = makeAppModel( + workspaceOperations: workspaceOperations, + runConfigurationOperations: runConfigurations + ) + let configurationA = ReadyRunConfigurationOperations.entryPoint + let configurationB = ReadyRunConfigurationOperations.serviceEntryPoint + + model.openProjectDirectly(workspaceA.root) + model.startRunConfiguration(configurationA) + #expect(await runConfigurations.inspectionEntered(1)) + + model.openProjectDirectly(workspaceB.root) + #expect(model.pendingRunAction == nil, "opening B must clear A's pending") + + // Keep B's snapshot gated so the direct start defers for B itself. + model.startRunConfiguration(configurationB) + #expect(await runConfigurations.inspectionEntered(2)) + runConfigurations.release(2) + let deferredForB = await awaitLoadDrivenChange(on: model) { + model.pendingRunAction?.kind == .startConfiguration(configurationB) + && model.pendingRunAction?.workspace == workspaceB.root.standardizedFileURL + } + #expect(deferredForB, "B should record its own deferred direct start") + + // A's in-flight ensure finishes after the switch. It must be treated as + // stale: no re-defer against B, and B's pending must survive. + runConfigurations.release(1) + let corruptedByStaleA = await awaitChange(on: model, timeout: .seconds(1)) { + model.pendingRunAction?.kind == .startConfiguration(configurationA) + } + #expect( + !corruptedByStaleA, + "a stale entry task for A must not re-defer its configuration onto B" + ) + #expect( + model.pendingRunAction?.kind == .startConfiguration(configurationB) + && model.pendingRunAction?.workspace == workspaceB.root.standardizedFileURL, + "B's pending action must survive the stale A task finishing" + ) + } + + /// When snapshot B is already published but its callback has not consumed it + /// into the run service, generation must stop rather than scan the still-ready + /// A inventory. @Test func generateRefusesStaleReadyInventoryWhenSnapshotAdvancesDuringLoad() async throws { let workspace = try JavaWorkspaceFixture() @@ -341,14 +474,12 @@ struct RunEntryPointTests { model.openProjectDirectly(workspace.root) workspaceOperations.releaseNext() - // Rebuild publishes the snapshot, then waits in updateWatchConfiguration - // before onSnapshotLoaded can start the run-service load. #expect(await watchContext.entered(1)) watchContext.release(1) #expect(await runConfigurations.inspectionEntered(1)) runConfigurations.release(1) - let readyForFirst = await awaitChange(on: model) { + let readyForFirst = await awaitLoadDrivenChange(on: model) { model.runFeatureIfActive?.isProjectReady( for: workspace.root, snapshotID: first.id @@ -357,48 +488,28 @@ struct RunEntryPointTests { #expect(readyForFirst, "the first snapshot never made the run project ready") let runFeature = try #require(model.runFeatureIfActive) - // Force a reload path so ensure captures the first snapshot, then waits. - let bindTask = Task { - await runFeature.loadProject( - at: workspace.root, - files: [], - mavenProject: nil, - snapshotID: nil - ) - } - #expect(await runConfigurations.inspectionEntered(2)) - runConfigurations.release(2) - await bindTask.value - #expect(runFeature.projectLoadState == .bound(workspace: workspace.root.standardizedFileURL)) - - let generateTask = Task { await model.generateRunConfigurations() } - #expect(await runConfigurations.inspectionEntered(3)) - - // Publish B, but hold its watch-context await so onSnapshotLoaded cannot - // consume B into the run service before ensure finishes loading A. let refreshTask = Task { await model.workspaceFeature.refreshCurrent() } workspaceOperations.releaseNext() #expect(await watchContext.entered(2)) - let advanced = await awaitChange(on: model) { + let advanced = await awaitLoadDrivenChange(on: model) { model.workspaceSnapshotID == second.id } #expect(advanced, "the refreshed snapshot was never published") + #expect( + runFeature.isProjectReady(for: workspace.root, snapshotID: first.id), + "the run service should still hold A while B's callback is held" + ) - runConfigurations.release(3) - await generateTask.value + await model.generateRunConfigurations() #expect(runFeature.generationState == .projectNotReady) #expect( runConfigurations.generatedInventories.isEmpty, "generation must not scan the superseded inventory" ) - #expect( - runFeature.isProjectReady(for: workspace.root, snapshotID: first.id), - "ensure should have finished loading A while B was only published" - ) watchContext.release(2) - #expect(await runConfigurations.inspectionEntered(4)) - runConfigurations.release(4) + #expect(await runConfigurations.inspectionEntered(2)) + runConfigurations.release(2) _ = await refreshTask.value } @@ -420,6 +531,19 @@ struct RunEntryPointTests { } } +/// Awaits a state this suite reaches only after a full snapshot-driven load — +/// watch configuration, project load, toolchain resolution, and the deferred +/// resume that follows. That pipeline is far longer than a single publication, +/// so these waits carry the same deadline as the file's cross-load gates +/// instead of the shared default a failing test would otherwise hit first. +@MainActor +private func awaitLoadDrivenChange( + on model: AppModel, + until isSatisfied: @escaping @MainActor @Sendable () -> Bool +) async -> Bool { + await awaitChange(on: model, timeout: .seconds(30), until: isSatisfied) +} + /// A real workspace on disk holding one Java entry point, so generation runs /// through the shared Core instead of a stubbed result. @MainActor @@ -547,12 +671,63 @@ private final class SequencedGatedWorkspaceOperations: WorkspaceOperations, @unc } } +/// Holds each workspace root's snapshot behind its own gate so a test can keep +/// an old project's scan suspended while a new project opens. +private final class MultiRootGatedWorkspaceOperations: WorkspaceOperations, @unchecked Sendable { + private let lock = NSLock() + private var snapshotsByRoot: [String: WorkspaceSnapshot] + private var gatesByRoot: [String: TestGate] + + init(snapshotsByRoot: [URL: WorkspaceSnapshot]) { + var snapshots: [String: WorkspaceSnapshot] = [:] + var gates: [String: TestGate] = [:] + for (root, snapshot) in snapshotsByRoot { + let key = root.standardizedFileURL.path + snapshots[key] = snapshot + gates[key] = TestGate() + } + self.snapshotsByRoot = snapshots + self.gatesByRoot = gates + } + + func release(_ root: URL) { + gatesByRoot[root.standardizedFileURL.path]?.open() + } + + func snapshot(at rootURL: URL, visibilityRules: FileVisibilityRules) -> WorkspaceSnapshot? { + let key = rootURL.standardizedFileURL.path + lock.lock() + let gate = gatesByRoot[key] + let snapshot = snapshotsByRoot[key] + lock.unlock() + guard let gate, gate.waitSynchronously(timeout: 30) else { return nil } + return snapshot + } + + func readFile(at rootURL: URL, relativePath: String) -> String? { + try? String(contentsOf: rootURL.appendingPathComponent(relativePath), encoding: .utf8) + } + + func writeFile(_ text: String, at rootURL: URL, relativePath: String) -> Bool { + (try? text.write( + to: rootURL.appendingPathComponent(relativePath), + atomically: true, + encoding: .utf8 + )) != nil + } +} + /// Reports a workspace that already carries a configuration, which the Swift test /// binary cannot obtain from the real store because it does not link the Rust /// Core. Records launch-plan requests so a test can prove no launch was built. private final class ReadyRunConfigurationOperations: RunConfigurationOperations, @unchecked Sendable { private let lock = NSLock() private var launchPlanCalls = 0 + private let launchPlanRequests: [TestGate] + + init(capacity: Int = 8) { + launchPlanRequests = (0.. Bool { + await launchPlanRequests[ordinal - 1].waitUntilOpen(timeout: .seconds(30)) + } + func inspect(at projectURL: URL) -> ProjectRunConfigurationInspection { ProjectRunConfigurationInspection(status: .ready, diagnostics: []) } @@ -616,7 +805,9 @@ private final class ReadyRunConfigurationOperations: RunConfigurationOperations, ) throws -> SharedLaunchPlan { lock.lock() launchPlanCalls += 1 + let ordinal = launchPlanCalls lock.unlock() + launchPlanRequests[ordinal - 1].open() throw RunConfigurationOperationFailure(message: "Launching is out of scope for this test") } @@ -630,6 +821,7 @@ private final class ReadyRunConfigurationOperations: RunConfigurationOperations, private final class InspectionGatedRunConfigurationOperations: RunConfigurationOperations, @unchecked Sendable { private let entered: [TestGate] private let releases: [TestGate] + private let launchPlanRequests: [TestGate] private let lock = NSLock() private var inspectCalls = 0 private var launchPlanCalls = 0 @@ -638,6 +830,7 @@ private final class InspectionGatedRunConfigurationOperations: RunConfigurationO init(capacity: Int = 8) { entered = (0.. Bool { + await launchPlanRequests[ordinal - 1].waitUntilOpen(timeout: .seconds(30)) + } + var resolveCallCount: Int { lock.lock() defer { lock.unlock() } @@ -705,7 +905,9 @@ private final class InspectionGatedRunConfigurationOperations: RunConfigurationO ) throws -> SharedLaunchPlan { lock.lock() launchPlanCalls += 1 + let ordinal = launchPlanCalls lock.unlock() + launchPlanRequests[ordinal - 1].open() throw RunConfigurationOperationFailure(message: "Launching is out of scope for this test") } From 9e26503e9881ccebb4e23b738d96465f852a3133 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sat, 29 Aug 2026 20:04:58 +0800 Subject: [PATCH 10/66] feat(debug): build shared Java debug foundation --- .../Composition/DebugFeatureGraph.swift | 19 +- .../Features/JavaDebugFeatureModel.swift | 105 - .../Features/JavaFeatureModel.swift | 102 +- .../Lithe/Core/Ports/RuntimeLocator.swift | 1 - .../Lithe/Core/Rust/RustCoreBridge.swift | 4 +- .../Core/Rust/RustDebugProtocolCore.swift | 389 +++ .../AppModel/AppModel+Development.swift | 107 +- .../AppModel/AppModel+ExecutionModules.swift | 18 +- .../Lithe/Models/AppModel/AppModel.swift | 6 +- .../Lithe/Models/Java/JavaDebugModels.swift | 84 - .../Models/Runtime/ProjectRuntimeModels.swift | 16 +- .../MacDebugOperationDeadlineScheduler.swift | 35 + .../Debug/MacJavaDebugAdapterTransport.swift | 142 + .../MacServerDebugAdapterTransport.swift | 2 +- .../Platform/MacOS/MacServiceContainer.swift | 31 +- .../MacJDTLSLaunchResourceResolver.swift | 35 +- .../MacOS/Runtime/MacRuntimeDiscovery.swift | 10 - .../MacOS/Runtime/MacRuntimeLocator.swift | 4 - .../Runtime/MacRuntimeToolDiscovery.swift | 2 +- .../DebugLaunchConfigurationResolver.swift | 43 +- .../Services/Java/JavaDebugService.swift | 855 ------ .../Services/Java/ProjectRuntimeService.swift | 37 +- .../Language/LanguagePackRegistry.swift | 6 - .../Lithe/Views/Debug/GenericDebugView.swift | 1036 ++++++- .../Lithe/Views/Debug/JavaDebugView.swift | 586 ---- .../Lithe/Views/Editor/CodeEditorView.swift | 39 +- .../Lithe/Views/Editor/EditorAreaView.swift | 2 - .../WorkbenchModuleUIComposition.swift | 9 +- .../Debug/DebugAdapterContracts.swift | 396 ++- .../Debug/DebugProtocolCore.swift | 230 ++ .../LanguageServerRuntimeContracts.swift | 5 +- .../Language/LanguageToolingContracts.swift | 16 +- .../GenericDebugFeatureModel.swift | 763 ++++- .../LitheDebugModule/Module/DebugModule.swift | 6 - .../CoreDebugAdapterProtocolSession.swift | 649 +++++ .../Runtime/DebugAdapterProtocolSession.swift | 418 ++- .../Runtime/DebugAdapterSessionManager.swift | 54 + .../Runtime/LanguageServerSession.swift | 18 +- .../LanguageToolingSessionManager.swift | 255 ++ .../DebugModuleTests.swift | 808 +++++- .../LanguageIntelligenceModuleTests.swift | 161 ++ .../JavaLanguageServerRuntimeTests.swift | 15 +- .../LanguageServerToolServiceTests.swift | 1 - .../Tests/LitheTests/MavenRuntimeTests.swift | 2 - .../RunConfigurationIntegrationTests.swift | 257 +- .../resources/lsp/language-providers.json | 2 +- rust/lithe-core/src/debug/engine.rs | 2524 +++++++++++++++++ rust/lithe-core/src/debug/mod.rs | 8 + rust/lithe-core/src/debug/protocol.rs | 105 + rust/lithe-core/src/debug/types.rs | 539 ++++ rust/lithe-core/src/lib.rs | 1 + rust/lithe-core/src/lsp/interface/client.rs | 2 +- rust/lithe-core/src/lsp/interface/engine.rs | 31 +- rust/lithe-core/src/lsp/languages/jdt.rs | 35 +- rust/lithe-core/src/lsp/tests.rs | 4 +- rust/lithe-core/src/protocol/command.rs | 64 + rust/lithe-core/src/runtime/dispatcher.rs | 244 ++ rust/lithe-core/src/tests/protocol.rs | 47 + scripts/prepare-jdtls.ps1 | 31 + scripts/prepare-jdtls.sh | 25 + scripts/verify-macos-package.sh | 12 +- shared/contracts/application-boundary.md | 4 +- shared/contracts/rust-core-api.md | 119 +- shared/fixtures/debug/dap-session-v1.json | 139 + shared/fixtures/lsp/jdt-direct-launch-v1.json | 3 +- third_party/jdtls/manifest.json | 7 + 66 files changed, 9589 insertions(+), 2136 deletions(-) delete mode 100644 macos/Sources/Lithe/Application/Features/JavaDebugFeatureModel.swift create mode 100644 macos/Sources/Lithe/Core/Rust/RustDebugProtocolCore.swift delete mode 100644 macos/Sources/Lithe/Models/Java/JavaDebugModels.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/Debug/MacDebugOperationDeadlineScheduler.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/Debug/MacJavaDebugAdapterTransport.swift delete mode 100644 macos/Sources/Lithe/Services/Java/JavaDebugService.swift delete mode 100644 macos/Sources/Lithe/Views/Debug/JavaDebugView.swift create mode 100644 macos/Sources/LitheCoreContracts/Debug/DebugProtocolCore.swift create mode 100644 macos/Sources/LitheDebugModule/Runtime/CoreDebugAdapterProtocolSession.swift create mode 100644 rust/lithe-core/src/debug/engine.rs create mode 100644 rust/lithe-core/src/debug/mod.rs create mode 100644 rust/lithe-core/src/debug/protocol.rs create mode 100644 rust/lithe-core/src/debug/types.rs create mode 100644 shared/fixtures/debug/dap-session-v1.json diff --git a/macos/Sources/Lithe/Application/Composition/DebugFeatureGraph.swift b/macos/Sources/Lithe/Application/Composition/DebugFeatureGraph.swift index 444708cf..61e76e8e 100644 --- a/macos/Sources/Lithe/Application/Composition/DebugFeatureGraph.swift +++ b/macos/Sources/Lithe/Application/Composition/DebugFeatureGraph.swift @@ -5,22 +5,17 @@ import LitheModuleAPI @MainActor final class DebugFeatureGraph: NSObject, DebugServiceGraph { - let java: JavaDebugService let adapterSessions: DebugAdapterSessionManager - let javaFeature: JavaDebugFeatureModel let genericFeature: GenericDebugFeatureModel private var activityObservers: Set = [] - private var javaLease: ModuleLease? private var adapterLease: ModuleLease? - init(java: JavaDebugService, adapterSessions: DebugAdapterSessionManager) { - self.java = java; self.adapterSessions = adapterSessions - javaFeature = JavaDebugFeatureModel(service: java) + init(adapterSessions: DebugAdapterSessionManager) { + self.adapterSessions = adapterSessions genericFeature = GenericDebugFeatureModel(sessions: adapterSessions) } - var isActive: Bool { java.state != .idle || !adapterSessions.activeAdapterIDs.isEmpty } - var javaFeatureTarget: any JavaDebugFeatureTarget { javaFeature } + var isActive: Bool { !adapterSessions.activeAdapterIDs.isEmpty } var genericFeatureTarget: any GenericDebugFeatureTarget { genericFeature } var hasActiveDebugWork: Bool { isActive } func activate(context: ModuleContext) { @@ -31,11 +26,6 @@ final class DebugFeatureGraph: NSObject, DebugServiceGraph { } func configureModuleLeases(acquire: @escaping @MainActor (String) -> ModuleLease) { - java.$state.map { $0 != .idle }.removeDuplicates().sink { [weak self] active in - guard let self else { return } - if active, javaLease == nil { javaLease = acquire("Java debug session is active") } - if !active { javaLease?.release(); javaLease = nil } - }.store(in: &activityObservers) genericFeature.$state.map { ![.idle, .terminated, .failed].contains($0) } .removeDuplicates().sink { [weak self] active in guard let self else { return } @@ -45,8 +35,7 @@ final class DebugFeatureGraph: NSObject, DebugServiceGraph { } func stop() { - java.stop(); adapterSessions.stopAll() - javaLease?.release(); javaLease = nil + adapterSessions.stopAll() adapterLease?.release(); adapterLease = nil activityObservers.removeAll() } diff --git a/macos/Sources/Lithe/Application/Features/JavaDebugFeatureModel.swift b/macos/Sources/Lithe/Application/Features/JavaDebugFeatureModel.swift deleted file mode 100644 index cf887aff..00000000 --- a/macos/Sources/Lithe/Application/Features/JavaDebugFeatureModel.swift +++ /dev/null @@ -1,105 +0,0 @@ -import Combine -import Foundation -import LitheDebugModule - -/// UI-facing projection for Java debugger state and commands. -@MainActor -final class JavaDebugFeatureModel: ObservableObject, JavaDebugFeatureTarget { - private let service: JavaDebugService - private var observation: AnyCancellable? - - @Published var targetKind: JavaDebugTargetKind { - didSet { - guard targetKind != service.targetKind else { return } - service.targetKind = targetKind - } - } - - @Published var remoteHost: String { - didSet { - guard remoteHost != service.remoteHost else { return } - service.remoteHost = remoteHost - } - } - - @Published var remotePort: String { - didSet { - guard remotePort != service.remotePort else { return } - service.remotePort = remotePort - } - } - - @Published var remoteJavaHomePath: String { - didSet { - guard remoteJavaHomePath != service.remoteJavaHomePath else { return } - service.remoteJavaHomePath = remoteJavaHomePath - } - } - - init(service: JavaDebugService) { - self.service = service - _targetKind = Published(initialValue: service.targetKind) - _remoteHost = Published(initialValue: service.remoteHost) - _remotePort = Published(initialValue: service.remotePort) - _remoteJavaHomePath = Published(initialValue: service.remoteJavaHomePath) - observation = service.objectWillChange.sink { [weak self] _ in - guard let self else { return } - if self.targetKind != self.service.targetKind { self.targetKind = self.service.targetKind } - if self.remoteHost != self.service.remoteHost { self.remoteHost = self.service.remoteHost } - if self.remotePort != self.service.remotePort { self.remotePort = self.service.remotePort } - if self.remoteJavaHomePath != self.service.remoteJavaHomePath { - self.remoteJavaHomePath = self.service.remoteJavaHomePath - } - self.objectWillChange.send() - } - } - - var state: JavaDebugSessionState { service.state } - var output: String { service.output } - var inspectionTitle: String? { service.inspectionTitle } - var inspectionOutput: String { service.inspectionOutput } - var variables: [JavaDebugVariable] { service.variables } - var threads: [JavaDebugThread] { service.threads } - var callStack: [JavaDebugStackFrame] { service.callStack } - var expandingVariableID: String? { service.expandingVariableID } - var exceptionMessage: String? { service.exceptionMessage } - var port: Int? { service.port } - var breakpoints: [JavaDebugBreakpoint] { service.breakpoints } - var runningTargetTitle: String? { service.runningTargetTitle } - var isSessionActive: Bool { service.isSessionActive } - var canControl: Bool { service.canControl } - - func pause() { service.pause() } - func continueExecution() { service.continueExecution() } - func stepInto() { service.stepInto() } - func stepOver() { service.stepOver() } - func stepOut() { service.stepOut() } - func inspectThreads() { service.inspectThreads() } - func inspectStack() { service.inspectStack() } - func inspectVariables() { service.inspectVariables() } - func evaluate(_ expression: String) { service.evaluate(expression) } - func toggleVariable(_ variable: JavaDebugVariable) { service.toggleVariable(variable) } - func clearOutput() { service.clearOutput() } - - func reset() { service.reset() } - func start( - fileURL: URL, - sourceText: String, - projectURL: URL?, - options: RunOptions - ) { service.start(fileURL: fileURL, sourceText: sourceText, projectURL: projectURL, options: options) } - func startMaven( - configuration: RunConfiguration, - project: MavenProject, - projectURL: URL, - options: RunOptions - ) { service.startMaven(configuration: configuration, project: project, projectURL: projectURL, options: options) } - func attachRemote() { service.attachRemote() } - func toggleBreakpoint(fileURL: URL, line: Int, className: String) { - service.toggleBreakpoint(fileURL: fileURL, line: line, className: className) - } - func className(for fileURL: URL, sourceText: String) -> String { - service.className(for: fileURL, sourceText: sourceText) - } - func stop() { service.stop() } -} diff --git a/macos/Sources/Lithe/Application/Features/JavaFeatureModel.swift b/macos/Sources/Lithe/Application/Features/JavaFeatureModel.swift index 6151f056..8f06a748 100644 --- a/macos/Sources/Lithe/Application/Features/JavaFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/JavaFeatureModel.swift @@ -66,7 +66,7 @@ enum JavaLanguageServerWorkspaceState { } } -/// Owns Java-only code vision, Maven integration, and legacy Java debug behavior. +/// Owns Java-only code vision and source structure behavior. /// Java LSP navigation and editing are delegated to the Rust host. @MainActor final class JavaFeatureModel: ObservableObject { @@ -75,11 +75,7 @@ final class JavaFeatureModel: ObservableObject { private let operations: any JavaMavenOperations private var documentProvider: (@MainActor () -> EditorDocument?)? - private var caretProvider: (@MainActor () -> EditorCaret?)? - private var notify: (@MainActor (String) -> Void)? private var loadBlame: (@MainActor (URL) async -> [GitBlameLine])? - private var mavenFeature: MavenFeatureModel? - private var debugFeature: JavaDebugFeatureModel? init(operations: any JavaMavenOperations) { self.operations = operations @@ -87,34 +83,18 @@ final class JavaFeatureModel: ObservableObject { func configure( documentProvider: @escaping @MainActor () -> EditorDocument?, - caretProvider: @escaping @MainActor () -> EditorCaret?, - notify: @escaping @MainActor (String) -> Void, loadBlame: @escaping @MainActor (URL) async -> [GitBlameLine] ) { self.documentProvider = documentProvider - self.caretProvider = caretProvider - self.notify = notify self.loadBlame = loadBlame } - func configureRuntime( - mavenFeature: MavenFeatureModel?, - debugFeature: JavaDebugFeatureModel? - ) { - self.mavenFeature = mavenFeature - self.debugFeature = debugFeature - } - /// Explicit boundary for Java-only editor adornments and legacy services. /// Callers can avoid scheduling Java work for every supported language. func handles(fileURL: URL) -> Bool { fileURL.pathExtension.lowercased() == "java" } - func supportsLegacyDebugging(fileURL: URL) -> Bool { - handles(fileURL: fileURL) - } - func stop() { cancelLanguageServerPreparation() javaCodeVisionHints = [:] @@ -187,86 +167,6 @@ final class JavaFeatureModel: ObservableObject { return false } - @discardableResult - func startDebugging( - currentDocument: EditorDocument?, - workspaceURL: URL?, - runFeature: RunFeatureModel, - saveDocument: @escaping @MainActor (EditorDocument) throws -> Void, - recordSave: @escaping @MainActor (EditorDocument, String) -> Void - ) -> Bool { - guard let debugFeature else { return false } - if debugFeature.targetKind != .remote, let currentDocument, currentDocument.isDirty { - do { - let previousText = currentDocument.savedText - try saveDocument(currentDocument) - recordSave(currentDocument, previousText) - } catch { - notify?("Could not save \(currentDocument.url.lastPathComponent)") - return false - } - } - switch debugFeature.targetKind { - case .currentFile: - guard let currentDocument, - currentDocument.url.pathExtension.lowercased() == "java" else { - notify?("Open a Java file before starting Debug") - return false - } - debugFeature.start( - fileURL: currentDocument.url, - sourceText: currentDocument.text, - projectURL: workspaceURL, - options: runFeature.options(for: .currentFile) - ) - case .runConfiguration: - guard let configuration = runFeature.selectedConfiguration, - configuration.kind.isMavenBacked else { - notify?("Select a Spring Boot or Maven Module configuration before starting Debug") - return false - } - guard let workspaceURL, let mavenProject = mavenFeature?.project else { - notify?("No Maven project is available for Debug") - return false - } - debugFeature.startMaven( - configuration: configuration, - project: mavenProject, - projectURL: workspaceURL, - options: runFeature.options(for: configuration) - ) - case .remote: - debugFeature.attachRemote() - } - return true - } - - func toggleDebugBreakpoint( - at fileURL: URL, - line: Int, - documents: [EditorDocument] - ) { - guard let debugFeature, - let document = documents.first(where: { - $0.url.standardizedFileURL == fileURL.standardizedFileURL - }), - document.url.pathExtension.lowercased() == "java", - line > 0 else { return } - let className = debugFeature.className(for: document.url, sourceText: document.text) - debugFeature.toggleBreakpoint(fileURL: document.url, line: line, className: className) - } - - func toggleDebugBreakpointAtCaret() { - guard let document = documentProvider?(), - let caret = caretProvider?(), - document.url.standardizedFileURL == caret.url.standardizedFileURL, - document.url.pathExtension.lowercased() == "java" else { - notify?("Place the caret in a Java file to set a breakpoint") - return - } - toggleDebugBreakpoint(at: document.url, line: caret.line + 1, documents: [document]) - } - func close(_ document: EditorDocument) { javaCodeVisionHints[document.url.standardizedFileURL] = nil } diff --git a/macos/Sources/Lithe/Core/Ports/RuntimeLocator.swift b/macos/Sources/Lithe/Core/Ports/RuntimeLocator.swift index 5ea4a4a9..0084f45a 100644 --- a/macos/Sources/Lithe/Core/Ports/RuntimeLocator.swift +++ b/macos/Sources/Lithe/Core/Ports/RuntimeLocator.swift @@ -74,7 +74,6 @@ protocol RuntimeLocator: Sendable { func systemMavenExecutable() -> URL? func mavenExecutable(forHomePath path: String) -> URL? func mavenRuntime(at executableURL: URL) -> MavenRuntimeCandidate? - func systemJDBExecutable() -> URL? /// The JDTLS runtime JDK bundled with the application, if present. /// Returns the home directory URL (containing `bin/java`). Returns `nil` /// in development builds or on platforms that do not bundle a JDK. diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index 16772f27..eafeefea 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -1425,6 +1425,7 @@ struct RustCoreBridge: Sendable { let launcherJarPath: String let configurationDirectory: String let lombokAgentPath: String + let javaDebugBundlePath: String? } private struct LspSessionIdentifierRequest: Encodable { @@ -2971,7 +2972,8 @@ struct RustCoreBridge: Sendable { LspJdtlsLaunchResourcesRequest( launcherJarPath: $0.launcherJarURL.path, configurationDirectory: $0.configurationDirectoryURL.path, - lombokAgentPath: $0.lombokAgentURL.path + lombokAgentPath: $0.lombokAgentURL.path, + javaDebugBundlePath: $0.javaDebugBundleURL?.path ) }, cacheDirectory: cacheDirectoryURL?.standardizedFileURL.path, diff --git a/macos/Sources/Lithe/Core/Rust/RustDebugProtocolCore.swift b/macos/Sources/Lithe/Core/Rust/RustDebugProtocolCore.swift new file mode 100644 index 00000000..5b6fbe29 --- /dev/null +++ b/macos/Sources/Lithe/Core/Rust/RustDebugProtocolCore.swift @@ -0,0 +1,389 @@ +import Foundation +import LitheCoreContracts + +extension RustCoreBridge: DebugProtocolCore { + func createDebugSession( + sessionID: String, + adapterID: String, + rootPath: String + ) throws -> DebugCoreUpdate { + try executeResult( + command: "debug.createSession", + payload: DebugCreateSessionPayload( + sessionID: sessionID, + adapterID: adapterID, + rootPath: rootPath + ) + ).get() + } + + func launchDebugSession( + sessionID: String, + operationID: String, + configuration: DebugLaunchConfiguration + ) throws -> DebugCoreUpdate { + try executeResult( + command: "debug.launch", + payload: DebugLaunchPayload( + sessionID: sessionID, + operationID: operationID, + configuration: configuration + ), + operationID: operationID + ).get() + } + + func setDebugBreakpoints( + sessionID: String, + sourcePath: String, + breakpoints: [DebugSourceBreakpoint] + ) throws -> DebugCoreUpdate { + try executeResult( + command: "debug.setBreakpoints", + payload: DebugBreakpointsPayload( + sessionID: sessionID, + sourcePath: sourcePath, + breakpoints: breakpoints + ) + ).get() + } + + func setDebugExceptionBreakpoints( + sessionID: String, + breakpoints: [DebugExceptionBreakpoint] + ) throws -> DebugCoreUpdate { + try executeResult( + command: "debug.setExceptionBreakpoints", + payload: DebugExceptionBreakpointsPayload( + sessionID: sessionID, + breakpoints: breakpoints + ) + ).get() + } + + func setDebugFunctionBreakpoints( + sessionID: String, + breakpoints: [DebugFunctionBreakpoint] + ) throws -> DebugCoreUpdate { + try executeResult( + command: "debug.setFunctionBreakpoints", + payload: DebugFunctionBreakpointsPayload( + sessionID: sessionID, + breakpoints: breakpoints + ) + ).get() + } + + func debugDataBreakpointInfo( + sessionID: String, + operationID: String, + name: String, + variablesReference: Int?, + frameID: Int? + ) throws -> DebugCoreUpdate { + try executeResult( + command: "debug.dataBreakpointInfo", + payload: DebugDataBreakpointInfoPayload( + sessionID: sessionID, + operationID: operationID, + name: name, + variablesReference: variablesReference, + frameID: frameID + ), + operationID: operationID + ).get() + } + + func setDebugDataBreakpoints( + sessionID: String, + breakpoints: [DebugDataBreakpoint] + ) throws -> DebugCoreUpdate { + try executeResult( + command: "debug.setDataBreakpoints", + payload: DebugDataBreakpointsPayload( + sessionID: sessionID, + breakpoints: breakpoints + ) + ).get() + } + + func setDebugVariable( + sessionID: String, + operationID: String, + variablesReference: Int, + name: String, + value: String + ) throws -> DebugCoreUpdate { + try executeResult( + command: "debug.setVariable", + payload: DebugSetVariablePayload( + sessionID: sessionID, + operationID: operationID, + variablesReference: variablesReference, + name: name, + value: value + ), + operationID: operationID + ).get() + } + + func cancelDebugOperation( + sessionID: String, + operationID: String, + reason: String + ) throws -> DebugCoreUpdate { + try executeResult( + command: "debug.cancelOperation", + payload: DebugCancelOperationPayload( + sessionID: sessionID, + operationID: operationID, + reason: reason + ), + operationID: operationID + ).get() + } + + func executeDebugCommand( + sessionID: String, + operationID: String, + command: DebugExecutionCommand, + threadID: Int?, + targetID: Int?, + singleThread: Bool + ) throws -> DebugCoreUpdate { + try executeResult( + command: "debug.execute", + payload: DebugExecutePayload( + sessionID: sessionID, + operationID: operationID, + command: command.rawValue, + threadID: threadID, + targetID: targetID, + singleThread: singleThread + ), + operationID: operationID + ).get() + } + + func inspectDebugSession( + sessionID: String, + operationID: String, + kind: String, + threadID: Int?, + frameID: Int?, + variablesReference: Int?, + expression: String?, + sourcePath: String?, + line: Int?, + column: Int? + ) throws -> DebugCoreUpdate { + try executeResult( + command: "debug.inspect", + payload: DebugInspectPayload( + sessionID: sessionID, + operationID: operationID, + kind: kind, + threadID: threadID, + frameID: frameID, + variablesReference: variablesReference, + expression: expression, + sourcePath: sourcePath, + line: line, + column: column + ), + operationID: operationID + ).get() + } + + func receiveDebugData(sessionID: String, data: Data) throws -> DebugCoreUpdate { + try executeResult( + command: "debug.receive", + payload: DebugReceivePayload( + sessionID: sessionID, + dataBase64: data.base64EncodedString() + ) + ).get() + } + + func disconnectDebugSession(sessionID: String) throws -> DebugCoreUpdate { + try executeResult( + command: "debug.disconnect", + payload: DebugSessionPayload(sessionID: sessionID) + ).get() + } + + func destroyDebugSession(sessionID: String) { + let result: Result = executeResult( + command: "debug.destroySession", + payload: DebugSessionPayload(sessionID: sessionID) + ) + _ = result + } +} + +private struct DebugCreateSessionPayload: Encodable { + let sessionID: String + let adapterID: String + let rootPath: String + + private enum CodingKeys: String, CodingKey { + case sessionID = "sessionId" + case adapterID = "adapterId" + case rootPath + } +} + +private struct DebugSessionPayload: Encodable { + let sessionID: String + + private enum CodingKeys: String, CodingKey { + case sessionID = "sessionId" + } +} + +private struct DebugLaunchPayload: Encodable { + let sessionID: String + let operationID: String + let configuration: DebugLaunchConfiguration + + private enum CodingKeys: String, CodingKey { + case sessionID = "sessionId" + case operationID = "operationId" + case configuration + } +} + +private struct DebugBreakpointsPayload: Encodable { + let sessionID: String + let sourcePath: String + let breakpoints: [DebugSourceBreakpoint] + + private enum CodingKeys: String, CodingKey { + case sessionID = "sessionId" + case sourcePath + case breakpoints + } +} + +private struct DebugExceptionBreakpointsPayload: Encodable { + let sessionID: String + let breakpoints: [DebugExceptionBreakpoint] + + private enum CodingKeys: String, CodingKey { + case sessionID = "sessionId" + case breakpoints + } +} + +private struct DebugFunctionBreakpointsPayload: Encodable { + let sessionID: String + let breakpoints: [DebugFunctionBreakpoint] + + private enum CodingKeys: String, CodingKey { + case sessionID = "sessionId" + case breakpoints + } +} + +private struct DebugDataBreakpointInfoPayload: Encodable { + let sessionID: String + let operationID: String + let name: String + let variablesReference: Int? + let frameID: Int? + + private enum CodingKeys: String, CodingKey { + case sessionID = "sessionId" + case operationID = "operationId" + case name, variablesReference + case frameID = "frameId" + } +} + +private struct DebugDataBreakpointsPayload: Encodable { + let sessionID: String + let breakpoints: [DebugDataBreakpoint] + + private enum CodingKeys: String, CodingKey { + case sessionID = "sessionId" + case breakpoints + } +} + +private struct DebugSetVariablePayload: Encodable { + let sessionID: String + let operationID: String + let variablesReference: Int + let name: String + let value: String + + private enum CodingKeys: String, CodingKey { + case sessionID = "sessionId" + case operationID = "operationId" + case variablesReference, name, value + } +} + +private struct DebugCancelOperationPayload: Encodable { + let sessionID: String + let operationID: String + let reason: String + + private enum CodingKeys: String, CodingKey { + case sessionID = "sessionId" + case operationID = "operationId" + case reason + } +} + +private struct DebugExecutePayload: Encodable { + let sessionID: String + let operationID: String + let command: String + let threadID: Int? + let targetID: Int? + let singleThread: Bool + + private enum CodingKeys: String, CodingKey { + case sessionID = "sessionId" + case operationID = "operationId" + case command + case threadID = "threadId" + case targetID = "targetId" + case singleThread + } +} + +private struct DebugInspectPayload: Encodable { + let sessionID: String + let operationID: String + let kind: String + let threadID: Int? + let frameID: Int? + let variablesReference: Int? + let expression: String? + let sourcePath: String? + let line: Int? + let column: Int? + + private enum CodingKeys: String, CodingKey { + case sessionID = "sessionId" + case operationID = "operationId" + case kind + case threadID = "threadId" + case frameID = "frameId" + case variablesReference + case expression + case sourcePath, line, column + } +} + +private struct DebugReceivePayload: Encodable { + let sessionID: String + let dataBase64: String + + private enum CodingKeys: String, CodingKey { + case sessionID = "sessionId" + case dataBase64 + } +} diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift index d04d89c4..6381fe86 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift @@ -322,60 +322,19 @@ extension AppModel { } private func startDebuggingAfterActivation() async { - guard let execution = await activateExecutionModule(), - let debug = await activateDebugModule() else { return } - let runFeature = execution.runFeature - let debugFeature = debug.javaFeature - javaFeature.configureRuntime( - mavenFeature: execution.mavenFeature, - debugFeature: debugFeature - ) + guard await activateExecutionModule() != nil, + await activateDebugModule() != nil else { return } if let document = activeDocument, languageProviderCatalog.provider(for: document.url)? .capabilities.contains(.debugAdapter) == true { startGenericDebugging(document) return } - if debugFeature.targetKind == .currentFile, - let document = activeDocument, - languageProviderCatalog.provider(for: document.url)?.id != "java" { - let language = languageProviderCatalog.provider(for: document.url)?.displayName - ?? "This file type" - showNotification("\(language) debugging is not available on this machine") - isDebugVisible = true - return - } - if debugFeature.targetKind == .runConfiguration, - runFeature.configurationStatus != .ready { - runFeature.requestRunConfigurationGeneration(intent: .debug) - return - } - if runFeature.blockingToolchainDiagnostic != nil { - isRunVisible = true - isDebugVisible = false - isGitLogVisible = false - isTerminalVisible = false - isReferencesVisible = false - isProblemsVisible = false - isMavenVisible = false - return - } - guard javaFeature.startDebugging( - currentDocument: activeDocument, - workspaceURL: workspaceURL, - runFeature: runFeature, - saveDocument: { [weak self] document in try self?.saveDocument(document) }, - recordSave: { [weak self] document, previousText in - self?.recordSave(document, previousText: previousText) - } - ) else { return } + let language = activeDocument.flatMap { + languageProviderCatalog.provider(for: $0.url)?.displayName + } ?? "This file type" + showNotification("\(language) debugging is not available on this machine") isDebugVisible = true - isGitLogVisible = false - isTerminalVisible = false - isReferencesVisible = false - isProblemsVisible = false - isMavenVisible = false - isRunVisible = false } func toggleTests() { @@ -487,11 +446,7 @@ extension AppModel { } func stopDebugging() { - if genericDebugFeatureIfActive?.providerID != nil { - genericDebugFeatureIfActive?.stop() - } else { - debugFeatureIfActive?.stop() - } + genericDebugFeatureIfActive?.stop() } func toggleDebugBreakpointAtCaret() { @@ -511,25 +466,36 @@ extension AppModel { guard let feature = await self?.activateDebugModule()?.genericFeature else { return } feature.toggleBreakpoint(fileURL: fileURL, line: line) } - } else if javaFeature.supportsLegacyDebugging(fileURL: fileURL) { - javaFeature.toggleDebugBreakpoint(at: fileURL, line: line, documents: openDocuments) } else { showNotification("Debugging is not supported for this file type") } } - var prefersGenericDebugUI: Bool { - if genericDebugFeatureIfActive?.providerID != nil { return true } - guard let document = activeDocument else { return false } - // Never show the Java/JDB panel for another language. A configured - // Provider may still be unavailable locally; the generic panel can - // then present the Provider's installation error without leaking a - // Java-specific workflow into that project. - guard let descriptor = languageProviderCatalog.provider(for: document.url) else { - return true + func runToCursor(fileURL: URL, line: Int, column: Int) { + guard let feature = genericDebugFeatureIfActive, + feature.state == .paused, + feature.capabilities.supportsGotoTargetsRequest else { + showNotification("Run to Cursor is unavailable for the active debug session") + return + } + feature.requestRunToCursor( + fileURL: fileURL, + line: line, + column: column + ) { [weak self, weak feature] result in + switch result { + case .success(let targets): + guard let target = targets.min(by: { + abs(($0.column ?? column) - column) < abs(($1.column ?? column) - column) + }) else { + self?.showNotification("No executable location was found at the cursor") + return + } + feature?.runToCursor(target) + case .failure(let error): + self?.showNotification(error.localizedDescription) + } } - return descriptor.id != "java" - || descriptor.capabilities.contains(.debugAdapter) } private func startGenericDebugging(_ document: EditorDocument) { @@ -556,12 +522,23 @@ extension AppModel { } let configuration: DebugLaunchConfiguration do { + let javaTarget: JavaDebugLaunchTarget? + if provider.id == "java" { + let sessions = try await languageSessionsForWorkspaceMaintenance() + javaTarget = try await sessions.resolveJavaDebugLaunchTarget( + fileURL: document.url, + rootURL: workspaceURL + ) + } else { + javaTarget = nil + } configuration = try debugLaunchConfigurationResolver.resolve( provider: provider, documentURL: document.url, workspaceURL: workspaceURL, configurations: runFeature.configurations, selectedConfiguration: runFeature.selectedConfiguration, + javaTarget: javaTarget, options: { [runFeature] in runFeature.options(for: $0) } ) } catch { diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift index 9c95a36d..00a6ff3b 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift @@ -6,7 +6,6 @@ import LitheExecutionModule @MainActor extension AppModel { struct DebugFeatureAccess { - let javaFeature: JavaDebugFeatureModel let genericFeature: GenericDebugFeatureModel } struct ExecutionFeatureAccess { @@ -18,9 +17,6 @@ extension AppModel { var mavenFeatureIfActive: MavenFeatureModel? { executionCapability?.mavenFeature } var runFeatureIfActive: RunFeatureModel? { executionCapability?.runFeature } - var debugFeatureIfActive: JavaDebugFeatureModel? { - debugCapability?.javaFeature as? JavaDebugFeatureModel - } var genericDebugFeatureIfActive: GenericDebugFeatureModel? { debugCapability?.genericFeature as? GenericDebugFeatureModel } @@ -54,24 +50,18 @@ extension AppModel { } func activateDebugModule() async -> DebugFeatureAccess? { - if let javaFeature = debugFeatureIfActive, - let genericFeature = genericDebugFeatureIfActive { - return DebugFeatureAccess(javaFeature: javaFeature, genericFeature: genericFeature) + if let genericFeature = genericDebugFeatureIfActive { + return DebugFeatureAccess(genericFeature: genericFeature) } do { let value = try await services.moduleRuntime.activateCapability(.debugWorkspace) guard let capability = value as? LitheDebugModule.DebugModuleCapability, - let javaFeature = capability.javaFeature as? JavaDebugFeatureModel, let genericFeature = capability.genericFeature as? GenericDebugFeatureModel else { return nil } cacheModuleCapability(capability, id: .debugWorkspace, moduleID: .debug) - self.javaFeature.configureRuntime( - mavenFeature: mavenFeatureIfActive, - debugFeature: javaFeature - ) - observeModuleFeature(.debug, observation: javaFeature.objectWillChange.sink { [weak self] _ in + observeModuleFeature(.debug, observation: genericFeature.objectWillChange.sink { [weak self] _ in self?.scheduleObjectWillChangeRelay() }) - return DebugFeatureAccess(javaFeature: javaFeature, genericFeature: genericFeature) + return DebugFeatureAccess(genericFeature: genericFeature) } catch { showNotification(error.localizedDescription) return nil diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 21b5f6c8..fb791560 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -592,8 +592,6 @@ final class AppModel: ObservableObject, Identifiable { .sink { [weak self] ids in self?.editorTabOrderFeature.reconcileDocuments(orderedIDs: ids) } javaFeature.configure( documentProvider: { [weak self] in self?.activeDocument }, - caretProvider: { [weak self] in self?.editorCaret }, - notify: { [weak self] message in self?.showNotification(message) }, loadBlame: { [weak self] fileURL in guard let self else { return [] } guard let feature = await self.activateGitModule() else { return [] } @@ -732,7 +730,7 @@ final class AppModel: ObservableObject, Identifiable { } private func reloadJavaRuntimeServices() { - debugFeatureIfActive?.stop() + genericDebugFeatureIfActive?.stop() mavenFeatureIfActive?.stop() languageToolingSessionsIfActive?.stopLanguageServer(providerID: "java") javaFeature.stop() @@ -933,7 +931,6 @@ final class AppModel: ObservableObject, Identifiable { runtimeFeature.openProject(at: normalizedURL) mavenFeatureIfActive?.reset() runFeatureIfActive?.reset() - debugFeatureIfActive?.reset() genericDebugFeatureIfActive?.reset() clearLanguageNavigationProjection() javaFeature.stop() @@ -1043,7 +1040,6 @@ final class AppModel: ObservableObject, Identifiable { runtimeFeature.closeProject() mavenFeatureIfActive?.reset() runFeatureIfActive?.reset() - debugFeatureIfActive?.reset() genericDebugFeatureIfActive?.reset() javaFeature.stop() springFeature.reset() diff --git a/macos/Sources/Lithe/Models/Java/JavaDebugModels.swift b/macos/Sources/Lithe/Models/Java/JavaDebugModels.swift deleted file mode 100644 index bdb29849..00000000 --- a/macos/Sources/Lithe/Models/Java/JavaDebugModels.swift +++ /dev/null @@ -1,84 +0,0 @@ -import Foundation - -enum JavaDebugTargetKind: String, CaseIterable, Identifiable, Sendable { - case currentFile - case runConfiguration - case remote - - var id: String { rawValue } - - var title: String { - switch self { - case .currentFile: "Current File" - case .runConfiguration: "Maven / Spring Boot" - case .remote: "Remote JVM / Tomcat" - } - } - - var systemImage: String { - switch self { - case .currentFile: "doc.text" - case .runConfiguration: "shippingbox" - case .remote: "network" - } - } -} - -enum JavaDebugSessionState: String, Sendable { - case idle - case launching - case running - case paused - case finished - case failed - - var title: String { - switch self { - case .idle: "Ready" - case .launching: "Launching" - case .running: "Running" - case .paused: "Paused" - case .finished: "Finished" - case .failed: "Failed" - } - } -} - -struct JavaDebugBreakpoint: Identifiable, Hashable, Sendable { - let id: String - let fileURL: URL - let line: Int - let className: String - - var title: String { - "\(fileURL.lastPathComponent):\(line)" - } -} - -struct JavaDebugVariable: Identifiable, Hashable, Sendable { - let id: String - let name: String - let expression: String - var value: String - var children: [JavaDebugVariable] - var isExpanded: Bool - let isExpandable: Bool - - var canExpand: Bool { - isExpandable || !children.isEmpty - } -} - -struct JavaDebugThread: Identifiable, Hashable, Sendable { - let id: String - let name: String - let status: String - let isCurrent: Bool -} - -struct JavaDebugStackFrame: Identifiable, Hashable, Sendable { - let level: Int - let description: String - - var id: String { "\(level):\(description)" } -} diff --git a/macos/Sources/Lithe/Models/Runtime/ProjectRuntimeModels.swift b/macos/Sources/Lithe/Models/Runtime/ProjectRuntimeModels.swift index c898ae41..8beb153e 100644 --- a/macos/Sources/Lithe/Models/Runtime/ProjectRuntimeModels.swift +++ b/macos/Sources/Lithe/Models/Runtime/ProjectRuntimeModels.swift @@ -80,7 +80,6 @@ enum JavaEnvironmentStatus: Equatable, Sendable { case ready case jdkMissing case configuredJDKInvalid(path: String) - case jdbMissing var requiresAttention: Bool { self != .checking && self != .ready @@ -88,7 +87,7 @@ enum JavaEnvironmentStatus: Equatable, Sendable { var blocksJavaRun: Bool { switch self { - case .jdkMissing, .configuredJDKInvalid, .jdbMissing: true + case .jdkMissing, .configuredJDKInvalid: true case .checking, .ready: false } } @@ -99,15 +98,13 @@ struct JavaEnvironmentReport: Equatable, Sendable { let projectURL: URL let javaHomePath: String? let javaExecutablePath: String? - let jdbExecutablePath: String? static func checking(for projectURL: URL) -> Self { Self( status: .checking, projectURL: projectURL.standardizedFileURL, javaHomePath: nil, - javaExecutablePath: nil, - jdbExecutablePath: nil + javaExecutablePath: nil ) } @@ -117,22 +114,19 @@ struct JavaEnvironmentReport: Equatable, Sendable { case .ready: "Java environment ready" case .jdkMissing: "JDK not found" case .configuredJDKInvalid: "Configured JDK is invalid" - case .jdbMissing: "Java debugger is incomplete" } } var message: String { switch status { case .checking: - "Lithe is checking the JDK and Java debugger." + "Lithe is checking the project JDK." case .ready: - "JDK and JDB are available for this project." + "A usable JDK is available for this project." case .jdkMissing: "This project contains Java sources, but no usable JDK was detected." case .configuredJDKInvalid(let path): "The configured JDK path is not a valid JDK: \(path)" - case .jdbMissing: - "A JDK was found, but its bin/jdb debugger is unavailable." } } @@ -143,8 +137,6 @@ struct JavaEnvironmentReport: Equatable, Sendable { "Choose a JDK in the Java service settings or install a full JDK and set JAVA_HOME." case .configuredJDKInvalid: "Choose another JDK in the Java service settings or clear the invalid path." - case .jdbMissing: - "Use a full JDK distribution instead of a JRE or minimal runtime." } } } diff --git a/macos/Sources/Lithe/Platform/MacOS/Debug/MacDebugOperationDeadlineScheduler.swift b/macos/Sources/Lithe/Platform/MacOS/Debug/MacDebugOperationDeadlineScheduler.swift new file mode 100644 index 00000000..fca0e7f7 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Debug/MacDebugOperationDeadlineScheduler.swift @@ -0,0 +1,35 @@ +import Foundation +import LitheCoreContracts + +@MainActor +final class MacDebugOperationDeadlineScheduler: DebugOperationDeadlineScheduling { + func schedule( + afterMilliseconds: Int, + action: @escaping @MainActor () -> Void + ) -> any DebugOperationDeadline { + let item = DispatchWorkItem { action() } + DispatchQueue.main.asyncAfter( + deadline: .now() + .milliseconds(afterMilliseconds), + execute: item + ) + return MacDebugOperationDeadline(item: item) + } +} + +@MainActor +private final class MacDebugOperationDeadline: DebugOperationDeadline { + private var item: DispatchWorkItem? + + init(item: DispatchWorkItem) { + self.item = item + } + + func cancel() { + item?.cancel() + item = nil + } + + deinit { + item?.cancel() + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Debug/MacJavaDebugAdapterTransport.swift b/macos/Sources/Lithe/Platform/MacOS/Debug/MacJavaDebugAdapterTransport.swift new file mode 100644 index 00000000..a23b413b --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Debug/MacJavaDebugAdapterTransport.swift @@ -0,0 +1,142 @@ +import Foundation +import LitheCoreContracts + +/// Connects the macOS product to the Java Debug Server hosted inside JDT LS. +/// JDT LS activation stays in the language module and DAP state stays in Core; +/// this adapter owns only asynchronous port discovery and the native TCP socket. +@MainActor +final class MacJavaDebugAdapterTransport: DebugAdapterTransport { + enum TransportError: LocalizedError { + case languageIntelligenceUnavailable + case connectionFailed(String) + case stopped + + var errorDescription: String? { + switch self { + case .languageIntelligenceUnavailable: + return "The Java language service is unavailable." + case .connectionFailed(let message): + return "Could not connect to the Java Debug Server: \(message)" + case .stopped: + return "The Java Debug Server connection is stopped." + } + } + } + + typealias PortResolver = @MainActor (URL) async throws -> UInt16 + + private let portResolver: PortResolver + private let socketFactory: @MainActor (String, UInt16) -> any DebugAdapterSocketConnection + private var startupTask: Task? + private var socket: (any DebugAdapterSocketConnection)? + private var pendingWrites: [Data] = [] + private var isSocketReady = false + private var generation = UUID() + private(set) var isRunning = false + + var onData: ((Data) -> Void)? + var onErrorOutput: ((Data) -> Void)? + var onTermination: ((Int) -> Void)? + + init( + portResolver: @escaping PortResolver, + socketFactory: @escaping @MainActor (String, UInt16) -> any DebugAdapterSocketConnection = { + NetworkDebugAdapterSocketConnection(host: $0, port: $1) + } + ) { + self.portResolver = portResolver + self.socketFactory = socketFactory + } + + func start(rootURL: URL) throws { + guard !isRunning else { return } + isRunning = true + isSocketReady = false + pendingWrites = [] + generation = UUID() + let currentGeneration = generation + let portResolver = portResolver + startupTask = Task { @MainActor [weak self] in + do { + let port = try await portResolver(rootURL.standardizedFileURL) + try Task.checkCancellation() + guard let self else { return } + guard isRunning, generation == currentGeneration else { return } + connect(port: port, generation: currentGeneration) + } catch is CancellationError { + return + } catch { + guard let self else { return } + guard isRunning, generation == currentGeneration else { return } + fail(error) + } + } + } + + func send(_ data: Data) throws { + guard isRunning else { throw TransportError.stopped } + guard isSocketReady, let socket else { + pendingWrites.append(data) + return + } + socket.send(data) + } + + func stop() { + generation = UUID() + startupTask?.cancel() + startupTask = nil + socket?.stop() + socket = nil + pendingWrites = [] + isSocketReady = false + isRunning = false + } + + private func connect(port: UInt16, generation: UUID) { + let socket = socketFactory("127.0.0.1", port) + self.socket = socket + socket.onReady = { [weak self] in + guard let self, self.generation == generation else { return } + self.socketDidBecomeReady() + } + socket.onData = { [weak self] data in + guard let self, self.generation == generation else { return } + self.onData?(data) + } + socket.onFailure = { [weak self] error in + guard let self, self.generation == generation else { return } + self.fail(TransportError.connectionFailed(error.localizedDescription)) + } + socket.onComplete = { [weak self] in + guard let self, self.generation == generation else { return } + self.terminate(exitCode: 0) + } + socket.start() + } + + private func socketDidBecomeReady() { + guard let socket, isRunning else { return } + isSocketReady = true + let writes = pendingWrites + pendingWrites = [] + writes.forEach(socket.send) + } + + private func fail(_ error: Error) { + onErrorOutput?(Data((error.localizedDescription + "\n").utf8)) + terminate(exitCode: 1) + } + + private func terminate(exitCode: Int) { + guard isRunning else { return } + startupTask?.cancel() + startupTask = nil + socket?.stop() + socket = nil + pendingWrites = [] + isSocketReady = false + isRunning = false + onTermination?(exitCode) + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Debug/MacServerDebugAdapterTransport.swift b/macos/Sources/Lithe/Platform/MacOS/Debug/MacServerDebugAdapterTransport.swift index 52fe1967..18b1f9a9 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Debug/MacServerDebugAdapterTransport.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Debug/MacServerDebugAdapterTransport.swift @@ -23,7 +23,7 @@ protocol DebugAdapterSocketConnection: AnyObject { typealias DlvSocketConnection = DebugAdapterSocketConnection @MainActor -private final class NetworkDebugAdapterSocketConnection: DebugAdapterSocketConnection { +final class NetworkDebugAdapterSocketConnection: DebugAdapterSocketConnection { private let connection: NWConnection private let queue = DispatchQueue(label: "app.lithe.debug.adapter-tcp") var onReady: (() -> Void)? diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 90c6ba81..e525041a 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -339,6 +339,26 @@ final class MacServiceContainer { try moduleRegistry.register(ModuleFactory(manifest: DebugModule.moduleManifest, contributions: DebugModule.moduleContributions) { DebugModule(makeGraph: { let debugFactories: [String: () -> (any DebugAdapterSession)?] = [ + "java": { + CoreDebugAdapterProtocolSession( + adapterID: "java", + transport: MacJavaDebugAdapterTransport( + portResolver: { rootURL in + guard let capability = try await moduleRuntime + .activateCapability(.languageIntelligence) + as? LanguageIntelligenceCapability else { + throw MacJavaDebugAdapterTransport.TransportError + .languageIntelligenceUnavailable + } + return try await capability.sessions.startJavaDebugServer( + rootURL: rootURL + ) + } + ), + core: rustCore, + deadlineScheduler: MacDebugOperationDeadlineScheduler() + ) + }, "go": { guard let executable = runtimeService.executableOnPath("dlv") else { return nil } return DebugAdapterProtocolSession( @@ -388,16 +408,7 @@ final class MacServiceContainer { ) } ) - let graph = DebugFeatureGraph( - java: JavaDebugService( - runtimeService: runtimeService, - processFactory: { MacStreamingProcess(processRegistry: processRegistry, moduleID: .debug) }, - fileStorage: fileStorage, - javaMavenOperations: javaMavenOperations, - runConfigurationOperations: runConfigurationStore - ), - adapterSessions: adapterSessions - ) + let graph = DebugFeatureGraph(adapterSessions: adapterSessions) return graph }) }) diff --git a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacJDTLSLaunchResourceResolver.swift b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacJDTLSLaunchResourceResolver.swift index d1fe54cc..13f090bf 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacJDTLSLaunchResourceResolver.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacJDTLSLaunchResourceResolver.swift @@ -9,6 +9,7 @@ enum MacJDTLSLaunchResourceResolution { struct MacJDTLSLaunchResourceResolver { private static let equinoxLauncherPrefix = "org.eclipse.equinox.launcher_" + private static let javaDebugBundlePrefix = "com.microsoft.java.debug.plugin-" private let bundledJdtlsRootURL: URL? private let fileManager: FileManager @@ -39,15 +40,20 @@ struct MacJDTLSLaunchResourceResolver { let pluginsURL = rootURL.appendingPathComponent("plugins", isDirectory: true) let configurationURL = configurationDirectory(in: rootURL) let lombokURL = rootURL.appendingPathComponent("lombok/lombok.jar") + let javaDebugURL = try firstJavaDebugBundle( + in: rootURL.appendingPathComponent("java-debug", isDirectory: true) + ) guard let launcherURL = try firstEquinoxLauncher(in: pluginsURL), let configurationURL, + let javaDebugURL, fileManager.fileExists(atPath: lombokURL.path) else { continue } return JDTLSLaunchResources( launcherJarURL: launcherURL, configurationDirectoryURL: configurationURL, - lombokAgentURL: lombokURL + lombokAgentURL: lombokURL, + javaDebugBundleURL: javaDebugURL ) } throw ResolutionError.incompleteInstallation @@ -82,10 +88,30 @@ struct MacJDTLSLaunchResourceResolver { } private func firstEquinoxLauncher(in pluginsURL: URL) throws -> URL? { + try firstRegularFile( + in: pluginsURL, + prefix: Self.equinoxLauncherPrefix, + suffix: ".jar" + ) + } + + private func firstJavaDebugBundle(in directoryURL: URL) throws -> URL? { + try firstRegularFile( + in: directoryURL, + prefix: Self.javaDebugBundlePrefix, + suffix: ".jar" + ) + } + + private func firstRegularFile( + in directoryURL: URL, + prefix: String, + suffix: String + ) throws -> URL? { let entries: [URL] do { entries = try fileManager.contentsOfDirectory( - at: pluginsURL, + at: directoryURL, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles] ) @@ -95,8 +121,7 @@ struct MacJDTLSLaunchResourceResolver { return try entries .filter { url in let name = url.lastPathComponent - guard name.hasPrefix(Self.equinoxLauncherPrefix), - name.hasSuffix(".jar") else { return false } + guard name.hasPrefix(prefix), name.hasSuffix(suffix) else { return false } return try url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile == true } .sorted { $0.lastPathComponent < $1.lastPathComponent } @@ -120,7 +145,7 @@ struct MacJDTLSLaunchResourceResolver { var errorDescription: String? { "Expected an Equinox launcher JAR, a macOS configuration directory, " - + "and lombok/lombok.jar in the selected JDTLS installation." + + "lombok/lombok.jar, and the Java Debug Server bundle in the selected JDTLS installation." } } } diff --git a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeDiscovery.swift b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeDiscovery.swift index 97240fe4..9181dea4 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeDiscovery.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeDiscovery.swift @@ -15,16 +15,6 @@ enum MacRuntimeDiscovery { return RuntimeDiscoveryResult(javaRuntimes: javaRuntimes, mavenRuntimes: mavenRuntimes) } - static func systemJDBExecutable() -> URL? { - [ - "/opt/homebrew/bin/jdb", - "/usr/local/bin/jdb", - "/usr/bin/jdb" - ] - .map(URL.init(fileURLWithPath:)) - .first(where: { FileManager.default.isExecutableFile(atPath: $0.path) }) - } - static func systemMavenExecutable(environment: [String: String]) -> URL? { discoverMavenExecutables(environment: environment).first } diff --git a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeLocator.swift b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeLocator.swift index c90c77c5..4cc1aadc 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeLocator.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeLocator.swift @@ -68,10 +68,6 @@ struct MacRuntimeLocator: RuntimeLocator { MacRuntimeDiscovery.probeMaven(executableURL) } - func systemJDBExecutable() -> URL? { - MacRuntimeDiscovery.systemJDBExecutable() - } - /// Returns the bundled JDK matching the current process architecture. /// Universal apps carry separate runtimes because a JDK contains native /// libraries throughout its installation. Single-architecture and legacy diff --git a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeToolDiscovery.swift b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeToolDiscovery.swift index 8c7c70ba..d864e619 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeToolDiscovery.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeToolDiscovery.swift @@ -171,7 +171,7 @@ struct MacRuntimeToolDiscovery: RuntimeToolDiscovery { command: command, displayName: "Java Debug Adapter", summary: "A Java DAP adapter was not found.", - recovery: "Set LITHE_JAVA_DEBUG_PATH to a stdio DAP adapter; Lithe will keep using JDB until one is available." + recovery: "Reinstall Lithe's bundled Java language and Debug Adapter resources." ) default: return RuntimeToolGuidance( diff --git a/macos/Sources/Lithe/Services/Debug/DebugLaunchConfigurationResolver.swift b/macos/Sources/Lithe/Services/Debug/DebugLaunchConfigurationResolver.swift index dc11ed28..ed95ce5b 100644 --- a/macos/Sources/Lithe/Services/Debug/DebugLaunchConfigurationResolver.swift +++ b/macos/Sources/Lithe/Services/Debug/DebugLaunchConfigurationResolver.swift @@ -3,6 +3,7 @@ import LitheCoreContracts enum DebugLaunchConfigurationResolutionError: LocalizedError, Equatable { case unsupportedProvider(String) + case javaLaunchTargetUnavailable case noRustBinaryConfiguration case rustExecutableNotBuilt(URL, binary: String) @@ -10,6 +11,8 @@ enum DebugLaunchConfigurationResolutionError: LocalizedError, Equatable { switch self { case .unsupportedProvider(let provider): return "The \(provider) Debug Adapter is not installed yet." + case .javaLaunchTargetUnavailable: + return "The Java language service could not resolve a main class for this file." case .noRustBinaryConfiguration: return "No Cargo binary run configuration matches this Rust file." case .rustExecutableNotBuilt(let url, let binary): @@ -47,15 +50,20 @@ struct DebugLaunchConfigurationResolver { workspaceURL: URL, configurations: [RunConfiguration], selectedConfiguration: RunConfiguration?, + javaTarget: JavaDebugLaunchTarget? = nil, options: (RunConfiguration) -> RunOptions ) throws -> DebugLaunchConfiguration { switch provider.id { case "java": + guard let javaTarget else { + throw DebugLaunchConfigurationResolutionError.javaLaunchTargetUnavailable + } return javaConfiguration( documentURL: documentURL, workspaceURL: workspaceURL, configurations: configurations, selectedConfiguration: selectedConfiguration, + target: javaTarget, options: options ) case "python": @@ -101,16 +109,26 @@ struct DebugLaunchConfigurationResolver { workspaceURL: URL, configurations: [RunConfiguration], selectedConfiguration: RunConfiguration?, + target: JavaDebugLaunchTarget, options: (RunConfiguration) -> RunOptions ) -> DebugLaunchConfiguration { let configuration = selectedConfiguration.flatMap { selected in selected.kind.isMavenBacked ? selected : nil } var arguments: [String: ToolingJSONValue] = [ - "mainClass": .string(inferJavaMainClass(documentURL: documentURL, workspaceURL: workspaceURL)), + "mainClass": .string(target.mainClass), "cwd": .string(workspaceURL.standardizedFileURL.path), "console": .string("internalConsole") ] + if let projectName = target.projectName { + arguments["projectName"] = .string(projectName) + } + if !target.modulePaths.isEmpty { + arguments["modulePaths"] = .array(target.modulePaths.map(ToolingJSONValue.string)) + } + if !target.classPaths.isEmpty { + arguments["classPaths"] = .array(target.classPaths.map(ToolingJSONValue.string)) + } if let configuration { let runOptions = options(configuration) let programArguments = RunArgumentParser.parse(runOptions.arguments) @@ -124,9 +142,6 @@ struct DebugLaunchConfigurationResolver { if !vmArguments.isEmpty { arguments["vmArgs"] = .array(vmArguments.map(ToolingJSONValue.string)) } - if let modulePath = configuration.modulePath, !modulePath.isEmpty { - arguments["projectName"] = .string(modulePath) - } } return DebugLaunchConfiguration( name: configuration?.name ?? documentURL.lastPathComponent, @@ -135,26 +150,6 @@ struct DebugLaunchConfigurationResolver { ) } - private func inferJavaMainClass(documentURL: URL, workspaceURL: URL) -> String { - let file = documentURL.standardizedFileURL - let root = workspaceURL.standardizedFileURL - let relative = file.path.hasPrefix(root.path + "/") - ? String(file.path.dropFirst(root.path.count + 1)) - : file.lastPathComponent - let components = relative.split(separator: "/").map(String.init) - let sourceRoots = ["src/main/java", "src/test/java", "src/main/kotlin"] - let sourceRootIndex: Int? = sourceRoots.compactMap { sourceRoot -> Int? in - let rootComponents = sourceRoot.split(separator: "/").map(String.init) - guard components.count > rootComponents.count, - Array(components.prefix(rootComponents.count)) == rootComponents else { return nil } - return rootComponents.count - }.first - let classComponents = Array(components.dropFirst(sourceRootIndex ?? max(0, components.count - 1))) - let className = classComponents.joined(separator: ".") - .replacingOccurrences(of: ".java", with: "") - return className.isEmpty ? file.deletingPathExtension().lastPathComponent : className - } - private func nodeConfiguration( documentURL: URL, workspaceURL: URL, diff --git a/macos/Sources/Lithe/Services/Java/JavaDebugService.swift b/macos/Sources/Lithe/Services/Java/JavaDebugService.swift deleted file mode 100644 index 5e0b5fb8..00000000 --- a/macos/Sources/Lithe/Services/Java/JavaDebugService.swift +++ /dev/null @@ -1,855 +0,0 @@ -import Foundation - -@MainActor -final class JavaDebugService: ObservableObject { - @Published private(set) var state: JavaDebugSessionState = .idle - @Published private(set) var output = "" - @Published private(set) var inspectionTitle: String? - @Published private(set) var inspectionOutput = "" - @Published private(set) var variables: [JavaDebugVariable] = [] - @Published private(set) var threads: [JavaDebugThread] = [] - @Published private(set) var callStack: [JavaDebugStackFrame] = [] - @Published private(set) var expandingVariableID: String? - @Published private(set) var exceptionMessage: String? - @Published private(set) var port: Int? - @Published private(set) var breakpoints: [JavaDebugBreakpoint] = [] - @Published var targetKind: JavaDebugTargetKind = .currentFile - @Published var remoteHost = "127.0.0.1" - @Published var remotePort = "5005" - @Published var remoteJavaHomePath = "" - - private var debuggeeProcess: (any StreamingProcess)? - private var jdbProcess: (any StreamingProcess)? - private var sessionID = UUID() - private var debugClassName: String? - private var activeJDBURL: URL? - private var activeJDBHost = "127.0.0.1" - private var launchesDebuggee = false - private var debuggeeOperationID: String? - private var jdbOperationID: String? - @Published private(set) var runningTargetTitle: String? - private var didBootstrap = false - private let maximumOutputCharacters = 400_000 - private let runtimeService: ProjectRuntimeService - private let processFactory: () -> any StreamingProcess - private let fileStorage: any FileStorage - private let javaMavenOperations: any JavaMavenOperations - private let runConfigurationOperations: any RunConfigurationOperations - - init( - runtimeService: ProjectRuntimeService, - processFactory: @escaping () -> any StreamingProcess, - fileStorage: any FileStorage, - javaMavenOperations: any JavaMavenOperations, - runConfigurationOperations: any RunConfigurationOperations - ) { - self.runtimeService = runtimeService - self.processFactory = processFactory - self.fileStorage = fileStorage - self.javaMavenOperations = javaMavenOperations - self.runConfigurationOperations = runConfigurationOperations - } - - private enum InspectionKind { - case threads - case stack - case locals - case dump(variableID: String) - case evaluate - } - - private var inspectionKind: InspectionKind? - - var isSessionActive: Bool { state != .idle } - var canControl: Bool { jdbProcess?.isRunning == true } - - func start(fileURL: URL, sourceText: String, projectURL: URL?, options: RunOptions) { - stop() - guard fileURL.pathExtension.lowercased() == "java" else { - fail("Select a Java file before starting Debug.") - return - } - guard let projectURL else { - fail("Open a project before starting Debug.") - return - } - let debugPort = Self.nextPort() - guard let currentFile = relativePath(for: fileURL, root: projectURL) else { - fail("The selected Java file is outside the project.") - return - } - let plan: SharedLaunchPlan - do { - plan = try runConfigurationOperations.launchPlan( - at: projectURL, - configurationID: RunConfiguration.currentFileID, - currentFile: currentFile, - classPath: nil, - debugPort: debugPort - ) - guard plan.toolchainID == "project-jdk" else { - throw RunConfigurationOperationFailure(message: "Current File does not use the project JDK.") - } - } catch { - fail(error.localizedDescription) - return - } - guard let javaURL = runtimeService.javaExecutableURL(overridePath: options.javaHomePath), - let jdbURL = runtimeService.jdbExecutableURL(overridePath: options.javaHomePath) else { - fail("No JDK with jdb was found. Set JDK Home or JAVA_HOME.") - return - } - - let id = prepareSession( - port: debugPort, - host: "127.0.0.1", - title: fileURL.lastPathComponent, - launchesDebuggee: true - ) - debugClassName = className(for: fileURL, sourceText: sourceText) - startDebuggee( - executable: javaURL, - arguments: plan.arguments, - workingDirectory: workingDirectory( - plan.workingDirectory, - fallback: fileURL.deletingLastPathComponent(), - relativeTo: projectURL - ), - environment: runtimeService.environment(for: .java, javaHomeOverride: options.javaHomePath), - jdbURL: jdbURL, - host: "127.0.0.1", - port: debugPort, - sessionID: id - ) - } - - func startMaven( - configuration: RunConfiguration, - project: MavenProject, - projectURL: URL, - options: RunOptions - ) { - stop() - guard configuration.kind.isMavenBacked else { - fail("Select a Spring Boot or Maven Module configuration before starting Debug.") - return - } - let debugPort = Self.nextPort() - let plan: SharedLaunchPlan - do { - plan = try runConfigurationOperations.launchPlan( - at: projectURL, - configurationID: configuration.id, - currentFile: nil, - classPath: nil, - debugPort: debugPort - ) - guard plan.toolchainID == "project-maven" else { - throw RunConfigurationOperationFailure(message: "The selected configuration does not use Maven.") - } - } catch { - fail(error.localizedDescription) - return - } - let mavenJavaHome = options.mavenJavaHomePath.isEmpty - ? options.javaHomePath - : options.mavenJavaHomePath - guard runtimeService.mavenJavaHomeURL(overridePath: mavenJavaHome) != nil, - let jdbURL = runtimeService.jdbExecutableURL( - overridePath: mavenJavaHome, - for: .maven - ) else { - fail("No JDK with jdb was found. Set JDK Home or JAVA_HOME.") - return - } - - let id = prepareSession( - port: debugPort, - host: "127.0.0.1", - title: configuration.name, - launchesDebuggee: true - ) - guard let executable = runtimeService.mavenExecutable( - for: project, - overridePath: options.mavenExecutablePath - ) else { - fail("No Maven executable was found. Edit this service configuration.") - return - } - append("$ " + executable.lastPathComponent + " " + plan.arguments.joined(separator: " ") + "\n\n") - startDebuggee( - executable: executable, - arguments: plan.arguments, - workingDirectory: workingDirectory( - plan.workingDirectory, - fallback: project.rootURL, - relativeTo: projectURL - ), - environment: runtimeService.environment(for: .maven, javaHomeOverride: mavenJavaHome), - jdbURL: jdbURL, - host: "127.0.0.1", - port: debugPort, - sessionID: id - ) - } - - func attachRemote() { - stop() - let host = remoteHost.trimmingCharacters(in: .whitespacesAndNewlines) - guard !host.isEmpty else { - fail("Enter a remote JVM host.") - return - } - guard let port = Int(remotePort.trimmingCharacters(in: .whitespacesAndNewlines)), - (1...65_535).contains(port) else { - fail("Enter a valid JDWP port.") - return - } - guard runtimeService.javaExecutableURL(overridePath: remoteJavaHomePath) != nil, - let jdbURL = runtimeService.jdbExecutableURL(overridePath: remoteJavaHomePath) else { - fail("No local JDK with jdb was found for the attach session.") - return - } - let id = prepareSession( - port: port, - host: host, - title: host + ":" + String(port), - launchesDebuggee: false - ) - append("Attach jdb to \(host):\(port)\n\n") - attachJDB(jdbURL: jdbURL, host: host, port: port, sessionID: id) - } - - func toggleBreakpoint(fileURL: URL, line: Int, className: String) { - guard line > 0 else { return } - let normalizedURL = fileURL.standardizedFileURL - let id = normalizedURL.path + ":" + String(line) - if let index = breakpoints.firstIndex(where: { $0.id == id }) { - let breakpoint = breakpoints.remove(at: index) - if canControl { - send("clear \(breakpoint.className):\(breakpoint.line)") - } - return - } - - let breakpoint = JavaDebugBreakpoint( - id: id, - fileURL: normalizedURL, - line: line, - className: className - ) - breakpoints.append(breakpoint) - breakpoints.sort { lhs, rhs in - if lhs.fileURL != rhs.fileURL { return lhs.fileURL.path < rhs.fileURL.path } - return lhs.line < rhs.line - } - if canControl { - send("stop at \(className):\(line)") - } - } - - func continueExecution() { - send("cont") - state = .running - } - - func pause() { - send("halt") - state = .paused - } - - func stepInto() { - send("step") - state = .running - } - - func stepOver() { - send("next") - state = .running - } - - func stepOut() { - send("step up") - state = .running - } - - func inspectThreads() { - inspect(title: "Threads", command: "threads", kind: .threads) - } - - func inspectStack() { - inspect(title: "Call Stack", command: "where all", kind: .stack) - } - - func inspectVariables() { - inspect(title: "Local Variables", command: "locals", kind: .locals) - } - - func evaluate(_ rawExpression: String) { - let expression = rawExpression.trimmingCharacters(in: .whitespacesAndNewlines) - guard !expression.isEmpty else { return } - guard canControl else { - inspectionTitle = "Evaluate" - inspectionOutput = "Start or pause a debug session before evaluating an expression.\n" - inspectionKind = .evaluate - return - } - inspectionTitle = "Evaluate" - inspectionOutput = "> print \(expression)\n" - inspectionKind = .evaluate - expandingVariableID = nil - send("print \(expression)") - } - - func toggleVariable(_ variable: JavaDebugVariable) { - guard variable.canExpand else { return } - if variable.isExpanded { - updateVariable(variable.id) { $0.isExpanded = false } - return - } - guard canControl else { return } - updateVariable(variable.id) { $0.isExpanded = true } - expandingVariableID = variable.id - inspectionTitle = "Local Variables" - inspectionKind = .dump(variableID: variable.id) - inspectionOutput = "> dump \(variable.expression)\n" - send("dump \(variable.expression)") - } - - func clearOutput() { - output = "" - inspectionOutput = "" - variables = [] - threads = [] - callStack = [] - expandingVariableID = nil - exceptionMessage = nil - } - - func stop() { - sessionID = UUID() - if let jdbProcess, jdbProcess.isRunning { - try? jdbProcess.send(Data("quit\n".utf8)) - jdbProcess.stop() - } - debuggeeProcess?.stop() - debuggeeProcess = nil - jdbProcess = nil - debuggeeOperationID = nil - jdbOperationID = nil - didBootstrap = false - debugClassName = nil - activeJDBURL = nil - activeJDBHost = "127.0.0.1" - launchesDebuggee = false - runningTargetTitle = nil - port = nil - inspectionTitle = nil - inspectionOutput = "" - variables = [] - threads = [] - callStack = [] - expandingVariableID = nil - exceptionMessage = nil - inspectionKind = nil - state = .idle - } - - func reset() { - stop() - output = "" - breakpoints = [] - targetKind = .currentFile - remoteHost = "127.0.0.1" - remotePort = "5005" - remoteJavaHomePath = "" - } - - func className(for fileURL: URL, sourceText: String) -> String { - let simpleName = fileURL.deletingPathExtension().lastPathComponent - return javaMavenOperations.className(source: sourceText, simpleName: simpleName) ?? simpleName - } - - private static func nextPort() -> Int { - Int.random(in: 49_152...60_000) - } - - private func relativePath(for fileURL: URL, root: URL) -> String? { - let file = fileURL.standardizedFileURL.path - let prefix = root.standardizedFileURL.path + "/" - guard file.hasPrefix(prefix) else { return nil } - return String(file.dropFirst(prefix.count)) - } - - private func prepareSession( - port: Int?, - host: String, - title: String, - launchesDebuggee: Bool - ) -> UUID { - let id = UUID() - sessionID = id - self.port = port - activeJDBHost = host - runningTargetTitle = title - self.launchesDebuggee = launchesDebuggee - activeJDBURL = nil - output = "" - inspectionTitle = nil - inspectionOutput = "" - variables = [] - threads = [] - callStack = [] - expandingVariableID = nil - exceptionMessage = nil - inspectionKind = nil - didBootstrap = false - state = .launching - return id - } - - private func startDebuggee( - executable: URL, - arguments: [String], - workingDirectory: URL, - environment: [String: String], - jdbURL: URL, - host: String, - port: Int, - sessionID: UUID - ) { - activeJDBURL = jdbURL - let debuggee = processFactory() - debuggee.onOutput = { [weak self] chunk in - Task { @MainActor [weak self] in - self?.appendDebuggeeOutput(chunk, sessionID: sessionID) - } - } - debuggee.onTermination = { [weak self] exitCode in - Task { @MainActor [weak self] in - guard let self, self.sessionID == sessionID else { return } - if self.state != .failed { - self.state = exitCode == 0 ? .finished : .failed - } - self.append("[debuggee exited with code \(exitCode)]\n") - } - } - let operationID = UUID().uuidString - debuggeeOperationID = operationID - debuggee.onStateChange = { [weak self] event in - Task { @MainActor [weak self] in - self?.consumeLifecycle(event, sessionID: sessionID, process: .debuggee) - } - } - - debuggeeProcess = debuggee - append("$ " + executable.lastPathComponent + " " + arguments.joined(separator: " ") + "\n\n") - do { - try debuggee.start(ProcessRequest( - operationID: operationID, - executablePath: executable.path, - arguments: arguments, - workingDirectory: workingDirectory.path, - environment: environment - )) - } catch { - fail("Unable to start debuggee: \(error.localizedDescription)") - return - } - - // Maven can buffer the JDWP listener line, so keep a delayed attach fallback. - Task { @MainActor [weak self, weak debuggee] in - try? await Task.sleep(for: .seconds(5)) - guard let self, - self.sessionID == sessionID, - self.jdbProcess == nil, - debuggee?.isRunning == true else { return } - self.attachJDB( - jdbURL: jdbURL, - host: host, - port: port, - sessionID: sessionID - ) - } - } - - private func attachJDB(jdbURL: URL, host: String, port: Int, sessionID: UUID) { - guard self.sessionID == sessionID, - jdbProcess == nil else { return } - - let jdb = processFactory() - jdb.onOutput = { [weak self] chunk in - Task { @MainActor [weak self] in - self?.appendJDBOutput(chunk, sessionID: sessionID) - } - } - jdb.onTermination = { [weak self] exitCode in - Task { @MainActor [weak self] in - guard let self, self.sessionID == sessionID else { return } - if self.state == .launching || self.state == .running { - self.state = .failed - self.append("[jdb exited with code \(exitCode)]\n") - } - self.jdbProcess = nil - } - } - let operationID = UUID().uuidString - jdbOperationID = operationID - jdb.onStateChange = { [weak self] event in - Task { @MainActor [weak self] in - self?.consumeLifecycle(event, sessionID: sessionID, process: .jdb) - } - } - - jdbProcess = jdb - do { - try jdb.start(ProcessRequest( - operationID: operationID, - executablePath: jdbURL.path, - arguments: ["-J-Duser.language=en", "-J-Duser.country=US", "-attach", "\(host):\(port)"], - keepsStandardInputOpen: true - )) - } catch { - fail("Unable to start jdb: \(error.localizedDescription)") - return - } - - Task { @MainActor [weak self, weak jdb] in - try? await Task.sleep(for: .milliseconds(900)) - guard let self, - self.sessionID == sessionID, - jdb?.isRunning == true, - !self.didBootstrap else { return } - self.didBootstrap = true - for breakpoint in self.breakpoints { - self.send("stop at \(breakpoint.className):\(breakpoint.line)") - } - if self.launchesDebuggee { - self.send("run") - self.state = .running - } else { - self.state = .paused - } - } - } - - private func appendDebuggeeOutput(_ chunk: String, sessionID: UUID) { - guard self.sessionID == sessionID else { return } - append("[debuggee] " + chunk) - _ = detectException(in: chunk) - if chunk.localizedCaseInsensitiveContains("Listening for transport") { - guard let port, let activeJDBURL else { return } - attachJDB( - jdbURL: activeJDBURL, - host: activeJDBHost, - port: port, - sessionID: sessionID - ) - } - } - - private func appendJDBOutput(_ chunk: String, sessionID: UUID) { - guard self.sessionID == sessionID else { return } - append("[jdb] " + chunk) - let didDetectException = detectException(in: chunk) - if inspectionTitle != nil { - inspectionOutput.append(chunk) - if inspectionOutput.count > 80_000 { - inspectionOutput.removeFirst(inspectionOutput.count - 80_000) - } - refreshInspectionData() - } - if chunk.contains("Breakpoint hit:") || chunk.contains("Step completed:") || chunk.contains("Method entered:") || didDetectException { - state = .paused - } - } - - private func inspect(title: String, command: String, kind: InspectionKind) { - inspectionTitle = title - inspectionOutput = "> \(command)\n" - inspectionKind = kind - expandingVariableID = nil - switch kind { - case .threads: threads = [] - case .stack: callStack = [] - case .locals: variables = [] - case .dump: break - case .evaluate: break - } - send(command) - } - - private func refreshInspectionData() { - guard let inspectionKind else { return } - switch inspectionKind { - case .threads: - threads = Self.parseThreads(inspectionOutput) - case .stack: - callStack = Self.parseStackFrames(inspectionOutput) - case .locals: - variables = Self.parseVariables(inspectionOutput) - case .dump(let variableID): - guard let variable = variable(with: variableID) else { return } - let children = Self.parseDumpChildren(inspectionOutput, parent: variable) - guard !children.isEmpty else { return } - updateVariable(variableID) { - $0.children = children - $0.isExpanded = true - } - expandingVariableID = nil - case .evaluate: - break - } - } - - private func variable(with id: String, in values: [JavaDebugVariable]? = nil) -> JavaDebugVariable? { - let values = values ?? variables - for value in values { - if value.id == id { return value } - if let child = variable(with: id, in: value.children) { return child } - } - return nil - } - - @discardableResult - private func updateVariable( - _ id: String, - in values: inout [JavaDebugVariable], - update: (inout JavaDebugVariable) -> Void - ) -> Bool { - for index in values.indices { - if values[index].id == id { - update(&values[index]) - return true - } - if updateVariable(id, in: &values[index].children, update: update) { - return true - } - } - return false - } - - private func updateVariable( - _ id: String, - update: (inout JavaDebugVariable) -> Void - ) { - _ = updateVariable(id, in: &variables, update: update) - } - - private static func parseVariables(_ text: String) -> [JavaDebugVariable] { - var result: [JavaDebugVariable] = [] - for line in text.components(separatedBy: .newlines) { - guard let assignment = parseAssignment(line) else { continue } - let expression = assignment.name - guard !result.contains(where: { $0.id == expression }) else { continue } - result.append(JavaDebugVariable( - id: expression, - name: assignment.name, - expression: expression, - value: assignment.value, - children: [], - isExpanded: false, - isExpandable: looksExpandable(assignment.value) - )) - } - return result - } - - private static func parseDumpChildren( - _ text: String, - parent: JavaDebugVariable - ) -> [JavaDebugVariable] { - var result: [JavaDebugVariable] = [] - for line in text.components(separatedBy: .newlines) { - guard let assignment = parseAssignment(line), - assignment.name != parent.name, - assignment.name != parent.expression else { continue } - let expression: String - if assignment.name.hasPrefix("[") { - expression = parent.expression + assignment.name - } else { - expression = parent.expression + "." + assignment.name - } - guard !result.contains(where: { $0.id == expression }) else { continue } - result.append(JavaDebugVariable( - id: expression, - name: assignment.name, - expression: expression, - value: assignment.value, - children: [], - isExpanded: false, - isExpandable: looksExpandable(assignment.value) - )) - } - return result - } - - private static func parseAssignment(_ line: String) -> (name: String, value: String)? { - let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty, - !trimmed.hasPrefix(">"), - !trimmed.hasSuffix(":"), - let separator = trimmed.range(of: " = ") else { return nil } - let name = String(trimmed[.. Bool { - if name.hasPrefix("[") && name.hasSuffix("]") { return true } - guard let first = name.unicodeScalars.first, - CharacterSet.letters.union(CharacterSet(charactersIn: "_$")).contains(first) else { - return false - } - return name.unicodeScalars.dropFirst().allSatisfy { - CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "_$")).contains($0) - } - } - - private static func looksExpandable(_ value: String) -> Bool { - let lowercased = value.lowercased() - return value.hasSuffix("{") || - lowercased.contains("instance of ") || - lowercased.contains("[length") || - lowercased.contains("array") - } - - private static func parseThreads(_ text: String) -> [JavaDebugThread] { - var result: [JavaDebugThread] = [] - for line in text.components(separatedBy: .newlines) { - let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty, - !trimmed.lowercased().hasPrefix("group ") else { continue } - - let id: String - let name: String - let status: String - if let colon = trimmed.firstIndex(of: ":"), - Int(trimmed[.. [JavaDebugStackFrame] { - var result: [JavaDebugStackFrame] = [] - for line in text.components(separatedBy: .newlines) { - let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.hasPrefix("[") else { continue } - guard let closing = trimmed.firstIndex(of: "]"), - let level = Int(trimmed[trimmed.index(after: trimmed.startIndex).. Bool { - for line in text.components(separatedBy: .newlines) { - let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) - let lowercased = trimmed.lowercased() - guard lowercased.contains("exception") || lowercased.hasPrefix("caused by:") else { continue } - if lowercased.contains("exception occurred") || - lowercased.hasPrefix("exception in thread") || - lowercased.hasPrefix("uncaught exception") || - lowercased.hasPrefix("caused by:") { - exceptionMessage = trimmed - return true - } - } - return false - } - - private func send(_ command: String) { - guard let jdbProcess, jdbProcess.isRunning else { return } - try? jdbProcess.send(Data((command + "\n").utf8)) - } - - private func append(_ value: String) { - output.append(value.replacingOccurrences(of: "\r", with: "")) - if output.count > maximumOutputCharacters { - output.removeFirst(output.count - maximumOutputCharacters) - } - } - - private func fail(_ message: String) { - output = message + "\n" - state = .failed - debuggeeProcess?.stop() - } - - private enum ProcessKind: Equatable { - case debuggee - case jdb - } - - private func consumeLifecycle( - _ event: ProcessLifecycleEvent, - sessionID: UUID, - process: ProcessKind - ) { - guard self.sessionID == sessionID else { return } - let expectedID = process == .debuggee ? debuggeeOperationID : jdbOperationID - guard event.operationID == expectedID else { return } - switch event.state { - case .starting: - state = .launching - case .running: - if process == .jdb, didBootstrap { state = launchesDebuggee ? .running : .paused } - case .stopping: - break - case .finished: - break - case .failed: - state = .failed - if let message = event.message, !message.isEmpty { - append("[" + (process == .jdb ? "jdb" : "debuggee") + ": " + message + "]\n") - } - } - } - - private func workingDirectory(_ path: String, fallback: URL, relativeTo projectURL: URL?) -> URL { - let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return fallback } - let expanded = (trimmed as NSString).expandingTildeInPath - let url = (expanded.hasPrefix("/") - ? URL(fileURLWithPath: expanded) - : URL(fileURLWithPath: expanded, relativeTo: projectURL ?? fallback) - ).standardizedFileURL - guard fileStorage.metadata(for: url)?.isDirectory == true else { - return fallback - } - return url - } - -} diff --git a/macos/Sources/Lithe/Services/Java/ProjectRuntimeService.swift b/macos/Sources/Lithe/Services/Java/ProjectRuntimeService.swift index bbbed017..dc711cca 100644 --- a/macos/Sources/Lithe/Services/Java/ProjectRuntimeService.swift +++ b/macos/Sources/Lithe/Services/Java/ProjectRuntimeService.swift @@ -213,22 +213,6 @@ final class ProjectRuntimeService: ObservableObject { return message } - func jdbExecutableURL( - overridePath: String? = nil, - for processKind: ProjectRuntimeProcessKind = .java - ) -> URL? { - let home = processKind == .maven - ? mavenJavaHomeURL(overridePath: overridePath) - : javaHomeURL(overridePath: overridePath) - if let home { - let candidate = home.appendingPathComponent("bin/jdb") - if runtimeLocator.isExecutable(at: candidate) { - return candidate - } - } - return runtimeLocator.systemJDBExecutable() - } - func mavenJavaHomeURL(overridePath: String? = nil) -> URL? { if let overridePath { let normalizedPath = normalizedOverridePath(overridePath) @@ -321,34 +305,17 @@ final class ProjectRuntimeService: ObservableObject { status: .jdkMissing, projectURL: projectURL, javaHomePath: nil, - javaExecutablePath: nil, - jdbExecutablePath: runtimeLocator.systemJDBExecutable()?.path + javaExecutablePath: nil ) return } let javaExecutable = javaHome.appendingPathComponent("bin/java") - let bundledJDB = javaHome.appendingPathComponent("bin/jdb") - let jdbExecutable = runtimeLocator.isExecutable(at: bundledJDB) - ? bundledJDB - : runtimeLocator.systemJDBExecutable() - guard let jdbExecutable else { - javaEnvironmentReport = JavaEnvironmentReport( - status: .jdbMissing, - projectURL: projectURL, - javaHomePath: javaHome.path, - javaExecutablePath: javaExecutable.path, - jdbExecutablePath: nil - ) - return - } - javaEnvironmentReport = JavaEnvironmentReport( status: .ready, projectURL: projectURL, javaHomePath: javaHome.path, - javaExecutablePath: javaExecutable.path, - jdbExecutablePath: jdbExecutable.path + javaExecutablePath: javaExecutable.path ) } diff --git a/macos/Sources/Lithe/Services/Language/LanguagePackRegistry.swift b/macos/Sources/Lithe/Services/Language/LanguagePackRegistry.swift index 68981229..d7aaa041 100644 --- a/macos/Sources/Lithe/Services/Language/LanguagePackRegistry.swift +++ b/macos/Sources/Lithe/Services/Language/LanguagePackRegistry.swift @@ -87,12 +87,6 @@ final class LanguagePackRegistry { private static func standardDebugAdapterDefinition(for id: String) -> StdioDebugAdapterLaunch? { switch id { - case "java": - return StdioDebugAdapterLaunch( - adapterID: "java", - executableNames: ["java-debug-adapter", "java-debug"], - arguments: ["--stdio"] - ) case "go": return StdioDebugAdapterLaunch( adapterID: "go", diff --git a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift index a80441d7..49ad6488 100644 --- a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift +++ b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift @@ -6,6 +6,14 @@ struct GenericDebugView: View { @EnvironmentObject private var model: AppModel @ObservedObject var feature: GenericDebugFeatureModel @State private var evaluateExpression = "" + @State private var editingBreakpoint: GenericDebugBreakpoint? + @State private var editingExceptionBreakpoint: GenericDebugExceptionBreakpoint? + @State private var functionBreakpointEditor: FunctionBreakpointEditorContext? + @State private var editingDataBreakpoint: GenericDebugDataBreakpoint? + @State private var editingVariable: DebugVariable? + @State private var watchEditor: WatchEditorContext? + @State private var smartStepTargets: [DebugStepInTarget] = [] + @State private var isSmartStepPickerPresented = false var body: some View { VStack(spacing: 0) { @@ -23,6 +31,71 @@ struct GenericDebugView: View { } } .litheWorkbenchSurface(LitheTheme.editor) + .sheet(item: $editingBreakpoint) { breakpoint in + BreakpointEditorView(breakpoint: breakpoint) { + feature.updateBreakpoint( + fileURL: breakpoint.fileURL, + line: breakpoint.line, + enabled: $0.enabled, + condition: $0.condition, + hitCondition: $0.hitCondition, + logMessage: $0.logMessage + ) + } + } + .sheet(item: $editingExceptionBreakpoint) { breakpoint in + ExceptionBreakpointEditorView(breakpoint: breakpoint) { + feature.updateExceptionBreakpoint( + breakpoint, + enabled: $0.enabled, + condition: $0.condition + ) + } + } + .sheet(item: $functionBreakpointEditor) { context in + FunctionBreakpointEditorView(breakpoint: context.breakpoint) { value in + if let breakpoint = context.breakpoint { + feature.updateFunctionBreakpoint( + breakpoint, + name: value.name, + enabled: value.enabled, + condition: value.condition, + hitCondition: value.hitCondition + ) + } else { + feature.addFunctionBreakpoint( + name: value.name, + condition: value.condition, + hitCondition: value.hitCondition + ) + } + } + } + .sheet(item: $editingDataBreakpoint) { breakpoint in + DataBreakpointEditorView(breakpoint: breakpoint) { value in + feature.updateDataBreakpoint( + breakpoint, + enabled: value.enabled, + accessType: value.accessType, + condition: value.condition, + hitCondition: value.hitCondition + ) + } + } + .sheet(item: $editingVariable) { variable in + VariableValueEditorView(variable: variable) { + feature.setVariable(variable, value: $0) + } + } + .sheet(item: $watchEditor) { context in + WatchEditorView(watch: context.watch) { expression in + if let watch = context.watch { + feature.updateWatch(watch, expression: expression) + } else { + feature.addWatch(expression) + } + } + } } private var header: some View { @@ -58,12 +131,71 @@ struct GenericDebugView: View { controlButton("arrow.down.to.line", help: "Step into", disabled: feature.state != .paused) { feature.execute(.stepIn) } + if feature.capabilities.supportsStepInTargetsRequest { + Button { + feature.requestSmartStepInto { result in + guard case .success(let targets) = result else { return } + if targets.count == 1, let target = targets.first { + feature.smartStepInto(target) + } else { + smartStepTargets = targets + isSmartStepPickerPresented = true + } + } + } label: { + Image(systemName: "arrow.down.right.and.arrow.up.left") + } + .litheIconButton() + .disabled(feature.state != .paused || feature.selectedFrameID == nil) + .help("Smart step into") + .popover(isPresented: $isSmartStepPickerPresented, arrowEdge: .bottom) { + VStack(alignment: .leading, spacing: 4) { + Text("Choose Step Target") + .font(.system(size: 11, weight: .semibold)) + .padding(.horizontal, 8) + .padding(.top, 6) + if smartStepTargets.isEmpty { + Text("No callable target at this location") + .font(LitheTheme.smallFont) + .foregroundStyle(LitheTheme.secondaryText) + .padding(8) + } else { + ForEach(smartStepTargets) { target in + Button(target.label) { + feature.smartStepInto(target) + isSmartStepPickerPresented = false + } + .buttonStyle(.plain) + .font(.system(size: 11, design: .monospaced)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + } + } + } + .frame(minWidth: 230) + .padding(.vertical, 4) + } + } controlButton("arrow.up.to.line", help: "Step out", disabled: feature.state != .paused) { feature.execute(.stepOut) } + if feature.capabilities.supportsStepBack { + controlButton("arrow.uturn.backward", help: "Step back", disabled: !feature.canStepBack) { + feature.execute(.stepBack) + } + } + if feature.capabilities.supportsRestartRequest { + controlButton("arrow.clockwise", help: "Restart", disabled: !feature.canRestart) { + feature.execute(.restart) + } + } Button { if feature.isSessionActive { - model.stopDebugging() + if feature.canTerminate { + feature.execute(.terminate) + } else { + model.stopDebugging() + } } else { model.startDebugging() } @@ -95,23 +227,273 @@ struct GenericDebugView: View { ScrollView { LazyVStack(alignment: .leading, spacing: 0) { Group { - sectionHeader("Breakpoints", count: feature.breakpoints.count) + breakpointSectionHeader if feature.breakpoints.isEmpty { placeholder("Click the editor gutter to add a breakpoint") } else { ForEach(feature.breakpoints) { breakpoint in HStack(spacing: 7) { - Image(systemName: breakpoint.verified ? "circle.fill" : "circle") - .font(.system(size: 8)) - .foregroundStyle(breakpoint.verified ? LitheTheme.error : LitheTheme.warning) - Text(breakpoint.title) - .font(.system(size: 11, design: .monospaced)) - .lineLimit(1) - Spacer(minLength: 0) + Button { + feature.setBreakpointEnabled( + breakpoint, + enabled: !breakpoint.enabled + ) + } label: { + Image(systemName: breakpointSymbol(breakpoint)) + .font(.system(size: 9)) + .foregroundStyle(breakpointColor(breakpoint)) + } + .buttonStyle(.plain) + .help(breakpoint.enabled ? "Disable breakpoint" : "Enable breakpoint") + Button { + model.openSourceLocation( + url: breakpoint.fileURL, + line: breakpoint.line, + column: breakpoint.column ?? 1 + ) + } label: { + VStack(alignment: .leading, spacing: 1) { + Text(breakpoint.title) + .font(.system(size: 11, design: .monospaced)) + .lineLimit(1) + if let detail = breakpointDetail(breakpoint) { + Text(detail) + .font(.system(size: 9.5, design: .monospaced)) + .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + Menu { + Button("Edit…") { editingBreakpoint = breakpoint } + Button(breakpoint.enabled ? "Disable" : "Enable") { + feature.setBreakpointEnabled( + breakpoint, + enabled: !breakpoint.enabled + ) + } + Divider() + Button("Remove", role: .destructive) { + feature.removeBreakpoint(breakpoint) + } + } label: { + Image(systemName: "ellipsis") + } + .menuStyle(.borderlessButton) + .fixedSize() } .help(breakpoint.message ?? breakpoint.title) .padding(.horizontal, 10) - .frame(height: 27) + .frame(minHeight: 31) + .opacity(breakpoint.enabled && !feature.areBreakpointsMuted ? 1 : 0.55) + .contextMenu { + Button("Edit…") { editingBreakpoint = breakpoint } + Button(breakpoint.enabled ? "Disable" : "Enable") { + feature.setBreakpointEnabled( + breakpoint, + enabled: !breakpoint.enabled + ) + } + Divider() + Button("Remove", role: .destructive) { + feature.removeBreakpoint(breakpoint) + } + } + } + } + if !feature.exceptionBreakpoints.isEmpty { + divider + sectionHeader("Exception Breakpoints", count: feature.exceptionBreakpoints.count) + ForEach(feature.exceptionBreakpoints) { breakpoint in + HStack(spacing: 7) { + Button { + feature.updateExceptionBreakpoint( + breakpoint, + enabled: !breakpoint.enabled, + condition: breakpoint.condition + ) + } label: { + Image(systemName: breakpoint.enabled ? "bolt.circle.fill" : "bolt.circle") + .font(.system(size: 10)) + .foregroundStyle( + breakpoint.enabled ? LitheTheme.error : LitheTheme.secondaryText + ) + } + .buttonStyle(.plain) + .help(breakpoint.enabled ? "Disable exception breakpoint" : "Enable exception breakpoint") + VStack(alignment: .leading, spacing: 1) { + Text(breakpoint.label) + .font(.system(size: 11)) + .lineLimit(1) + if let condition = breakpoint.condition { + Text("If: \(condition)") + .font(.system(size: 9.5, design: .monospaced)) + .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + if breakpoint.supportsCondition { + Button { + editingExceptionBreakpoint = breakpoint + } label: { + Image(systemName: "ellipsis") + } + .buttonStyle(.plain) + .help("Edit exception breakpoint") + } + } + .help(breakpoint.description ?? breakpoint.label) + .padding(.horizontal, 10) + .frame(minHeight: 31) + .opacity(breakpoint.enabled ? 1 : 0.55) + .contextMenu { + Button(breakpoint.enabled ? "Disable" : "Enable") { + feature.updateExceptionBreakpoint( + breakpoint, + enabled: !breakpoint.enabled, + condition: breakpoint.condition + ) + } + if breakpoint.supportsCondition { + Button("Edit Condition…") { + editingExceptionBreakpoint = breakpoint + } + } + } + } + } + if feature.capabilities.supportsFunctionBreakpoints + || !feature.functionBreakpoints.isEmpty { + divider + functionBreakpointSectionHeader + if feature.functionBreakpoints.isEmpty { + placeholder("Add a class or method name") + } else { + ForEach(feature.functionBreakpoints) { breakpoint in + HStack(spacing: 7) { + Button { + feature.setFunctionBreakpointEnabled( + breakpoint, + enabled: !breakpoint.enabled + ) + } label: { + Image(systemName: "function") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle( + breakpoint.enabled + ? (breakpoint.verified ? LitheTheme.error : LitheTheme.warning) + : LitheTheme.secondaryText + ) + } + .buttonStyle(.plain) + .help(breakpoint.enabled ? "Disable method breakpoint" : "Enable method breakpoint") + Button { + functionBreakpointEditor = FunctionBreakpointEditorContext( + breakpoint: breakpoint + ) + } label: { + VStack(alignment: .leading, spacing: 1) { + Text(breakpoint.name) + .font(.system(size: 11, design: .monospaced)) + .lineLimit(1) + if let detail = functionBreakpointDetail(breakpoint) { + Text(detail) + .font(.system(size: 9.5, design: .monospaced)) + .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + Menu { + Button("Edit…") { + functionBreakpointEditor = FunctionBreakpointEditorContext( + breakpoint: breakpoint + ) + } + Button(breakpoint.enabled ? "Disable" : "Enable") { + feature.setFunctionBreakpointEnabled( + breakpoint, + enabled: !breakpoint.enabled + ) + } + Divider() + Button("Remove", role: .destructive) { + feature.removeFunctionBreakpoint(breakpoint) + } + } label: { + Image(systemName: "ellipsis") + } + .menuStyle(.borderlessButton) + .fixedSize() + } + .padding(.horizontal, 10) + .frame(minHeight: 31) + .opacity(breakpoint.enabled ? 1 : 0.55) + } + } + } + if feature.capabilities.supportsDataBreakpoints + || !feature.dataBreakpoints.isEmpty { + divider + sectionHeader("Field Breakpoints", count: feature.dataBreakpoints.count) + if feature.dataBreakpoints.isEmpty { + placeholder("Right-click a field while paused to add a breakpoint") + } else { + ForEach(feature.dataBreakpoints) { breakpoint in + HStack(spacing: 7) { + Button { + feature.setDataBreakpointEnabled( + breakpoint, + enabled: !breakpoint.enabled + ) + } label: { + Image(systemName: "eye.circle.fill") + .font(.system(size: 10)) + .foregroundStyle( + breakpoint.enabled + ? (breakpoint.verified ? LitheTheme.error : LitheTheme.warning) + : LitheTheme.secondaryText + ) + } + .buttonStyle(.plain) + Button { editingDataBreakpoint = breakpoint } label: { + VStack(alignment: .leading, spacing: 1) { + Text(breakpoint.label) + .font(.system(size: 11, design: .monospaced)) + .lineLimit(1) + Text(dataBreakpointDetail(breakpoint)) + .font(.system(size: 9.5, design: .monospaced)) + .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + Menu { + Button("Edit…") { editingDataBreakpoint = breakpoint } + Button(breakpoint.enabled ? "Disable" : "Enable") { + feature.setDataBreakpointEnabled( + breakpoint, + enabled: !breakpoint.enabled + ) + } + Divider() + Button("Remove", role: .destructive) { + feature.removeDataBreakpoint(breakpoint) + } + } label: { Image(systemName: "ellipsis") } + .menuStyle(.borderlessButton) + .fixedSize() + } + .padding(.horizontal, 10) + .frame(minHeight: 31) + .opacity(breakpoint.enabled ? 1 : 0.55) + } } } } @@ -133,6 +515,17 @@ struct GenericDebugView: View { Image(systemName: "circle") Text(thread.name).lineLimit(1) } + .contextMenu { + if feature.capabilities.supportsSingleThreadExecutionRequests { + Button(feature.state == .paused ? "Resume Thread" : "Pause Thread") { + feature.executeThread( + feature.state == .paused ? .continueExecution : .pause, + thread: thread + ) + } + .disabled(feature.state != .paused && feature.state != .running) + } + } } } } @@ -174,9 +567,10 @@ struct GenericDebugView: View { if feature.variables.isEmpty { placeholder("Select a stack frame to inspect variables") } else { - ForEach(feature.variables) { variable in + ForEach(feature.visibleVariableRows) { row in + let variable = row.variable HStack(alignment: .firstTextBaseline, spacing: 6) { - Image(systemName: variable.isExpandable ? "chevron.right" : "circle.fill") + Image(systemName: variableSymbol(variable)) .font(.system(size: variable.isExpandable ? 8 : 4)) .foregroundStyle(LitheTheme.secondaryText) Text(variable.name) @@ -191,12 +585,78 @@ struct GenericDebugView: View { } .contentShape(Rectangle()) .onTapGesture { - if variable.isExpandable { - feature.loadVariables(reference: variable.variablesReference) + feature.toggleVariableExpansion(variable) + } + .padding(.leading, 10 + CGFloat(row.depth * 14)) + .padding(.trailing, 10) + .padding(.vertical, 5) + .contextMenu { + if feature.capabilities.supportsSetVariable, + variable.containerReference != nil { + Button("Set Value…") { editingVariable = variable } + } + if feature.capabilities.supportsDataBreakpoints, + variable.containerReference != nil { + Button("Break on Field Access…") { + feature.requestDataBreakpoint(for: variable) + } } } + } + } + } + + Group { + divider + watchSectionHeader + if feature.watches.isEmpty { + placeholder("Add an expression to watch while paused") + } else { + ForEach(feature.watches) { watch in + HStack(alignment: .firstTextBaseline, spacing: 6) { + Image(systemName: "eye") + .font(.system(size: 9)) + .foregroundStyle(LitheTheme.secondaryText) + VStack(alignment: .leading, spacing: 2) { + Text(watch.expression) + .font(.system(size: 10.5, design: .monospaced)) + .lineLimit(1) + if let error = watch.error { + Text(error) + .font(.system(size: 9.5)) + .foregroundStyle(LitheTheme.error) + .lineLimit(2) + } else if let value = watch.value { + HStack(spacing: 4) { + Text(value) + .foregroundStyle(LitheTheme.accent) + if let type = watch.type { + Text(type).foregroundStyle(LitheTheme.secondaryText) + } + } + .font(.system(size: 9.5, design: .monospaced)) + .lineLimit(2) + } else { + Text(feature.state == .paused ? "Evaluating…" : "Not available") + .font(.system(size: 9.5)) + .foregroundStyle(LitheTheme.secondaryText) + } + } + Spacer(minLength: 0) + } .padding(.horizontal, 10) .padding(.vertical, 5) + .contextMenu { + Button("Refresh") { feature.refreshWatches() } + .disabled(feature.state != .paused) + Button("Edit…") { + watchEditor = WatchEditorContext(watch: watch) + } + Divider() + Button("Remove", role: .destructive) { + feature.removeWatch(watch) + } + } } } } @@ -215,7 +675,12 @@ struct GenericDebugView: View { TextField("Evaluate expression", text: $evaluateExpression) .textFieldStyle(.plain) .font(.system(size: 11, design: .monospaced)) - .onSubmit { feature.evaluate(evaluateExpression) } + .onSubmit { addWatchExpression() } + Button { addWatchExpression() } label: { + Image(systemName: "plus.circle") + } + .litheIconButton() + .help("Add watch") Button { feature.evaluate(evaluateExpression) } label: { Image(systemName: "arrow.right.circle") } @@ -225,6 +690,37 @@ struct GenericDebugView: View { .frame(height: 32) } + private var watchSectionHeader: some View { + HStack { + Text("Watches") + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(LitheTheme.secondaryText) + Spacer() + Text(String(feature.watches.count)) + .font(.system(size: 9.5, design: .monospaced)) + .foregroundStyle(LitheTheme.secondaryText) + Button { feature.refreshWatches() } label: { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(.plain) + .disabled(feature.state != .paused || feature.watches.isEmpty) + .help("Refresh watches") + Button { watchEditor = WatchEditorContext(watch: nil) } label: { + Image(systemName: "plus") + } + .buttonStyle(.plain) + .help("Add watch") + } + .padding(.horizontal, 10) + .frame(height: 27) + .litheWorkbenchSurface(LitheTheme.toolHeader) + } + + private func addWatchExpression() { + feature.addWatch(evaluateExpression) + evaluateExpression = "" + } + private var output: some View { ScrollView([.vertical, .horizontal]) { VStack(alignment: .leading, spacing: 8) { @@ -288,6 +784,99 @@ struct GenericDebugView: View { .litheWorkbenchSurface(LitheTheme.toolHeader) } + private var breakpointSectionHeader: some View { + HStack { + Text("Breakpoints") + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(LitheTheme.secondaryText) + Spacer() + Text(String(feature.breakpoints.count)) + .font(.system(size: 9.5, design: .monospaced)) + .foregroundStyle(LitheTheme.secondaryText) + Menu { + Button(feature.areBreakpointsMuted ? "Unmute All" : "Mute All") { + feature.toggleBreakpointMute() + } + Button("Remove All", role: .destructive) { + feature.removeAllBreakpoints() + } + .disabled(feature.breakpoints.isEmpty) + } label: { + Image(systemName: feature.areBreakpointsMuted ? "speaker.slash.fill" : "ellipsis") + } + .menuStyle(.borderlessButton) + .fixedSize() + .help("Breakpoint actions") + } + .padding(.horizontal, 10) + .frame(height: 27) + .litheWorkbenchSurface(LitheTheme.toolHeader) + } + + private var functionBreakpointSectionHeader: some View { + HStack { + Text("Method Breakpoints") + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(LitheTheme.secondaryText) + Spacer() + Text(String(feature.functionBreakpoints.count)) + .font(.system(size: 9.5, design: .monospaced)) + .foregroundStyle(LitheTheme.secondaryText) + Button { + functionBreakpointEditor = FunctionBreakpointEditorContext(breakpoint: nil) + } label: { + Image(systemName: "plus") + } + .buttonStyle(.plain) + .help("Add method breakpoint") + } + .padding(.horizontal, 10) + .frame(height: 27) + .litheWorkbenchSurface(LitheTheme.toolHeader) + } + + private func breakpointSymbol(_ breakpoint: GenericDebugBreakpoint) -> String { + if breakpoint.isLogpoint { return breakpoint.enabled ? "diamond.fill" : "diamond" } + return breakpoint.enabled ? "circle.fill" : "circle" + } + + private func variableSymbol(_ variable: DebugVariable) -> String { + guard variable.isExpandable else { return "circle.fill" } + if feature.isVariableLoading(variable) { return "hourglass" } + return feature.isVariableExpanded(variable) ? "chevron.down" : "chevron.right" + } + + private func breakpointColor(_ breakpoint: GenericDebugBreakpoint) -> Color { + guard breakpoint.enabled, !feature.areBreakpointsMuted else { + return LitheTheme.secondaryText + } + if breakpoint.isLogpoint { return LitheTheme.accent } + return breakpoint.verified ? LitheTheme.error : LitheTheme.warning + } + + private func breakpointDetail(_ breakpoint: GenericDebugBreakpoint) -> String? { + if let logMessage = breakpoint.logMessage { return "Log: \(logMessage)" } + if let condition = breakpoint.condition { return "If: \(condition)" } + if let hitCondition = breakpoint.hitCondition { return "Hit: \(hitCondition)" } + return breakpoint.message + } + + private func functionBreakpointDetail( + _ breakpoint: GenericDebugFunctionBreakpoint + ) -> String? { + if let condition = breakpoint.condition { return "If: \(condition)" } + if let hitCondition = breakpoint.hitCondition { return "Hit: \(hitCondition)" } + return breakpoint.message + } + + private func dataBreakpointDetail(_ breakpoint: GenericDebugDataBreakpoint) -> String { + var parts = [breakpoint.accessType ?? "access"] + if let condition = breakpoint.condition { parts.append("if \(condition)") } + if let hitCondition = breakpoint.hitCondition { parts.append("hit \(hitCondition)") } + if let message = breakpoint.message { parts.append(message) } + return parts.joined(separator: " · ") + } + private func placeholder(_ text: String) -> some View { Text(text) .font(LitheTheme.smallFont) @@ -320,6 +909,423 @@ struct GenericDebugView: View { } } +private struct BreakpointEditorValue { + let enabled: Bool + let condition: String? + let hitCondition: String? + let logMessage: String? +} + +private struct ExceptionBreakpointEditorValue { + let enabled: Bool + let condition: String? +} + +private struct FunctionBreakpointEditorContext: Identifiable { + let id = UUID() + let breakpoint: GenericDebugFunctionBreakpoint? +} + +private struct FunctionBreakpointEditorValue { + let name: String + let enabled: Bool + let condition: String? + let hitCondition: String? +} + +private struct FunctionBreakpointEditorView: View { + @Environment(\.dismiss) private var dismiss + let breakpoint: GenericDebugFunctionBreakpoint? + let onSave: (FunctionBreakpointEditorValue) -> Void + @State private var name: String + @State private var enabled: Bool + @State private var condition: String + @State private var hitCondition: String + + init( + breakpoint: GenericDebugFunctionBreakpoint?, + onSave: @escaping (FunctionBreakpointEditorValue) -> Void + ) { + self.breakpoint = breakpoint + self.onSave = onSave + _name = State(initialValue: breakpoint?.name ?? "") + _enabled = State(initialValue: breakpoint?.enabled ?? true) + _condition = State(initialValue: breakpoint?.condition ?? "") + _hitCondition = State(initialValue: breakpoint?.hitCondition ?? "") + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + Text(breakpoint == nil ? "Add Method Breakpoint" : "Edit Method Breakpoint") + .font(.system(size: 14, weight: .semibold)) + Spacer() + Toggle("Enabled", isOn: $enabled) + .toggleStyle(.checkbox) + } + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 10) { + functionEditorRow("Class or method", text: $name) + functionEditorRow("Condition", text: $condition) + functionEditorRow("Hit count", text: $hitCondition) + } + Spacer(minLength: 0) + HStack { + Spacer() + Button("Cancel") { dismiss() } + .keyboardShortcut(.cancelAction) + Button("Save") { + onSave(FunctionBreakpointEditorValue( + name: name.trimmingCharacters(in: .whitespacesAndNewlines), + enabled: enabled, + condition: optionalFunctionText(condition), + hitCondition: optionalFunctionText(hitCondition) + )) + dismiss() + } + .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .keyboardShortcut(.defaultAction) + } + } + .padding(18) + .frame(width: 440, height: 245) + .litheWorkbenchSurface(LitheTheme.editor) + } + + private func functionEditorRow(_ title: String, text: Binding) -> some View { + GridRow { + Text(title) + .font(.system(size: 11)) + .foregroundStyle(LitheTheme.secondaryText) + TextField("", text: text) + .textFieldStyle(.roundedBorder) + .font(.system(size: 11, design: .monospaced)) + .frame(minWidth: 300) + } + } + + private func optionalFunctionText(_ value: String) -> String? { + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) + return normalized.isEmpty ? nil : normalized + } +} + +private struct ExceptionBreakpointEditorView: View { + @Environment(\.dismiss) private var dismiss + let breakpoint: GenericDebugExceptionBreakpoint + let onSave: (ExceptionBreakpointEditorValue) -> Void + @State private var enabled: Bool + @State private var condition: String + + init( + breakpoint: GenericDebugExceptionBreakpoint, + onSave: @escaping (ExceptionBreakpointEditorValue) -> Void + ) { + self.breakpoint = breakpoint + self.onSave = onSave + _enabled = State(initialValue: breakpoint.enabled) + _condition = State(initialValue: breakpoint.condition ?? "") + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(breakpoint.label) + .font(.system(size: 14, weight: .semibold)) + if let description = breakpoint.description { + Text(description) + .font(.system(size: 10.5)) + .foregroundStyle(LitheTheme.secondaryText) + } + } + Spacer() + Toggle("Enabled", isOn: $enabled) + .toggleStyle(.checkbox) + } + TextField( + breakpoint.conditionDescription ?? "Exception condition", + text: $condition + ) + .textFieldStyle(.roundedBorder) + .font(.system(size: 11, design: .monospaced)) + Spacer(minLength: 0) + HStack { + Spacer() + Button("Cancel") { dismiss() } + .keyboardShortcut(.cancelAction) + Button("Save") { + let normalized = condition.trimmingCharacters(in: .whitespacesAndNewlines) + onSave(ExceptionBreakpointEditorValue( + enabled: enabled, + condition: normalized.isEmpty ? nil : normalized + )) + dismiss() + } + .keyboardShortcut(.defaultAction) + } + } + .padding(18) + .frame(width: 440, height: 190) + .litheWorkbenchSurface(LitheTheme.editor) + } +} + +private struct BreakpointEditorView: View { + @Environment(\.dismiss) private var dismiss + let breakpoint: GenericDebugBreakpoint + let onSave: (BreakpointEditorValue) -> Void + @State private var enabled: Bool + @State private var condition: String + @State private var hitCondition: String + @State private var logMessage: String + + init( + breakpoint: GenericDebugBreakpoint, + onSave: @escaping (BreakpointEditorValue) -> Void + ) { + self.breakpoint = breakpoint + self.onSave = onSave + _enabled = State(initialValue: breakpoint.enabled) + _condition = State(initialValue: breakpoint.condition ?? "") + _hitCondition = State(initialValue: breakpoint.hitCondition ?? "") + _logMessage = State(initialValue: breakpoint.logMessage ?? "") + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Breakpoint") + .font(.system(size: 14, weight: .semibold)) + Text(breakpoint.title) + .font(.system(size: 10.5, design: .monospaced)) + .foregroundStyle(LitheTheme.secondaryText) + } + Spacer() + Toggle("Enabled", isOn: $enabled) + .toggleStyle(.checkbox) + } + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 10) { + editorRow("Condition", text: $condition) + editorRow("Hit count", text: $hitCondition) + editorRow("Log message", text: $logMessage) + } + Spacer(minLength: 0) + HStack { + Spacer() + Button("Cancel") { dismiss() } + .keyboardShortcut(.cancelAction) + Button("Save") { + onSave(BreakpointEditorValue( + enabled: enabled, + condition: optional(condition), + hitCondition: optional(hitCondition), + logMessage: optional(logMessage) + )) + dismiss() + } + .keyboardShortcut(.defaultAction) + } + } + .padding(18) + .frame(width: 440, height: 245) + .litheWorkbenchSurface(LitheTheme.editor) + } + + private func editorRow(_ title: String, text: Binding) -> some View { + GridRow { + Text(title) + .font(.system(size: 11)) + .foregroundStyle(LitheTheme.secondaryText) + TextField("", text: text) + .textFieldStyle(.roundedBorder) + .font(.system(size: 11, design: .monospaced)) + .frame(minWidth: 300) + } + } + + private func optional(_ value: String) -> String? { + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) + return normalized.isEmpty ? nil : normalized + } +} + +private struct DataBreakpointEditorValue { + let enabled: Bool + let accessType: String? + let condition: String? + let hitCondition: String? +} + +private struct DataBreakpointEditorView: View { + @Environment(\.dismiss) private var dismiss + let breakpoint: GenericDebugDataBreakpoint + let onSave: (DataBreakpointEditorValue) -> Void + @State private var enabled: Bool + @State private var accessType: String + @State private var condition: String + @State private var hitCondition: String + + init( + breakpoint: GenericDebugDataBreakpoint, + onSave: @escaping (DataBreakpointEditorValue) -> Void + ) { + self.breakpoint = breakpoint + self.onSave = onSave + _enabled = State(initialValue: breakpoint.enabled) + _accessType = State(initialValue: breakpoint.accessType ?? breakpoint.accessTypes.first ?? "") + _condition = State(initialValue: breakpoint.condition ?? "") + _hitCondition = State(initialValue: breakpoint.hitCondition ?? "") + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Field Breakpoint") + .font(.system(size: 14, weight: .semibold)) + Text(breakpoint.label) + .font(.system(size: 10.5, design: .monospaced)) + .foregroundStyle(LitheTheme.secondaryText) + } + Spacer() + Toggle("Enabled", isOn: $enabled).toggleStyle(.checkbox) + } + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 10) { + if !breakpoint.accessTypes.isEmpty { + GridRow { + Text("Access") + .font(.system(size: 11)) + .foregroundStyle(LitheTheme.secondaryText) + Picker("", selection: $accessType) { + ForEach(breakpoint.accessTypes, id: \.self) { Text($0).tag($0) } + } + .labelsHidden() + } + } + dataEditorRow("Condition", text: $condition) + dataEditorRow("Hit count", text: $hitCondition) + } + Spacer(minLength: 0) + HStack { + Spacer() + Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction) + Button("Save") { + onSave(DataBreakpointEditorValue( + enabled: enabled, + accessType: optionalDataText(accessType), + condition: optionalDataText(condition), + hitCondition: optionalDataText(hitCondition) + )) + dismiss() + } + .keyboardShortcut(.defaultAction) + } + } + .padding(18) + .frame(width: 440, height: 245) + .litheWorkbenchSurface(LitheTheme.editor) + } + + private func dataEditorRow(_ title: String, text: Binding) -> some View { + GridRow { + Text(title) + .font(.system(size: 11)) + .foregroundStyle(LitheTheme.secondaryText) + TextField("", text: text) + .textFieldStyle(.roundedBorder) + .font(.system(size: 11, design: .monospaced)) + .frame(minWidth: 300) + } + } + + private func optionalDataText(_ value: String) -> String? { + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) + return normalized.isEmpty ? nil : normalized + } +} + +private struct WatchEditorContext: Identifiable { + let id = UUID() + let watch: GenericDebugWatch? +} + +private struct WatchEditorView: View { + @Environment(\.dismiss) private var dismiss + let watch: GenericDebugWatch? + let onSave: (String) -> Void + @State private var expression: String + + init(watch: GenericDebugWatch?, onSave: @escaping (String) -> Void) { + self.watch = watch + self.onSave = onSave + _expression = State(initialValue: watch?.expression ?? "") + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text(watch == nil ? "Add Watch" : "Edit Watch") + .font(.system(size: 14, weight: .semibold)) + TextField("Expression", text: $expression) + .textFieldStyle(.roundedBorder) + .font(.system(size: 11, design: .monospaced)) + HStack { + Spacer() + Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction) + Button("Save") { + onSave(expression) + dismiss() + } + .disabled(expression.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .keyboardShortcut(.defaultAction) + } + } + .padding(18) + .frame(width: 440, height: 150) + .litheWorkbenchSurface(LitheTheme.editor) + } +} + +private struct VariableValueEditorView: View { + @Environment(\.dismiss) private var dismiss + let variable: DebugVariable + let onSave: (String) -> Void + @State private var value: String + + init(variable: DebugVariable, onSave: @escaping (String) -> Void) { + self.variable = variable + self.onSave = onSave + _value = State(initialValue: variable.value) + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 2) { + Text("Set Variable Value") + .font(.system(size: 14, weight: .semibold)) + Text(variable.name) + .font(.system(size: 10.5, design: .monospaced)) + .foregroundStyle(LitheTheme.secondaryText) + } + TextField("Value", text: $value) + .textFieldStyle(.roundedBorder) + .font(.system(size: 11, design: .monospaced)) + HStack { + Spacer() + Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction) + Button("Set") { + onSave(value) + dismiss() + } + .keyboardShortcut(.defaultAction) + } + } + .padding(18) + .frame(width: 440, height: 170) + .litheWorkbenchSurface(LitheTheme.editor) + } +} + private extension DebugAdapterState { var title: String { switch self { diff --git a/macos/Sources/Lithe/Views/Debug/JavaDebugView.swift b/macos/Sources/Lithe/Views/Debug/JavaDebugView.swift deleted file mode 100644 index e3ebe2ec..00000000 --- a/macos/Sources/Lithe/Views/Debug/JavaDebugView.swift +++ /dev/null @@ -1,586 +0,0 @@ -import SwiftUI - -struct JavaDebugView: View { - @EnvironmentObject private var model: AppModel - @ObservedObject var service: JavaDebugFeatureModel - @ObservedObject var runService: JavaRunFeatureModel - @State private var evaluateExpression = "" - - init(feature: JavaDebugFeatureModel, runFeature: JavaRunFeatureModel) { - service = feature - runService = runFeature - } - - var body: some View { - VStack(spacing: 0) { - header - targetBar - Rectangle().fill(LitheTheme.divider).frame(height: 1) - - if service.isSessionActive || !service.output.isEmpty { - HStack(spacing: 0) { - inspector - .frame(width: 280) - Rectangle().fill(LitheTheme.divider).frame(width: 1) - outputView - } - } else { - emptyState - } - } - .litheWorkbenchSurface(LitheTheme.editor) - } - - private var targetBar: some View { - VStack(spacing: 7) { - Picker("Debug target", selection: $service.targetKind) { - ForEach(JavaDebugTargetKind.allCases) { target in - Label(LocalizedStringKey(target.title), systemImage: target.systemImage) - .tag(target) - } - } - .pickerStyle(.segmented) - .lithePointer() - .labelsHidden() - .disabled(service.isSessionActive) - - switch service.targetKind { - case .currentFile: - HStack(spacing: 7) { - LitheSystemIcon(systemImage: "doc.text") - .foregroundStyle(LitheTheme.secondaryText) - Text(model.activeDocument?.url.lastPathComponent ?? "Open a Java file") - .font(.system(size: 11.5)) - .foregroundStyle(LitheTheme.secondaryText) - .lineLimit(1) - Spacer(minLength: 0) - } - case .runConfiguration: - HStack(spacing: 7) { - if let selectedDebugConfiguration { - RunConfigurationIcon(kind: selectedDebugConfiguration.kind, size: 16) - } else { - LitheSystemIcon(systemImage: "shippingbox") - .foregroundStyle(LitheTheme.secondaryText) - } - Menu { - if debugConfigurations.isEmpty { - Text("No Spring Boot or Maven Module configurations") - } else { - ForEach(debugConfigurations) { configuration in - Button { - model.selectRunConfiguration(configuration) - } label: { - HStack { - RunConfigurationIcon(kind: configuration.kind, size: 16) - Text(configuration.name) - } - } - } - } - } label: { - HStack(spacing: 4) { - Text(selectedDebugConfiguration?.name ?? "Select a Spring Boot or Maven Module configuration") - .font(.system(size: 11.5)) - .foregroundStyle(LitheTheme.secondaryText) - .lineLimit(1) - Image(systemName: "chevron.down") - .font(.system(size: 8, weight: .bold)) - .foregroundStyle(LitheTheme.secondaryText) - } - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - } - .menuStyle(.borderlessButton) - .lithePointer() - .menuIndicator(.hidden) - Spacer(minLength: 0) - } - case .remote: - remoteFields - } - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .litheWorkbenchSurface(LitheTheme.toolHeader) - } - - private var remoteFields: some View { - HStack(spacing: 8) { - TextField("Host", text: $service.remoteHost) - .textFieldStyle(.roundedBorder) - .frame(width: 170) - TextField("JDWP port", text: $service.remotePort) - .textFieldStyle(.roundedBorder) - .frame(width: 90) - TextField("Local JDK Home (optional)", text: $service.remoteJavaHomePath) - .textFieldStyle(.roundedBorder) - Image(systemName: "lock.shield") - .foregroundStyle(LitheTheme.warning) - .help("JDWP is not encrypted; prefer localhost or an SSH tunnel") - } - .font(.system(size: 11.5)) - .disabled(service.isSessionActive) - } - - private var header: some View { - LitheToolWindowHeader( - title: "Debug", - systemImage: "ladybug", - ideaAssetPath: "toolwindows/toolWindowDebugger.svg", - subtitle: service.state.title, - onMinimize: { model.isDebugVisible = false } - ) { - if let runningTargetTitle = service.runningTargetTitle { - Text(runningTargetTitle) - .font(.system(size: 11.5, weight: .medium)) - .foregroundStyle(LitheTheme.secondaryText) - .lineLimit(1) - } - - if let port = service.port { - Text("JDWP \(port)") - .font(.system(size: 10.5, design: .monospaced)) - .foregroundStyle(LitheTheme.secondaryText) - } - - Spacer() - - Group { - Button { - model.toggleDebugBreakpointAtCaret() - } label: { - Image(systemName: "smallcircle.filled.circle") - } - .litheIconButton() - .help("Toggle breakpoint at caret") - - Button { - if canStop { - model.stopDebugging() - } else { - model.startDebugging() - } - } label: { - Image(systemName: canStop ? "stop.fill" : "play.fill") - } - .litheIconButton() - .foregroundStyle(canStop ? LitheTheme.warning : LitheTheme.success) - .help(canStop ? "Stop debugging" : "Start debugging") - - Button { - service.pause() - } label: { - Image(systemName: "pause.fill") - } - .litheIconButton() - .disabled(!service.canControl || service.state != .running) - .help("Pause") - } - - Button { - service.continueExecution() - } label: { - LitheSystemIcon(systemImage: "play.fill") - } - .litheIconButton() - .disabled(!service.canControl || service.state != .paused) - .help("Continue") - - Button { - service.stepOver() - } label: { - Image(systemName: "arrow.right.to.line") - } - .litheIconButton() - .disabled(!service.canControl || service.state != .paused) - .help("Step over") - - Button { - service.stepInto() - } label: { - Image(systemName: "arrow.down.to.line") - } - .litheIconButton() - .disabled(!service.canControl || service.state != .paused) - .help("Step into") - - Button { - service.stepOut() - } label: { - Image(systemName: "arrow.up.to.line") - } - .litheIconButton() - .disabled(!service.canControl || service.state != .paused) - .help("Step out") - - Button { - service.clearOutput() - } label: { - Image(systemName: "trash") - } - .litheIconButton() - .help("Clear debug output") - - } - } - - private var inspector: some View { - VStack(alignment: .leading, spacing: 0) { - sectionHeader("Breakpoints", count: service.breakpoints.count) - if service.breakpoints.isEmpty { - Text("No breakpoints") - .font(LitheTheme.smallFont) - .foregroundStyle(LitheTheme.secondaryText) - .padding(12) - } else { - ScrollView { - LazyVStack(spacing: 0) { - ForEach(service.breakpoints) { breakpoint in - HStack(spacing: 7) { - Image(systemName: "circle.fill") - .font(.system(size: 8)) - .foregroundStyle(LitheTheme.error) - Text(breakpoint.title) - .font(.system(size: 11.5, design: .monospaced)) - .foregroundStyle(LitheTheme.primaryText) - .lineLimit(1) - Spacer(minLength: 0) - } - .padding(.horizontal, 12) - .frame(height: 28) - } - } - } - .frame(maxHeight: 150) - } - - Rectangle().fill(LitheTheme.divider).frame(height: 1) - Group { - sectionHeader("Inspect", count: nil) - inspectButton("Threads", icon: "person.3", action: service.inspectThreads) - inspectButton("Call Stack", icon: "list.number", action: service.inspectStack) - inspectButton("Local Variables", icon: "list.bullet.rectangle", action: service.inspectVariables) - evaluateRow - } - - if let exceptionMessage = service.exceptionMessage { - exceptionBanner(exceptionMessage) - } - - if let title = service.inspectionTitle { - Rectangle().fill(LitheTheme.divider).frame(height: 1) - Text(LocalizedStringKey(title)) - .font(.system(size: 11.5, weight: .semibold)) - .foregroundStyle(LitheTheme.primaryText) - .padding(.horizontal, 12) - .frame(height: 30, alignment: .leading) - ScrollView([.vertical, .horizontal]) { - VStack(alignment: .leading, spacing: 0) { - structuredInspection - if !service.inspectionOutput.isEmpty { - DisclosureGroup("Raw jdb output") { - Text(service.inspectionOutput) - .font(.system(size: 10.5, design: .monospaced)) - .foregroundStyle(LitheTheme.secondaryText) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .topLeading) - .padding(.top, 7) - } - .font(.system(size: 10.5, weight: .medium)) - .foregroundStyle(LitheTheme.secondaryText) - .lithePointer() - .padding(10) - } - } - .frame(maxWidth: .infinity, alignment: .topLeading) - } - } - - Spacer(minLength: 0) - } - .litheWorkbenchSurface(LitheTheme.sidebar) - } - - private var evaluateRow: some View { - HStack(spacing: 6) { - Image(systemName: "function") - .font(.system(size: 11)) - .foregroundStyle(LitheTheme.secondaryText) - .frame(width: 16) - TextField("Evaluate expression", text: $evaluateExpression) - .textFieldStyle(.plain) - .font(.system(size: 11.5, design: .monospaced)) - .onSubmit { - service.evaluate(evaluateExpression) - } - Button { - service.evaluate(evaluateExpression) - } label: { - Image(systemName: "arrow.right.circle") - } - .litheIconButton() - .disabled(evaluateExpression.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - .help("Evaluate expression") - } - .padding(.horizontal, 10) - .frame(height: 30) - .background(LitheTheme.inputBackground) - .clipShape(RoundedRectangle(cornerRadius: 4)) - .padding(.horizontal, 10) - .padding(.vertical, 5) - } - - @ViewBuilder - private var structuredInspection: some View { - switch service.inspectionTitle { - case "Threads": - if service.threads.isEmpty { - Text("Waiting for thread data…") - .font(LitheTheme.smallFont) - .foregroundStyle(LitheTheme.secondaryText) - .padding(10) - } else { - VStack(alignment: .leading, spacing: 0) { - ForEach(service.threads) { thread in - HStack(spacing: 7) { - Image(systemName: thread.isCurrent ? "play.circle.fill" : "circle") - .foregroundStyle(thread.isCurrent ? LitheTheme.accent : LitheTheme.secondaryText) - Text(thread.name) - .font(.system(size: 11.5, weight: thread.isCurrent ? .medium : .regular)) - .foregroundStyle(LitheTheme.primaryText) - .lineLimit(1) - Spacer(minLength: 4) - Text(thread.status.isEmpty ? thread.id : thread.status) - .font(.system(size: 10.5)) - .foregroundStyle(LitheTheme.secondaryText) - .lineLimit(1) - } - .padding(.horizontal, 10) - .frame(minHeight: 28) - } - } - } - case "Call Stack": - if service.callStack.isEmpty { - Text("Waiting for stack data…") - .font(LitheTheme.smallFont) - .foregroundStyle(LitheTheme.secondaryText) - .padding(10) - } else { - VStack(alignment: .leading, spacing: 0) { - ForEach(service.callStack) { frame in - HStack(alignment: .top, spacing: 8) { - Text("#\(frame.level)") - .font(.system(size: 10.5, design: .monospaced)) - .foregroundStyle(LitheTheme.secondaryText) - .frame(width: 24, alignment: .trailing) - Text(frame.description) - .font(.system(size: 11, design: .monospaced)) - .foregroundStyle(LitheTheme.primaryText) - .lineLimit(2) - } - .padding(.horizontal, 10) - .padding(.vertical, 5) - } - } - } - case "Local Variables": - if service.variables.isEmpty { - Text("No local variables in the current frame") - .font(LitheTheme.smallFont) - .foregroundStyle(LitheTheme.secondaryText) - .padding(10) - } else { - VStack(alignment: .leading, spacing: 0) { - ForEach(service.variables) { variable in - variableRow(variable, depth: 0) - } - } - } - default: - EmptyView() - } - } - - private func variableRow(_ variable: JavaDebugVariable, depth: Int) -> JavaDebugVariableRow { - JavaDebugVariableRow(service: service, variable: variable, depth: depth) - } - - private func exceptionBanner(_ message: String) -> some View { - HStack(alignment: .top, spacing: 8) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(LitheTheme.error) - VStack(alignment: .leading, spacing: 2) { - Text("Exception") - .font(.system(size: 11.5, weight: .semibold)) - .foregroundStyle(LitheTheme.primaryText) - Text(message) - .font(.system(size: 10.5, design: .monospaced)) - .foregroundStyle(LitheTheme.secondaryText) - .lineLimit(3) - } - Spacer(minLength: 0) - } - .padding(.horizontal, 10) - .padding(.vertical, 8) - .background(LitheTheme.error.opacity(0.10)) - } - - private var outputView: some View { - ScrollView([.vertical, .horizontal]) { - Text(service.output.isEmpty ? "Waiting for debugger output…" : service.output) - .font(.system(size: 12, design: .monospaced)) - .foregroundStyle(LitheTheme.primaryText) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .topLeading) - .padding(12) - } - } - - private var emptyState: some View { - VStack(spacing: 10) { - LitheSystemIcon(systemImage: "ladybug") - .font(.system(size: 30, weight: .light)) - .foregroundStyle(LitheTheme.secondaryText) - Text(emptyStateTitle) - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(LitheTheme.primaryText) - Button(emptyStateActionTitle) { - model.startDebugging() - } - .buttonStyle(.borderedProminent) - .lithePointer() - .tint(LitheTheme.accent) - .controlSize(.small) - .disabled( - runService.isLoadingProject || - (service.targetKind == .runConfiguration && - runService.configurationStatus == .ready && - selectedDebugConfiguration == nil) - ) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - private var debugConfigurations: [JavaRunConfiguration] { - runService.configurations.filter { - $0.kind.isMavenBacked - } - } - - private var selectedDebugConfiguration: JavaRunConfiguration? { - guard let configuration = runService.selectedConfiguration, - configuration.kind.isMavenBacked else { - return nil - } - return configuration - } - - private var emptyStateTitle: String { - switch service.targetKind { - case .currentFile: - "Start debugging the current Java file" - case .runConfiguration: - selectedDebugConfiguration.map { "Start debugging \($0.name)" } - ?? "Select a Spring Boot or Maven Module configuration" - case .remote: - "Attach to a remote JVM or Tomcat" - } - } - - private var emptyStateActionTitle: String { - service.targetKind == .remote ? "Attach" : "Start Debugging" - } - - private func sectionHeader(_ title: String, count: Int?) -> some View { - HStack { - Text(LocalizedStringKey(title)) - .font(.system(size: 11.5, weight: .semibold)) - .foregroundStyle(LitheTheme.primaryText) - Spacer() - if let count { - Text("\(count)") - .font(.system(size: 10.5, weight: .medium)) - .foregroundStyle(LitheTheme.secondaryText) - } - } - .padding(.horizontal, 12) - .frame(height: 32) - .litheWorkbenchSurface(LitheTheme.sidebar) - } - - private func inspectButton(_ title: String, icon: String, action: @escaping () -> Void) -> some View { - Button(action: action) { - Label(LocalizedStringKey(title), systemImage: icon) - .font(.system(size: 11.5)) - .foregroundStyle(LitheTheme.primaryText) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 12) - .frame(height: 30) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .lithePointer() - } - - private var canStop: Bool { - service.isSessionActive - } - - private var stateColor: Color { - switch service.state { - case .running: LitheTheme.success - case .paused: LitheTheme.accent - case .failed: LitheTheme.error - case .launching: LitheTheme.warning - default: LitheTheme.secondaryText - } - } -} - -private struct JavaDebugVariableRow: View { - @ObservedObject var service: JavaDebugFeatureModel - let variable: JavaDebugVariable - let depth: Int - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - Button { - service.toggleVariable(variable) - } label: { - HStack(spacing: 6) { - if variable.canExpand { - Image(systemName: service.expandingVariableID == variable.id ? "hourglass" : (variable.isExpanded ? "chevron.down" : "chevron.right")) - .font(.system(size: 8, weight: .bold)) - .foregroundStyle(LitheTheme.secondaryText) - .frame(width: 10) - } else { - Color.clear.frame(width: 10, height: 1) - } - Text(variable.name) - .font(.system(size: 11.5, weight: .medium, design: .monospaced)) - .foregroundStyle(LitheTheme.primaryText) - Text(variable.value) - .font(.system(size: 11, design: .monospaced)) - .foregroundStyle(LitheTheme.secondaryText) - .lineLimit(1) - Spacer(minLength: 0) - } - .padding(.leading, CGFloat(depth * 14) + 10) - .padding(.trailing, 10) - .frame(maxWidth: .infinity, alignment: .leading) - .frame(minHeight: 28) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .lithePointer() - - if variable.isExpanded { - ForEach(variable.children) { child in - JavaDebugVariableRow(service: service, variable: child, depth: depth + 1) - } - } - } - } -} diff --git a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift index 197534cc..3622d8e6 100644 --- a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift +++ b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift @@ -306,7 +306,6 @@ struct CodeEditorView: NSViewRepresentable { @EnvironmentObject private var diagnosticsStore: EditorDiagnosticsStore @EnvironmentObject private var settings: AppSettings @ObservedObject var document: EditorDocument - var debugService: JavaDebugFeatureModel? var shouldFocus = true var markdownScrollPosition: Binding? = nil let viewportStore: EditorViewportStore @@ -315,7 +314,6 @@ struct CodeEditorView: NSViewRepresentable { Coordinator( document: document, model: model, - debugService: debugService, markdownScrollPosition: markdownScrollPosition, viewportStore: viewportStore ) @@ -397,6 +395,13 @@ 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.onRunToCursor = { [weak model] line, column in + model?.runToCursor( + fileURL: document.url, + line: line + 1, + column: column + 1 + ) + } textView.onFindStateChange = { [weak coordinator = context.coordinator] index, count in coordinator?.scheduleFindStateUpdate(currentIndex: index, count: count) } @@ -468,6 +473,9 @@ struct CodeEditorView: NSViewRepresentable { context.coordinator.updateDiagnostics() context.coordinator.shouldFocus = shouldFocus context.coordinator.requestInitialFocusIfNeeded() + let debugFeature = model.genericDebugFeatureIfActive + textView.isRunToCursorEnabled = debugFeature?.state == .paused + && debugFeature?.capabilities.supportsGotoTargetsRequest == true context.coordinator.restoreViewportWhenReady() return container } @@ -480,7 +488,6 @@ struct CodeEditorView: NSViewRepresentable { || context.coordinator.colorTheme != settings.colorTheme context.coordinator.document = document context.coordinator.model = model - context.coordinator.debugService = debugService context.coordinator.shouldFocus = shouldFocus context.coordinator.markdownScrollPosition = markdownScrollPosition container.displaysTransparentBackground = showsWorkbenchBackground @@ -555,7 +562,6 @@ struct CodeEditorView: NSViewRepresentable { final class Coordinator: NSObject, NSTextViewDelegate { weak var document: EditorDocument? weak var model: AppModel? - weak var debugService: JavaDebugFeatureModel? let fileName: String let fileExtension: String weak var textView: NSTextView? @@ -611,13 +617,11 @@ struct CodeEditorView: NSViewRepresentable { init( document: EditorDocument, model: AppModel, - debugService: JavaDebugFeatureModel?, markdownScrollPosition: Binding?, viewportStore: EditorViewportStore ) { self.document = document self.model = model - self.debugService = debugService self.markdownScrollPosition = markdownScrollPosition self.viewportStore = viewportStore fileName = document.url.lastPathComponent @@ -1214,13 +1218,10 @@ struct CodeEditorView: NSViewRepresentable { let isBlameVisible = model.blameVisibleURL == url let blameLines = model.gitBlameLines[url] ?? [] - let javaBreakpointLines = debugService?.breakpoints.filter { - $0.fileURL.standardizedFileURL == url - }.map(\.line) ?? [] let genericBreakpointLines = (model.genericDebugFeatureIfActive?.breakpoints ?? []).filter { $0.fileURL.standardizedFileURL == url }.map(\.line) - let debugBreakpointLines = Set(javaBreakpointLines + genericBreakpointLines) + let debugBreakpointLines = Set(genericBreakpointLines) if appliedBlameVisible != isBlameVisible || appliedBlameLines != blameLines || appliedDebugBreakpointLines != debugBreakpointLines { @@ -1493,6 +1494,8 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { var onRenameRequested: ((Int, Int, String) -> Void)? var onFormatRequested: (() -> Void)? var onCodeActionsRequested: ((Int, Int) -> Void)? + var onRunToCursor: ((Int, Int) -> Void)? + var isRunToCursorEnabled = false var onPasteImage: (() -> Bool)? private var findMatchRanges: [NSRange] = [] @@ -2791,6 +2794,17 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { let menu = super.menu(for: event) ?? NSMenu() let languageItems = languageContextMenuItems() + if onRunToCursor != nil { + let runToCursor = NSMenuItem( + title: "Run to Cursor", + action: #selector(runToCursorFromMenu), + keyEquivalent: "" + ) + runToCursor.target = self + runToCursor.isEnabled = isRunToCursorEnabled + menu.insertItem(.separator(), at: 0) + menu.insertItem(runToCursor, at: 0) + } guard !languageItems.isEmpty else { return menu } menu.insertItem(.separator(), at: 0) for item in languageItems.reversed() { menu.insertItem(item, at: 0) } @@ -2816,6 +2830,11 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { return languageItems } + @objc private func runToCursorFromMenu() { + let position = languageServerPosition(at: selectedRange().location) + onRunToCursor?(position.line, position.utf16Column) + } + @objc private func goToDefinitionFromMenu() { onGoToDefinition?() } diff --git a/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift b/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift index 67fdcbcd..29d42834 100644 --- a/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift +++ b/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift @@ -993,7 +993,6 @@ struct EditorAreaView: View { if let document { CodeEditorView( document: document, - debugService: model.debugFeatureIfActive, shouldFocus: !showsHeader && document.id == model.activeDocumentID, viewportStore: editorViewportStore ) @@ -1159,7 +1158,6 @@ struct EditorAreaView: View { ) -> some View { CodeEditorView( document: document, - debugService: model.debugFeatureIfActive, shouldFocus: true, markdownScrollPosition: markdownScrollPosition, viewportStore: editorViewportStore diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift index e5ae9e40..9fbbf5c6 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift @@ -133,15 +133,10 @@ enum WorkbenchModuleUIComposition { isVisible: { _ in true }, isSelected: { $0.isDebugVisible }, content: { model in - if model.prefersGenericDebugUI, - let feature = model.genericDebugFeatureIfActive { - return AnyView(GenericDebugView(feature: feature)) - } - guard let feature = model.debugFeatureIfActive, - let runFeature = model.runFeatureIfActive else { + guard let feature = model.genericDebugFeatureIfActive else { return AnyView(WorkbenchModuleUIRegistry.moduleLoadingView) } - return AnyView(JavaDebugView(feature: feature, runFeature: runFeature)) + return AnyView(GenericDebugView(feature: feature)) } ) ] diff --git a/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift b/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift index 0a9ebd43..2631389d 100644 --- a/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift +++ b/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift @@ -24,6 +24,19 @@ public protocol DebugAdapterChildTransportProviding: AnyObject { func makeChildTransport() -> (any DebugAdapterTransport)? } +@MainActor +public protocol DebugOperationDeadline: AnyObject { + func cancel() +} + +@MainActor +public protocol DebugOperationDeadlineScheduling: AnyObject { + func schedule( + afterMilliseconds: Int, + action: @escaping @MainActor () -> Void + ) -> any DebugOperationDeadline +} + public extension DebugAdapterSession { var state: DebugAdapterState { isRunning ? .running : .idle } } @@ -32,11 +45,11 @@ public enum DebugAdapterState: String, Equatable, Sendable { case idle, initializing, ready, launching, running, paused, terminated, failed } -public enum DebugRequestKind: String, Equatable, Sendable { +public enum DebugRequestKind: String, Codable, Equatable, Sendable { case launch, attach } -public struct DebugLaunchConfiguration: Equatable, Sendable { +public struct DebugLaunchConfiguration: Codable, Equatable, Sendable { public let name: String public let request: DebugRequestKind public let arguments: [String: ToolingJSONValue] @@ -48,15 +61,49 @@ public struct DebugLaunchConfiguration: Equatable, Sendable { } } -public struct DebugSourceBreakpoint: Hashable, Sendable { +/// JDT LS-owned identity for one Java launch target. `mainClass` may include +/// the JPMS module prefix (`module/name.Type`) required by Java Debug Server. +public struct JavaDebugLaunchTarget: Equatable, Sendable { + public let mainClass: String + public let projectName: String? + public let modulePaths: [String] + public let classPaths: [String] + + public init( + mainClass: String, + projectName: String?, + modulePaths: [String] = [], + classPaths: [String] = [] + ) { + self.mainClass = mainClass + self.projectName = projectName + self.modulePaths = modulePaths + self.classPaths = classPaths + } +} + +public struct DebugSourceBreakpoint: Codable, Hashable, Sendable { public let line: Int public let column: Int? + public let enabled: Bool public let condition: String? + public let hitCondition: String? + public let logMessage: String? - public init(line: Int, column: Int? = nil, condition: String? = nil) { + public init( + line: Int, + column: Int? = nil, + enabled: Bool = true, + condition: String? = nil, + hitCondition: String? = nil, + logMessage: String? = nil + ) { self.line = line self.column = column + self.enabled = enabled self.condition = condition + self.hitCondition = hitCondition + self.logMessage = logMessage } } @@ -64,20 +111,255 @@ public struct DebugBreakpoint: Identifiable, Equatable, Sendable { public let id: Int public let verified: Bool public let message: String? + public let functionName: String? + public let dataID: String? public let sourceURL: URL? public let line: Int? public let column: Int? - public init(id: Int, verified: Bool, message: String?, sourceURL: URL?, line: Int?, column: Int?) { + public init( + id: Int, + verified: Bool, + message: String?, + sourceURL: URL?, + line: Int?, + column: Int?, + functionName: String? = nil, + dataID: String? = nil + ) { self.id = id self.verified = verified self.message = message + self.functionName = functionName + self.dataID = dataID self.sourceURL = sourceURL self.line = line self.column = column } } +public struct DebugExceptionBreakpointFilter: Codable, Equatable, Sendable { + public let filter: String + public let label: String + public let description: String? + public let isDefault: Bool + public let supportsCondition: Bool + public let conditionDescription: String? + + public init( + filter: String, + label: String, + description: String?, + isDefault: Bool, + supportsCondition: Bool, + conditionDescription: String? + ) { + self.filter = filter + self.label = label + self.description = description + self.isDefault = isDefault + self.supportsCondition = supportsCondition + self.conditionDescription = conditionDescription + } + + private enum CodingKeys: String, CodingKey { + case filter, label, description + case isDefault = "default" + case supportsCondition, conditionDescription + } +} + +public struct DebugExceptionBreakpoint: Codable, Hashable, Sendable { + public let filter: String + public let enabled: Bool + public let condition: String? + + public init(filter: String, enabled: Bool = true, condition: String? = nil) { + self.filter = filter + self.enabled = enabled + self.condition = condition + } +} + +public struct DebugFunctionBreakpoint: Codable, Hashable, Sendable { + public let name: String + public let enabled: Bool + public let condition: String? + public let hitCondition: String? + + public init( + name: String, + enabled: Bool = true, + condition: String? = nil, + hitCondition: String? = nil + ) { + self.name = name + self.enabled = enabled + self.condition = condition + self.hitCondition = hitCondition + } +} + +public struct DebugDataBreakpoint: Codable, Hashable, Sendable { + public let dataID: String + public let label: String? + public let enabled: Bool + public let accessType: String? + public let condition: String? + public let hitCondition: String? + + public init( + dataID: String, + label: String? = nil, + enabled: Bool = true, + accessType: String? = nil, + condition: String? = nil, + hitCondition: String? = nil + ) { + self.dataID = dataID + self.label = label + self.enabled = enabled + self.accessType = accessType + self.condition = condition + self.hitCondition = hitCondition + } + + private enum CodingKeys: String, CodingKey { + case dataID = "dataId" + case label, enabled, accessType, condition, hitCondition + } +} + +public struct DebugDataBreakpointInfo: Equatable, Sendable { + public let dataID: String? + public let description: String + public let accessTypes: [String] + public let canPersist: Bool + + public init(dataID: String?, description: String, accessTypes: [String], canPersist: Bool) { + self.dataID = dataID + self.description = description + self.accessTypes = accessTypes + self.canPersist = canPersist + } +} + +public struct DebugStepInTarget: Identifiable, Equatable, Sendable { + public let id: Int + public let label: String + public let line: Int? + public let column: Int? + public let endLine: Int? + public let endColumn: Int? + + public init( + id: Int, + label: String, + line: Int?, + column: Int?, + endLine: Int?, + endColumn: Int? + ) { + self.id = id + self.label = label + self.line = line + self.column = column + self.endLine = endLine + self.endColumn = endColumn + } +} + +public struct DebugGotoTarget: Identifiable, Equatable, Sendable { + public let id: Int + public let label: String + public let line: Int + public let column: Int? + public let endLine: Int? + public let endColumn: Int? + public let instructionPointerReference: String? + + public init( + id: Int, + label: String, + line: Int, + column: Int?, + endLine: Int?, + endColumn: Int?, + instructionPointerReference: String? + ) { + self.id = id + self.label = label + self.line = line + self.column = column + self.endLine = endLine + self.endColumn = endColumn + self.instructionPointerReference = instructionPointerReference + } +} + +public struct DebugAdapterCapabilities: Equatable, Sendable { + public let negotiated: Bool + public let supportsConfigurationDone: Bool + public let supportsConditionalBreakpoints: Bool + public let supportsHitConditionalBreakpoints: Bool + public let supportsLogPoints: Bool + public let supportsFunctionBreakpoints: Bool + public let supportsDataBreakpoints: Bool + public let supportsExceptionOptions: Bool + public let supportsExceptionFilterOptions: Bool + public let supportsSetVariable: Bool + public let supportsCancelRequest: Bool + public let supportsSingleThreadExecutionRequests: Bool + public let supportsRestartRequest: Bool + public let supportsTerminateRequest: Bool + public let supportsStepBack: Bool + public let supportsStepInTargetsRequest: Bool + public let supportsGotoTargetsRequest: Bool + public let exceptionBreakpointFilters: [DebugExceptionBreakpointFilter] + + public static let unknown = DebugAdapterCapabilities() + + public init( + negotiated: Bool = false, + supportsConfigurationDone: Bool = false, + supportsConditionalBreakpoints: Bool = false, + supportsHitConditionalBreakpoints: Bool = false, + supportsLogPoints: Bool = false, + supportsFunctionBreakpoints: Bool = false, + supportsDataBreakpoints: Bool = false, + supportsExceptionOptions: Bool = false, + supportsExceptionFilterOptions: Bool = false, + supportsSetVariable: Bool = false, + supportsCancelRequest: Bool = false, + supportsSingleThreadExecutionRequests: Bool = false, + supportsRestartRequest: Bool = false, + supportsTerminateRequest: Bool = false, + supportsStepBack: Bool = false, + supportsStepInTargetsRequest: Bool = false, + supportsGotoTargetsRequest: Bool = false, + exceptionBreakpointFilters: [DebugExceptionBreakpointFilter] = [] + ) { + self.negotiated = negotiated + self.supportsConfigurationDone = supportsConfigurationDone + self.supportsConditionalBreakpoints = supportsConditionalBreakpoints + self.supportsHitConditionalBreakpoints = supportsHitConditionalBreakpoints + self.supportsLogPoints = supportsLogPoints + self.supportsFunctionBreakpoints = supportsFunctionBreakpoints + self.supportsDataBreakpoints = supportsDataBreakpoints + self.supportsExceptionOptions = supportsExceptionOptions + self.supportsExceptionFilterOptions = supportsExceptionFilterOptions + self.supportsSetVariable = supportsSetVariable + self.supportsCancelRequest = supportsCancelRequest + self.supportsSingleThreadExecutionRequests = supportsSingleThreadExecutionRequests + self.supportsRestartRequest = supportsRestartRequest + self.supportsTerminateRequest = supportsTerminateRequest + self.supportsStepBack = supportsStepBack + self.supportsStepInTargetsRequest = supportsStepInTargetsRequest + self.supportsGotoTargetsRequest = supportsGotoTargetsRequest + self.exceptionBreakpointFilters = exceptionBreakpointFilters + } +} + public struct DebugThread: Identifiable, Equatable, Sendable { public let id: Int public let name: String @@ -121,6 +403,7 @@ public struct DebugVariable: Identifiable, Equatable, Sendable { public let type: String? public let evaluateName: String? public let variablesReference: Int + public let containerReference: Int? public var isExpandable: Bool { variablesReference > 0 } public init( @@ -129,7 +412,8 @@ public struct DebugVariable: Identifiable, Equatable, Sendable { value: String, type: String?, evaluateName: String?, - variablesReference: Int + variablesReference: Int, + containerReference: Int? = nil ) { self.id = id self.name = name @@ -137,11 +421,13 @@ public struct DebugVariable: Identifiable, Equatable, Sendable { self.type = type self.evaluateName = evaluateName self.variablesReference = variablesReference + self.containerReference = containerReference } } public enum DebugAdapterEvent: Equatable, Sendable { case initialized + case capabilities(DebugAdapterCapabilities) case output(category: String?, output: String) case stopped(reason: String, threadID: Int?, description: String?) case continued(threadID: Int?) @@ -149,21 +435,115 @@ public enum DebugAdapterEvent: Equatable, Sendable { case breakpoint(DebugBreakpoint) } -public enum DebugExecutionCommand: String, Equatable, Sendable { +public enum DebugExecutionCommand: String, Codable, Equatable, Sendable { case continueExecution = "continue" - case pause, next, stepIn, stepOut + case pause, next, stepIn, stepOut, stepBack, goto, restart, terminate } @MainActor public protocol DebugAdapterControllingSession: DebugAdapterSession { + var capabilities: DebugAdapterCapabilities { get } var onStateChange: ((DebugAdapterState) -> Void)? { get set } var onEvent: ((DebugAdapterEvent) -> Void)? { get set } func launch(_ configuration: DebugLaunchConfiguration) throws func setBreakpoints(_ breakpoints: [DebugSourceBreakpoint], in fileURL: URL) + func setExceptionBreakpoints(_ breakpoints: [DebugExceptionBreakpoint]) + func setFunctionBreakpoints(_ breakpoints: [DebugFunctionBreakpoint]) + func setDataBreakpoints(_ breakpoints: [DebugDataBreakpoint]) + func requestDataBreakpointInfo( + name: String, + variablesReference: Int?, + frameID: Int?, + completion: @escaping (Result) -> Void + ) func execute(_ command: DebugExecutionCommand, threadID: Int?) + func execute(_ command: DebugExecutionCommand, threadID: Int?, targetID: Int?) + func execute( + _ command: DebugExecutionCommand, + threadID: Int?, + targetID: Int?, + singleThread: Bool + ) + func requestStepInTargets( + frameID: Int, + completion: @escaping (Result<[DebugStepInTarget], Error>) -> Void + ) + func requestGotoTargets( + fileURL: URL, + line: Int, + column: Int?, + completion: @escaping (Result<[DebugGotoTarget], Error>) -> Void + ) func requestThreads(_ completion: @escaping (Result<[DebugThread], Error>) -> Void) func requestStackTrace(threadID: Int, completion: @escaping (Result<[DebugStackFrame], Error>) -> Void) func requestScopes(frameID: Int, completion: @escaping (Result<[DebugScope], Error>) -> Void) func requestVariables(reference: Int, completion: @escaping (Result<[DebugVariable], Error>) -> Void) + func setVariable( + variablesReference: Int, + name: String, + value: String, + completion: @escaping (Result) -> Void + ) func evaluate(_ expression: String, frameID: Int?, completion: @escaping (Result) -> Void) + func cancelPendingOperations() +} + +public extension DebugAdapterControllingSession { + var capabilities: DebugAdapterCapabilities { .unknown } + func setExceptionBreakpoints(_: [DebugExceptionBreakpoint]) {} + func setFunctionBreakpoints(_: [DebugFunctionBreakpoint]) {} + func setDataBreakpoints(_: [DebugDataBreakpoint]) {} + func execute(_ command: DebugExecutionCommand, threadID: Int?, targetID _: Int?) { + execute(command, threadID: threadID) + } + func execute( + _ command: DebugExecutionCommand, + threadID: Int?, + targetID: Int?, + singleThread _: Bool + ) { + execute(command, threadID: threadID, targetID: targetID) + } + func requestStepInTargets( + frameID _: Int, + completion: @escaping (Result<[DebugStepInTarget], Error>) -> Void + ) { + completion(.failure(DebugAdapterCapabilityError.unsupported("smart step into"))) + } + func requestGotoTargets( + fileURL _: URL, + line _: Int, + column _: Int?, + completion: @escaping (Result<[DebugGotoTarget], Error>) -> Void + ) { + completion(.failure(DebugAdapterCapabilityError.unsupported("run to cursor"))) + } + func requestDataBreakpointInfo( + name _: String, + variablesReference _: Int?, + frameID _: Int?, + completion: @escaping (Result) -> Void + ) { + completion(.failure(DebugAdapterCapabilityError.unsupported("data breakpoints"))) + } + func setVariable( + variablesReference _: Int, + name _: String, + value _: String, + completion: @escaping (Result) -> Void + ) { + completion(.failure(DebugAdapterCapabilityError.unsupported("variable mutation"))) + } + func cancelPendingOperations() {} +} + +public enum DebugAdapterCapabilityError: LocalizedError, Sendable { + case unsupported(String) + + public var errorDescription: String? { + switch self { + case let .unsupported(feature): + "The active debug adapter does not support \(feature)." + } + } } diff --git a/macos/Sources/LitheCoreContracts/Debug/DebugProtocolCore.swift b/macos/Sources/LitheCoreContracts/Debug/DebugProtocolCore.swift new file mode 100644 index 00000000..e31b0df7 --- /dev/null +++ b/macos/Sources/LitheCoreContracts/Debug/DebugProtocolCore.swift @@ -0,0 +1,230 @@ +import Foundation + +/// Lifecycle state reduced by the shared Rust Debug Core. +public enum DebugCoreSessionState: String, Decodable, Equatable, Sendable { + case idle, initializing, ready, launching, running, paused, terminating, terminated, failed +} + +/// One deterministic reduction returned by a shared Debug Core command. +public struct DebugCoreUpdate: Decodable, Equatable, Sendable { + public let sessionID: String + public let state: DebugCoreSessionState + public let outboundFrames: [String] + public let events: [DebugCoreEvent] + + private enum CodingKeys: String, CodingKey { + case sessionID = "sessionId" + case state + case outboundFrames + case events + } +} + +/// A normalized event emitted by the shared Debug Core. +public struct DebugCoreEvent: Decodable, Equatable, Sendable { + public let sequence: UInt64 + public let type: String + public let state: DebugCoreSessionState? + public let category: String? + public let output: String? + public let reason: String? + public let threadID: Int? + public let description: String? + public let exitCode: Int? + public let breakpoint: DebugCoreBreakpoint? + public let capabilities: DebugCoreCapabilities? + public let operationID: String? + public let result: DebugCoreOperationResult? + public let command: String? + public let code: String? + public let message: String? + + private enum CodingKeys: String, CodingKey { + case sequence + case type + case state + case category + case output + case reason + case threadID = "threadId" + case description + case exitCode + case breakpoint + case capabilities + case operationID = "operationId" + case result + case command + case code + case message + } +} + +public struct DebugCoreCapabilities: Decodable, Equatable, Sendable { + public let supportsConfigurationDone: Bool + public let supportsConditionalBreakpoints: Bool + public let supportsHitConditionalBreakpoints: Bool + public let supportsLogPoints: Bool + public let supportsFunctionBreakpoints: Bool + public let supportsDataBreakpoints: Bool + public let supportsExceptionOptions: Bool + public let supportsExceptionFilterOptions: Bool + public let supportsSetVariable: Bool + public let supportsCancelRequest: Bool + public let supportsSingleThreadExecutionRequests: Bool + public let supportsRestartRequest: Bool + public let supportsTerminateRequest: Bool + public let supportsStepBack: Bool + public let supportsStepInTargetsRequest: Bool + public let supportsGotoTargetsRequest: Bool + public let exceptionBreakpointFilters: [DebugExceptionBreakpointFilter] +} + +public struct DebugCoreOperationResult: Decodable, Equatable, Sendable { + public let kind: String + public let command: String? + public let threads: [DebugCoreThread]? + public let stackFrames: [DebugCoreStackFrame]? + public let scopes: [DebugCoreScope]? + public let variables: [DebugCoreVariable]? + public let variable: DebugCoreVariable? + public let dataID: String? + public let description: String? + public let accessTypes: [String]? + public let canPersist: Bool? + public let targets: [DebugCoreTarget]? + + private enum CodingKeys: String, CodingKey { + case kind, command, threads, stackFrames, scopes, variables, variable + case dataID = "dataId" + case description, accessTypes, canPersist, targets + } +} + +public struct DebugCoreTarget: Decodable, Equatable, Sendable { + public let id: Int + public let label: String + public let line: Int? + public let column: Int? + public let endLine: Int? + public let endColumn: Int? + public let instructionPointerReference: String? +} + +public struct DebugCoreBreakpoint: Decodable, Equatable, Sendable { + public let id: Int + public let verified: Bool + public let message: String? + public let functionName: String? + public let dataID: String? + public let sourcePath: String? + public let line: Int? + public let column: Int? + + private enum CodingKeys: String, CodingKey { + case id, verified, message, functionName + case dataID = "dataId" + case sourcePath, line, column + } +} + +public struct DebugCoreThread: Decodable, Equatable, Sendable { + public let id: Int + public let name: String +} + +public struct DebugCoreStackFrame: Decodable, Equatable, Sendable { + public let id: Int + public let name: String + public let sourcePath: String? + public let line: Int + public let column: Int +} + +public struct DebugCoreScope: Decodable, Equatable, Sendable { + public let name: String + public let variablesReference: Int + public let expensive: Bool +} + +public struct DebugCoreVariable: Decodable, Equatable, Sendable { + public let name: String + public let value: String + public let type: String? + public let evaluateName: String? + public let variablesReference: Int +} + +/// Transport-neutral Debug Core boundary. Native products own processes and +/// sockets; this contract owns DAP framing, state, sequencing, and normalized data. +@MainActor +public protocol DebugProtocolCore: Sendable { + func createDebugSession( + sessionID: String, + adapterID: String, + rootPath: String + ) throws -> DebugCoreUpdate + func launchDebugSession( + sessionID: String, + operationID: String, + configuration: DebugLaunchConfiguration + ) throws -> DebugCoreUpdate + func setDebugBreakpoints( + sessionID: String, + sourcePath: String, + breakpoints: [DebugSourceBreakpoint] + ) throws -> DebugCoreUpdate + func setDebugExceptionBreakpoints( + sessionID: String, + breakpoints: [DebugExceptionBreakpoint] + ) throws -> DebugCoreUpdate + func setDebugFunctionBreakpoints( + sessionID: String, + breakpoints: [DebugFunctionBreakpoint] + ) throws -> DebugCoreUpdate + func debugDataBreakpointInfo( + sessionID: String, + operationID: String, + name: String, + variablesReference: Int?, + frameID: Int? + ) throws -> DebugCoreUpdate + func setDebugDataBreakpoints( + sessionID: String, + breakpoints: [DebugDataBreakpoint] + ) throws -> DebugCoreUpdate + func setDebugVariable( + sessionID: String, + operationID: String, + variablesReference: Int, + name: String, + value: String + ) throws -> DebugCoreUpdate + func cancelDebugOperation( + sessionID: String, + operationID: String, + reason: String + ) throws -> DebugCoreUpdate + func executeDebugCommand( + sessionID: String, + operationID: String, + command: DebugExecutionCommand, + threadID: Int?, + targetID: Int?, + singleThread: Bool + ) throws -> DebugCoreUpdate + func inspectDebugSession( + sessionID: String, + operationID: String, + kind: String, + threadID: Int?, + frameID: Int?, + variablesReference: Int?, + expression: String?, + sourcePath: String?, + line: Int?, + column: Int? + ) throws -> DebugCoreUpdate + func receiveDebugData(sessionID: String, data: Data) throws -> DebugCoreUpdate + func disconnectDebugSession(sessionID: String) throws -> DebugCoreUpdate + func destroyDebugSession(sessionID: String) +} diff --git a/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift b/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift index 6fa18246..d2012b55 100644 --- a/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift +++ b/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift @@ -34,15 +34,18 @@ package struct JDTLSLaunchResources: Equatable, Sendable { package let launcherJarURL: URL package let configurationDirectoryURL: URL package let lombokAgentURL: URL + package let javaDebugBundleURL: URL? package init( launcherJarURL: URL, configurationDirectoryURL: URL, - lombokAgentURL: URL + lombokAgentURL: URL, + javaDebugBundleURL: URL? = nil ) { self.launcherJarURL = launcherJarURL.standardizedFileURL self.configurationDirectoryURL = configurationDirectoryURL.standardizedFileURL self.lombokAgentURL = lombokAgentURL.standardizedFileURL + self.javaDebugBundleURL = javaDebugBundleURL?.standardizedFileURL } } diff --git a/macos/Sources/LitheCoreContracts/Language/LanguageToolingContracts.swift b/macos/Sources/LitheCoreContracts/Language/LanguageToolingContracts.swift index 08382d16..4df6c765 100644 --- a/macos/Sources/LitheCoreContracts/Language/LanguageToolingContracts.swift +++ b/macos/Sources/LitheCoreContracts/Language/LanguageToolingContracts.swift @@ -170,7 +170,7 @@ package struct LanguageProviderCatalog: Sendable { package static let compatibilityFallback = LanguageProviderCatalog(descriptors: [ LanguageProviderDescriptor( id: "java", displayName: "Java", fileExtensions: ["java"], - capabilities: [.run, .languageServer, .formatting, .testing], + capabilities: [.run, .languageServer, .debugAdapter, .formatting, .testing], activationPolicy: .onDemand ), LanguageProviderDescriptor( @@ -589,6 +589,11 @@ package protocol LanguageServerSession: AnyObject { fileURL: URL, completion: @escaping (Result) -> Void ) throws + func executeReturningValue( + _ command: LanguageServerCommand, + fileURL: URL, + completion: @escaping (Result) -> Void + ) throws func resolveVirtualDocument( uri: String, completion: @escaping (Result) -> Void @@ -619,6 +624,15 @@ package extension LanguageServerSession { get { nil } set {} } + func executeReturningValue( + _ command: LanguageServerCommand, + fileURL: URL, + completion: @escaping (Result) -> Void + ) throws { + try execute(command, fileURL: fileURL) { result in + completion(result.map { .null }) + } + } var serverInfo: LanguageServerInfo? { nil } var onServerInfoChange: ((LanguageServerInfo?) -> Void)? { get { nil } diff --git a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift index b75ec936..285230ba 100644 --- a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift +++ b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift @@ -4,11 +4,72 @@ import LitheCoreContracts public struct GenericDebugBreakpoint: Identifiable, Equatable, Sendable { public let fileURL: URL public let line: Int + public let column: Int? + public let enabled: Bool + public let condition: String? + public let hitCondition: String? + public let logMessage: String? public var verified: Bool public var message: String? - public var id: String { fileURL.standardizedFileURL.path + ":" + String(line) } + public var id: String { + fileURL.standardizedFileURL.path + ":" + String(line) + ":" + String(column ?? 0) + } public var title: String { fileURL.lastPathComponent + ":" + String(line) } + public var isLogpoint: Bool { logMessage?.isEmpty == false } +} + +public struct GenericDebugExceptionBreakpoint: Identifiable, Equatable, Sendable { + public let filter: String + public let label: String + public let description: String? + public let enabled: Bool + public let condition: String? + public let supportsCondition: Bool + public let conditionDescription: String? + + public var id: String { filter } +} + +public struct GenericDebugFunctionBreakpoint: Identifiable, Equatable, Sendable { + public let name: String + public let enabled: Bool + public let condition: String? + public let hitCondition: String? + public var verified: Bool + public var message: String? + + public var id: String { name } +} + +public struct GenericDebugDataBreakpoint: Identifiable, Equatable, Sendable { + public let dataID: String + public let label: String + public let enabled: Bool + public let accessType: String? + public let accessTypes: [String] + public let condition: String? + public let hitCondition: String? + public let canPersist: Bool + public var verified: Bool + public var message: String? + + public var id: String { dataID + ":" + (accessType ?? "") } +} + +public struct GenericDebugWatch: Identifiable, Equatable, Sendable { + public let expression: String + public var value: String? + public var type: String? + public var error: String? + + public var id: String { expression } +} + +public struct GenericDebugVariableRow: Identifiable, Equatable, Sendable { + public let id: String + public let variable: DebugVariable + public let depth: Int } @MainActor @@ -20,16 +81,27 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu @Published public private(set) var errorMessage: String? @Published public private(set) var stoppedReason: String? @Published public private(set) var breakpoints: [GenericDebugBreakpoint] = [] + @Published public private(set) var exceptionBreakpoints: [GenericDebugExceptionBreakpoint] = [] + @Published public private(set) var functionBreakpoints: [GenericDebugFunctionBreakpoint] = [] + @Published public private(set) var dataBreakpoints: [GenericDebugDataBreakpoint] = [] @Published public private(set) var threads: [DebugThread] = [] @Published public private(set) var stackFrames: [DebugStackFrame] = [] @Published public private(set) var scopes: [DebugScope] = [] @Published public private(set) var variables: [DebugVariable] = [] + @Published public private(set) var variableChildren: [String: [DebugVariable]] = [:] + @Published public private(set) var expandedVariableIDs: Set = [] + @Published public private(set) var loadingVariableIDs: Set = [] + @Published public private(set) var watches: [GenericDebugWatch] = [] @Published public private(set) var selectedThreadID: Int? @Published public private(set) var selectedFrameID: Int? + @Published public private(set) var areBreakpointsMuted = false + @Published public private(set) var capabilities: DebugAdapterCapabilities = .unknown private let sessions: DebugAdapterSessionManager - private var requestedLinesByFile: [URL: Set] = [:] + private var requestedBreakpointsByFile: [URL: [Int: DebugSourceBreakpoint]] = [:] + private var activeFileURL: URL? private let maximumOutputCharacters = 400_000 + private var watchGeneration = 0 public init(sessions: DebugAdapterSessionManager) { self.sessions = sessions @@ -48,6 +120,20 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } public var canControl: Bool { state == .running || state == .paused } + public var canRestart: Bool { + canControl && capabilities.supportsRestartRequest + } + public var canTerminate: Bool { + canControl && capabilities.supportsTerminateRequest + } + public var canStepBack: Bool { + state == .paused && capabilities.supportsStepBack + } + public var visibleVariableRows: [GenericDebugVariableRow] { + var rows: [GenericDebugVariableRow] = [] + appendVisibleVariables(variables, parentPath: "root", depth: 0, to: &rows) + return rows + } public func start( fileURL: URL, @@ -55,6 +141,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu configuration: DebugLaunchConfiguration ) -> Bool { stop() + activeFileURL = fileURL.standardizedFileURL providerID = sessionsProviderID(for: fileURL) targetTitle = configuration.name output = "" @@ -63,16 +150,21 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu threads = [] stackFrames = [] scopes = [] - variables = [] + resetVariableTree() + invalidateWatchResults() + capabilities = .unknown selectedThreadID = nil selectedFrameID = nil do { - if let lines = requestedLinesByFile[fileURL.standardizedFileURL] { + if requestedBreakpointsByFile[fileURL.standardizedFileURL] != nil { try sessions.setBreakpoints( - lines.sorted().map { DebugSourceBreakpoint(line: $0) }, + effectiveBreakpoints(for: fileURL.standardizedFileURL), in: fileURL ) } + if !dataBreakpoints.isEmpty { + try sessions.setDataBreakpoints(coreDataBreakpoints, for: fileURL) + } let session = try sessions.launch( for: fileURL, rootURL: rootURL, @@ -89,6 +181,10 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } public func stop() { + if let activeFileURL { + dataBreakpoints.removeAll { !$0.canPersist } + try? sessions.setDataBreakpoints(coreDataBreakpoints, for: activeFileURL) + } if let providerID { sessions.stop(providerID: providerID) } @@ -99,7 +195,10 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu threads = [] stackFrames = [] scopes = [] - variables = [] + resetVariableTree() + invalidateWatchResults() + capabilities = .unknown + activeFileURL = nil } public func reset() { @@ -109,24 +208,257 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu output = "" errorMessage = nil breakpoints = [] - requestedLinesByFile = [:] + exceptionBreakpoints = [] + functionBreakpoints = [] + dataBreakpoints = [] + watches = [] + requestedBreakpointsByFile = [:] + areBreakpointsMuted = false } public func toggleBreakpoint(fileURL: URL, line: Int) { guard line > 0 else { return } let normalizedURL = fileURL.standardizedFileURL - var lines = requestedLinesByFile[normalizedURL] ?? [] - if lines.contains(line) { - lines.remove(line) + var values = requestedBreakpointsByFile[normalizedURL] ?? [:] + if values[line] != nil { + values[line] = nil } else { - lines.insert(line) + values[line] = DebugSourceBreakpoint(line: line) } - requestedLinesByFile[normalizedURL] = lines + requestedBreakpointsByFile[normalizedURL] = values.isEmpty ? nil : values + reconcileBreakpoints() + synchronizeBreakpoints(for: normalizedURL) + } + + public func updateBreakpoint( + fileURL: URL, + line: Int, + enabled: Bool, + condition: String?, + hitCondition: String?, + logMessage: String? + ) { + guard line > 0 else { return } + let normalizedURL = fileURL.standardizedFileURL + var values = requestedBreakpointsByFile[normalizedURL] ?? [:] + values[line] = DebugSourceBreakpoint( + line: line, + enabled: enabled, + condition: normalizedOptionalText(condition), + hitCondition: normalizedOptionalText(hitCondition), + logMessage: normalizedOptionalText(logMessage) + ) + requestedBreakpointsByFile[normalizedURL] = values reconcileBreakpoints() - try? sessions.setBreakpoints( - lines.sorted().map { DebugSourceBreakpoint(line: $0) }, - in: normalizedURL + synchronizeBreakpoints(for: normalizedURL) + } + + public func setBreakpointEnabled(_ breakpoint: GenericDebugBreakpoint, enabled: Bool) { + updateBreakpoint( + fileURL: breakpoint.fileURL, + line: breakpoint.line, + enabled: enabled, + condition: breakpoint.condition, + hitCondition: breakpoint.hitCondition, + logMessage: breakpoint.logMessage + ) + } + + public func removeBreakpoint(_ breakpoint: GenericDebugBreakpoint) { + let fileURL = breakpoint.fileURL.standardizedFileURL + var values = requestedBreakpointsByFile[fileURL] ?? [:] + values[breakpoint.line] = nil + requestedBreakpointsByFile[fileURL] = values.isEmpty ? nil : values + reconcileBreakpoints() + synchronizeBreakpoints(for: fileURL) + } + + public func removeAllBreakpoints() { + let fileURLs = requestedBreakpointsByFile.keys.sorted { $0.path < $1.path } + requestedBreakpointsByFile = [:] + reconcileBreakpoints() + for fileURL in fileURLs { synchronizeBreakpoints(for: fileURL) } + } + + public func toggleBreakpointMute() { + areBreakpointsMuted.toggle() + for fileURL in requestedBreakpointsByFile.keys.sorted(by: { $0.path < $1.path }) { + synchronizeBreakpoints(for: fileURL) + } + } + + public func updateExceptionBreakpoint( + _ breakpoint: GenericDebugExceptionBreakpoint, + enabled: Bool, + condition: String? + ) { + guard let index = exceptionBreakpoints.firstIndex(where: { $0.filter == breakpoint.filter }) + else { return } + exceptionBreakpoints[index] = GenericDebugExceptionBreakpoint( + filter: breakpoint.filter, + label: breakpoint.label, + description: breakpoint.description, + enabled: enabled, + condition: breakpoint.supportsCondition ? normalizedOptionalText(condition) : nil, + supportsCondition: breakpoint.supportsCondition, + conditionDescription: breakpoint.conditionDescription + ) + synchronizeExceptionBreakpoints() + } + + public func addFunctionBreakpoint( + name: String, + condition: String?, + hitCondition: String? + ) { + let normalizedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedName.isEmpty else { return } + let breakpoint = GenericDebugFunctionBreakpoint( + name: normalizedName, + enabled: true, + condition: normalizedOptionalText(condition), + hitCondition: normalizedOptionalText(hitCondition), + verified: false, + message: nil + ) + if let index = functionBreakpoints.firstIndex(where: { $0.name == normalizedName }) { + functionBreakpoints[index] = breakpoint + } else { + functionBreakpoints.append(breakpoint) + } + functionBreakpoints.sort { $0.name < $1.name } + synchronizeFunctionBreakpoints() + } + + public func updateFunctionBreakpoint( + _ breakpoint: GenericDebugFunctionBreakpoint, + name: String, + enabled: Bool, + condition: String?, + hitCondition: String? + ) { + let normalizedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedName.isEmpty else { return } + let previousVerification = functionBreakpoints.first { $0.name == breakpoint.name } + functionBreakpoints.removeAll { + $0.name == breakpoint.name || $0.name == normalizedName + } + functionBreakpoints.append(GenericDebugFunctionBreakpoint( + name: normalizedName, + enabled: enabled, + condition: normalizedOptionalText(condition), + hitCondition: normalizedOptionalText(hitCondition), + verified: previousVerification?.verified ?? breakpoint.verified, + message: previousVerification?.message ?? breakpoint.message + )) + functionBreakpoints.sort { $0.name < $1.name } + synchronizeFunctionBreakpoints() + } + + public func setFunctionBreakpointEnabled( + _ breakpoint: GenericDebugFunctionBreakpoint, + enabled: Bool + ) { + updateFunctionBreakpoint( + breakpoint, + name: breakpoint.name, + enabled: enabled, + condition: breakpoint.condition, + hitCondition: breakpoint.hitCondition + ) + } + + public func removeFunctionBreakpoint(_ breakpoint: GenericDebugFunctionBreakpoint) { + functionBreakpoints.removeAll { $0.name == breakpoint.name } + synchronizeFunctionBreakpoints() + } + + public func requestDataBreakpoint(for variable: DebugVariable) { + guard state == .paused, + capabilities.supportsDataBreakpoints, + let session = activeSession else { return } + session.requestDataBreakpointInfo( + name: variable.name, + variablesReference: variable.containerReference, + frameID: selectedFrameID + ) { [weak self] result in + guard let self else { return } + switch result { + case .success(let info): + guard let dataID = info.dataID, !dataID.isEmpty else { + record(DebugAdapterProtocolError.requestFailed( + command: "dataBreakpointInfo", + message: info.description + )) + return + } + let preferredAccess = info.accessTypes.contains("write") + ? "write" : info.accessTypes.first + let breakpoint = GenericDebugDataBreakpoint( + dataID: dataID, + label: info.description.isEmpty ? variable.name : info.description, + enabled: true, + accessType: preferredAccess, + accessTypes: info.accessTypes, + condition: nil, + hitCondition: nil, + canPersist: info.canPersist, + verified: false, + message: nil + ) + dataBreakpoints.removeAll { $0.id == breakpoint.id } + dataBreakpoints.append(breakpoint) + sortDataBreakpoints() + synchronizeDataBreakpoints() + case .failure(let error): + record(error) + } + } + } + + public func updateDataBreakpoint( + _ breakpoint: GenericDebugDataBreakpoint, + enabled: Bool, + accessType: String?, + condition: String?, + hitCondition: String? + ) { + guard let index = dataBreakpoints.firstIndex(where: { $0.id == breakpoint.id }) else { return } + let replacement = GenericDebugDataBreakpoint( + dataID: breakpoint.dataID, + label: breakpoint.label, + enabled: enabled, + accessType: normalizedOptionalText(accessType), + accessTypes: breakpoint.accessTypes, + condition: normalizedOptionalText(condition), + hitCondition: normalizedOptionalText(hitCondition), + canPersist: breakpoint.canPersist, + verified: breakpoint.verified, + message: breakpoint.message ) + dataBreakpoints.remove(at: index) + dataBreakpoints.removeAll { $0.id == replacement.id } + dataBreakpoints.append(replacement) + sortDataBreakpoints() + synchronizeDataBreakpoints() + } + + public func setDataBreakpointEnabled( + _ breakpoint: GenericDebugDataBreakpoint, + enabled: Bool + ) { + updateDataBreakpoint( + breakpoint, + enabled: enabled, + accessType: breakpoint.accessType, + condition: breakpoint.condition, + hitCondition: breakpoint.hitCondition + ) + } + + public func removeDataBreakpoint(_ breakpoint: GenericDebugDataBreakpoint) { + dataBreakpoints.removeAll { $0.id == breakpoint.id } + synchronizeDataBreakpoints() } public func execute(_ command: DebugExecutionCommand) { @@ -135,6 +467,67 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu session.execute(command, threadID: selectedThreadID) } + public func executeThread(_ command: DebugExecutionCommand, thread: DebugThread) { + guard capabilities.supportsSingleThreadExecutionRequests, + (command == .continueExecution && state == .paused) + || (command == .pause && state == .running), + let session = activeSession else { return } + session.execute( + command, + threadID: thread.id, + targetID: nil, + singleThread: true + ) + } + + public func requestSmartStepInto( + completion: @escaping (Result<[DebugStepInTarget], Error>) -> Void + ) { + guard state == .paused, + capabilities.supportsStepInTargetsRequest, + let selectedFrameID, + let session = activeSession else { + completion(.failure(DebugAdapterCapabilityError.unsupported("smart step into"))) + return + } + session.requestStepInTargets(frameID: selectedFrameID) { [weak self] result in + if case .failure(let error) = result { self?.record(error) } + completion(result) + } + } + + public func smartStepInto(_ target: DebugStepInTarget) { + guard let selectedThreadID, let session = activeSession else { return } + session.execute(.stepIn, threadID: selectedThreadID, targetID: target.id) + } + + public func requestRunToCursor( + fileURL: URL, + line: Int, + column: Int?, + completion: @escaping (Result<[DebugGotoTarget], Error>) -> Void + ) { + guard state == .paused, + capabilities.supportsGotoTargetsRequest, + let session = activeSession else { + completion(.failure(DebugAdapterCapabilityError.unsupported("run to cursor"))) + return + } + session.requestGotoTargets( + fileURL: fileURL, + line: line, + column: column + ) { [weak self] result in + if case .failure(let error) = result { self?.record(error) } + completion(result) + } + } + + public func runToCursor(_ target: DebugGotoTarget) { + guard let selectedThreadID, let session = activeSession else { return } + session.execute(.goto, threadID: selectedThreadID, targetID: target.id) + } + public func inspectThreads() { guard let session = activeSession else { return } session.requestThreads { [weak self] result in @@ -148,6 +541,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } public func selectThread(_ thread: DebugThread) { + activeSession?.cancelPendingOperations() selectedThreadID = thread.id guard let session = activeSession else { return } session.requestStackTrace(threadID: thread.id) { [weak self] result in @@ -162,7 +556,9 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } public func selectFrame(_ frame: DebugStackFrame) { + activeSession?.cancelPendingOperations() selectedFrameID = frame.id + refreshWatches() guard let session = activeSession else { return } session.requestScopes(frameID: frame.id) { [weak self] result in switch result { @@ -171,7 +567,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu if let scope = scopes.first(where: { !$0.expensive }) ?? scopes.first { self?.loadVariables(reference: scope.variablesReference) } else { - self?.variables = [] + self?.resetVariableTree() } case .failure(let error): self?.record(error) } @@ -182,12 +578,119 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu guard let session = activeSession else { return } session.requestVariables(reference: reference) { [weak self] result in switch result { - case .success(let variables): self?.variables = variables + case .success(let variables): + self?.variables = variables + self?.variableChildren = [:] + self?.expandedVariableIDs = [] + self?.loadingVariableIDs = [] case .failure(let error): self?.record(error) } } } + public func toggleVariableExpansion(_ variable: DebugVariable) { + guard variable.isExpandable else { return } + if expandedVariableIDs.contains(variable.id) { + expandedVariableIDs.remove(variable.id) + return + } + if variableChildren[variable.id] != nil { + expandedVariableIDs.insert(variable.id) + return + } + guard !loadingVariableIDs.contains(variable.id), let session = activeSession else { return } + loadingVariableIDs.insert(variable.id) + session.requestVariables(reference: variable.variablesReference) { [weak self] result in + guard let self else { return } + self.loadingVariableIDs.remove(variable.id) + switch result { + case .success(let children): + self.variableChildren[variable.id] = children + self.expandedVariableIDs.insert(variable.id) + case .failure(let error): + self.record(error) + } + } + } + + public func children(of variable: DebugVariable) -> [DebugVariable] { + variableChildren[variable.id] ?? [] + } + + public func isVariableExpanded(_ variable: DebugVariable) -> Bool { + expandedVariableIDs.contains(variable.id) + } + + public func isVariableLoading(_ variable: DebugVariable) -> Bool { + loadingVariableIDs.contains(variable.id) + } + + public func setVariable(_ variable: DebugVariable, value: String) { + guard state == .paused, + capabilities.supportsSetVariable, + let containerReference = variable.containerReference, + let session = activeSession else { return } + session.setVariable( + variablesReference: containerReference, + name: variable.name, + value: value + ) { [weak self] result in + guard let self else { return } + switch result { + case .success(let replacement): + let updated = DebugVariable( + id: variable.id, + name: variable.name, + value: replacement.value, + type: replacement.type ?? variable.type, + evaluateName: variable.evaluateName, + variablesReference: replacement.variablesReference, + containerReference: containerReference + ) + self.replaceVariable(updated) + self.refreshWatches() + case .failure(let error): + self.record(error) + } + } + } + + public func addWatch(_ expression: String) { + let expression = expression.trimmingCharacters(in: .whitespacesAndNewlines) + guard !expression.isEmpty else { return } + if !watches.contains(where: { $0.expression == expression }) { + watches.append(GenericDebugWatch( + expression: expression, + value: nil, + type: nil, + error: nil + )) + } + refreshWatches() + } + + public func updateWatch(_ watch: GenericDebugWatch, expression: String) { + let expression = expression.trimmingCharacters(in: .whitespacesAndNewlines) + guard !expression.isEmpty else { return } + watches.removeAll { $0.expression == watch.expression || $0.expression == expression } + watches.append(GenericDebugWatch(expression: expression, value: nil, type: nil, error: nil)) + refreshWatches() + } + + public func removeWatch(_ watch: GenericDebugWatch) { + watches.removeAll { $0.expression == watch.expression } + refreshWatches() + } + + public func refreshWatches() { + watchGeneration += 1 + let generation = watchGeneration + let expressions = watches.map(\.expression) + for expression in expressions { + evaluateWatch(expression, generation: generation) + } + } + public func evaluate(_ expression: String) { let value = expression.trimmingCharacters(in: .whitespacesAndNewlines) guard !value.isEmpty, let session = activeSession else { return } @@ -207,6 +710,35 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu return sessions.session(providerID: providerID) } + private func evaluateWatch(_ expression: String, generation: Int) { + guard state == .paused, let session = activeSession else { return } + let frameID = selectedFrameID + session.evaluate(expression, frameID: frameID) { [weak self] result in + guard let self, + self.watchGeneration == generation, + self.selectedFrameID == frameID, + let index = self.watches.firstIndex(where: { $0.expression == expression }) + else { return } + switch result { + case .success(let variable): + self.watches[index].value = variable.value + self.watches[index].type = variable.type + self.watches[index].error = nil + case .failure(let error): + self.watches[index].value = nil + self.watches[index].type = nil + self.watches[index].error = error.localizedDescription + } + } + } + + private func invalidateWatchResults() { + watchGeneration += 1 + watches = watches.map { + GenericDebugWatch(expression: $0.expression, value: nil, type: nil, error: nil) + } + } + private func sessionsProviderID(for fileURL: URL) -> String? { sessions.provider(for: fileURL)?.id } @@ -215,6 +747,10 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu switch event { case .initialized: break + case .capabilities(let capabilities): + self.capabilities = capabilities + reconcileExceptionBreakpoints(with: capabilities.exceptionBreakpointFilters) + synchronizeExceptionBreakpoints() case .output(_, let text): append(text) case .stopped(let reason, let threadID, let description): @@ -234,9 +770,25 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } case .continued: stoppedReason = nil + activeSession?.cancelPendingOperations() + resetVariableTree() + invalidateWatchResults() case .terminated(let exitCode): + invalidateWatchResults() if let exitCode { append("Debug session exited with code \(exitCode).\n") } case .breakpoint(let resolved): + if let dataID = resolved.dataID, + let index = dataBreakpoints.firstIndex(where: { $0.dataID == dataID }) { + dataBreakpoints[index].verified = resolved.verified + dataBreakpoints[index].message = resolved.message + return + } + if let functionName = resolved.functionName, + let index = functionBreakpoints.firstIndex(where: { $0.name == functionName }) { + functionBreakpoints[index].verified = resolved.verified + functionBreakpoints[index].message = resolved.message + return + } guard let sourceURL = resolved.sourceURL, let line = resolved.line else { return } if let index = breakpoints.firstIndex(where: { $0.fileURL.standardizedFileURL == sourceURL.standardizedFileURL && $0.line == line @@ -248,14 +800,22 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } private func reconcileBreakpoints() { - breakpoints = requestedLinesByFile - .flatMap { fileURL, lines in - lines.map { - GenericDebugBreakpoint( + let previous = Dictionary(uniqueKeysWithValues: breakpoints.map { ($0.id, $0) }) + breakpoints = requestedBreakpointsByFile + .flatMap { fileURL, values in + values.values.map { configuration in + let id = fileURL.standardizedFileURL.path + ":" + + String(configuration.line) + ":" + String(configuration.column ?? 0) + return GenericDebugBreakpoint( fileURL: fileURL, - line: $0, - verified: false, - message: nil + line: configuration.line, + column: configuration.column, + enabled: configuration.enabled, + condition: configuration.condition, + hitCondition: configuration.hitCondition, + logMessage: configuration.logMessage, + verified: previous[id]?.verified ?? false, + message: previous[id]?.message ) } } @@ -265,6 +825,161 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } } + private func effectiveBreakpoints(for fileURL: URL) -> [DebugSourceBreakpoint] { + (requestedBreakpointsByFile[fileURL]?.values ?? [:].values) + .map { breakpoint in + DebugSourceBreakpoint( + line: breakpoint.line, + column: breakpoint.column, + enabled: breakpoint.enabled && !areBreakpointsMuted, + condition: breakpoint.condition, + hitCondition: breakpoint.hitCondition, + logMessage: breakpoint.logMessage + ) + } + .sorted { + ($0.line, $0.column ?? 0) < ($1.line, $1.column ?? 0) + } + } + + private func synchronizeBreakpoints(for fileURL: URL) { + do { + try sessions.setBreakpoints(effectiveBreakpoints(for: fileURL), in: fileURL) + } catch { + record(error) + } + } + + private func reconcileExceptionBreakpoints( + with filters: [DebugExceptionBreakpointFilter] + ) { + let previous = Dictionary(uniqueKeysWithValues: exceptionBreakpoints.map { ($0.filter, $0) }) + exceptionBreakpoints = filters.map { filter in + let existing = previous[filter.filter] + return GenericDebugExceptionBreakpoint( + filter: filter.filter, + label: filter.label, + description: filter.description, + enabled: existing?.enabled ?? filter.isDefault, + condition: filter.supportsCondition ? existing?.condition : nil, + supportsCondition: filter.supportsCondition, + conditionDescription: filter.conditionDescription + ) + } + } + + private func synchronizeExceptionBreakpoints() { + guard let activeFileURL else { return } + do { + try sessions.setExceptionBreakpoints( + exceptionBreakpoints.map { + DebugExceptionBreakpoint( + filter: $0.filter, + enabled: $0.enabled, + condition: $0.condition + ) + }, + for: activeFileURL + ) + } catch { + record(error) + } + } + + private func synchronizeFunctionBreakpoints() { + guard let activeFileURL else { return } + do { + try sessions.setFunctionBreakpoints( + functionBreakpoints.map { + DebugFunctionBreakpoint( + name: $0.name, + enabled: $0.enabled, + condition: $0.condition, + hitCondition: $0.hitCondition + ) + }, + for: activeFileURL + ) + } catch { + record(error) + } + } + + private var coreDataBreakpoints: [DebugDataBreakpoint] { + dataBreakpoints.map { + DebugDataBreakpoint( + dataID: $0.dataID, + label: $0.label, + enabled: $0.enabled, + accessType: $0.accessType, + condition: $0.condition, + hitCondition: $0.hitCondition + ) + } + } + + private func synchronizeDataBreakpoints() { + guard let activeFileURL else { return } + do { + try sessions.setDataBreakpoints(coreDataBreakpoints, for: activeFileURL) + } catch { + record(error) + } + } + + private func sortDataBreakpoints() { + dataBreakpoints.sort { ($0.label, $0.id) < ($1.label, $1.id) } + } + + private func resetVariableTree() { + variables = [] + variableChildren = [:] + expandedVariableIDs = [] + loadingVariableIDs = [] + } + + private func replaceVariable(_ replacement: DebugVariable) { + if let index = variables.firstIndex(where: { $0.id == replacement.id }) { + variables[index] = replacement + return + } + for parentID in variableChildren.keys.sorted() { + guard var children = variableChildren[parentID], + let index = children.firstIndex(where: { $0.id == replacement.id }) else { + continue + } + children[index] = replacement + variableChildren[parentID] = children + return + } + } + + private func appendVisibleVariables( + _ values: [DebugVariable], + parentPath: String, + depth: Int, + to rows: inout [GenericDebugVariableRow] + ) { + for (index, variable) in values.enumerated() { + let path = "\(parentPath)/\(index):\(variable.id)" + rows.append(GenericDebugVariableRow(id: path, variable: variable, depth: depth)) + if expandedVariableIDs.contains(variable.id) { + appendVisibleVariables( + variableChildren[variable.id] ?? [], + parentPath: path, + depth: depth + 1, + to: &rows + ) + } + } + } + + private func normalizedOptionalText(_ value: String?) -> String? { + guard let value else { return nil } + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) + return normalized.isEmpty ? nil : normalized + } + private func record(_ error: Error) { errorMessage = error.localizedDescription append(error.localizedDescription + "\n") diff --git a/macos/Sources/LitheDebugModule/Module/DebugModule.swift b/macos/Sources/LitheDebugModule/Module/DebugModule.swift index 8e1bb492..c7aa0166 100644 --- a/macos/Sources/LitheDebugModule/Module/DebugModule.swift +++ b/macos/Sources/LitheDebugModule/Module/DebugModule.swift @@ -1,15 +1,11 @@ import Foundation import LitheModuleAPI -@MainActor -public protocol JavaDebugFeatureTarget: AnyObject {} - @MainActor public protocol GenericDebugFeatureTarget: AnyObject {} @MainActor public protocol DebugServiceGraph: AnyObject { - var javaFeatureTarget: any JavaDebugFeatureTarget { get } var genericFeatureTarget: any GenericDebugFeatureTarget { get } var hasActiveDebugWork: Bool { get } func activate(context: ModuleContext) @@ -19,11 +15,9 @@ public protocol DebugServiceGraph: AnyObject { @MainActor public final class DebugModuleCapability: NSObject { - public let javaFeature: any JavaDebugFeatureTarget public let genericFeature: any GenericDebugFeatureTarget fileprivate init(graph: any DebugServiceGraph) { - javaFeature = graph.javaFeatureTarget genericFeature = graph.genericFeatureTarget } } diff --git a/macos/Sources/LitheDebugModule/Runtime/CoreDebugAdapterProtocolSession.swift b/macos/Sources/LitheDebugModule/Runtime/CoreDebugAdapterProtocolSession.swift new file mode 100644 index 00000000..7b697cd0 --- /dev/null +++ b/macos/Sources/LitheDebugModule/Runtime/CoreDebugAdapterProtocolSession.swift @@ -0,0 +1,649 @@ +import Foundation +import LitheCoreContracts + +/// DAP session projected from the shared Rust Debug Core. This type owns only +/// callback correlation and UI model conversion; framing and state reduction +/// stay behind `DebugProtocolCore`, while native I/O stays in the transport. +@MainActor +public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSession { + private typealias OperationHandler = (Result) -> Void + + private struct PendingOperation { + let handler: OperationHandler + let deadline: any DebugOperationDeadline + } + + private let adapterID: String + private let transport: any DebugAdapterTransport + private let core: any DebugProtocolCore + private let sessionID: String + private let deadlineScheduler: any DebugOperationDeadlineScheduling + private let operationTimeoutMilliseconds: Int + private var operationHandlers: [String: PendingOperation] = [:] + private var ownsCoreSession = false + private var isStopping = false + + public private(set) var state: DebugAdapterState = .idle { + didSet { if oldValue != state { onStateChange?(state) } } + } + public var onStateChange: ((DebugAdapterState) -> Void)? + public var onEvent: ((DebugAdapterEvent) -> Void)? + public var isRunning: Bool { transport.isRunning } + public private(set) var capabilities: DebugAdapterCapabilities = .unknown + + public init( + adapterID: String, + transport: any DebugAdapterTransport, + core: any DebugProtocolCore, + sessionID: String = UUID().uuidString, + deadlineScheduler: any DebugOperationDeadlineScheduling, + operationTimeoutMilliseconds: Int = 10_000 + ) { + self.adapterID = adapterID + self.transport = transport + self.core = core + self.sessionID = sessionID + self.deadlineScheduler = deadlineScheduler + self.operationTimeoutMilliseconds = max(1, operationTimeoutMilliseconds) + transport.onData = { [weak self] data in self?.receive(data) } + transport.onErrorOutput = { [weak self] data in + guard let text = String(data: data, encoding: .utf8), !text.isEmpty else { return } + self?.onEvent?(.output(category: "stderr", output: text)) + } + transport.onTermination = { [weak self] code in self?.transportTerminated(exitCode: code) } + } + + public func start(rootURL: URL) throws { + guard state == .idle || state == .terminated || state == .failed else { return } + isStopping = false + operationHandlers = [:] + capabilities = .unknown + try transport.start(rootURL: rootURL.standardizedFileURL) + do { + let update = try core.createDebugSession( + sessionID: sessionID, + adapterID: adapterID, + rootPath: rootURL.standardizedFileURL.path + ) + ownsCoreSession = true + try apply(update) + } catch { + transport.stop() + releaseCoreSession() + state = .failed + throw error + } + } + + public func stop() { + guard state != .idle || transport.isRunning || ownsCoreSession else { return } + isStopping = true + if ownsCoreSession, transport.isRunning, + let update = try? core.disconnectDebugSession(sessionID: sessionID) { + try? apply(update) + } + transport.stop() + releaseCoreSession() + failPendingOperations(DebugAdapterProtocolError.stopped) + state = .idle + capabilities = .unknown + isStopping = false + } + + public func launch(_ configuration: DebugLaunchConfiguration) throws { + guard ownsCoreSession else { throw DebugAdapterProtocolError.notReady } + let operationID = UUID().uuidString + let update = try core.launchDebugSession( + sessionID: sessionID, + operationID: operationID, + configuration: configuration + ) + try apply(update) + } + + public func setBreakpoints(_ breakpoints: [DebugSourceBreakpoint], in fileURL: URL) { + guard ownsCoreSession else { return } + do { + try apply(core.setDebugBreakpoints( + sessionID: sessionID, + sourcePath: fileURL.standardizedFileURL.path, + breakpoints: breakpoints + )) + } catch { + onEvent?(.output(category: "stderr", output: error.localizedDescription + "\n")) + } + } + + public func setExceptionBreakpoints(_ breakpoints: [DebugExceptionBreakpoint]) { + guard ownsCoreSession else { return } + do { + try apply(core.setDebugExceptionBreakpoints( + sessionID: sessionID, + breakpoints: breakpoints + )) + } catch { + onEvent?(.output(category: "stderr", output: error.localizedDescription + "\n")) + } + } + + public func setFunctionBreakpoints(_ breakpoints: [DebugFunctionBreakpoint]) { + guard ownsCoreSession else { return } + do { + try apply(core.setDebugFunctionBreakpoints( + sessionID: sessionID, + breakpoints: breakpoints + )) + } catch { + onEvent?(.output(category: "stderr", output: error.localizedDescription + "\n")) + } + } + + public func setDataBreakpoints(_ breakpoints: [DebugDataBreakpoint]) { + guard ownsCoreSession else { return } + do { + try apply(core.setDebugDataBreakpoints( + sessionID: sessionID, + breakpoints: breakpoints + )) + } catch { + onEvent?(.output(category: "stderr", output: error.localizedDescription + "\n")) + } + } + + public func requestDataBreakpointInfo( + name: String, + variablesReference: Int?, + frameID: Int?, + completion: @escaping (Result) -> Void + ) { + guard ownsCoreSession else { + completion(.failure(DebugAdapterProtocolError.stopped)) + return + } + guard capabilities.supportsDataBreakpoints else { + completion(.failure(DebugAdapterCapabilityError.unsupported("data breakpoints"))) + return + } + let operationID = UUID().uuidString + registerOperation(operationID) { result in + completion(result.flatMap { value in + guard value.kind == "dataBreakpointInfo", + let description = value.description else { + return .failure(DebugAdapterProtocolError.invalidResponse("dataBreakpointInfo")) + } + return .success(DebugDataBreakpointInfo( + dataID: value.dataID, + description: description, + accessTypes: value.accessTypes ?? [], + canPersist: value.canPersist ?? false + )) + }) + } + do { + try apply(core.debugDataBreakpointInfo( + sessionID: sessionID, + operationID: operationID, + name: name, + variablesReference: variablesReference, + frameID: frameID + )) + } catch { + completeOperation(operationID, result: .failure(error)) + } + } + + public func execute(_ command: DebugExecutionCommand, threadID: Int?) { + execute(command, threadID: threadID, targetID: nil, singleThread: false) + } + + public func execute(_ command: DebugExecutionCommand, threadID: Int?, targetID: Int?) { + execute(command, threadID: threadID, targetID: targetID, singleThread: false) + } + + public func execute( + _ command: DebugExecutionCommand, + threadID: Int?, + targetID: Int?, + singleThread: Bool + ) { + guard ownsCoreSession else { return } + let operationID = UUID().uuidString + do { + try apply(core.executeDebugCommand( + sessionID: sessionID, + operationID: operationID, + command: command, + threadID: threadID, + targetID: targetID, + singleThread: singleThread + )) + } catch { + onEvent?(.output(category: "stderr", output: error.localizedDescription + "\n")) + } + } + + public func requestThreads(_ completion: @escaping (Result<[DebugThread], Error>) -> Void) { + inspect(kind: "threads") { result in + completion(result.flatMap { value in + guard value.kind == "threads", let threads = value.threads else { + return .failure(DebugAdapterProtocolError.invalidResponse("threads")) + } + return .success(threads.map { DebugThread(id: $0.id, name: $0.name) }) + }) + } + } + + public func requestStackTrace( + threadID: Int, + completion: @escaping (Result<[DebugStackFrame], Error>) -> Void + ) { + inspect(kind: "stackTrace", threadID: threadID) { result in + completion(result.flatMap { value in + guard value.kind == "stackTrace", let frames = value.stackFrames else { + return .failure(DebugAdapterProtocolError.invalidResponse("stackTrace")) + } + return .success(frames.map { + DebugStackFrame( + id: $0.id, + name: $0.name, + sourceURL: $0.sourcePath.map { URL(fileURLWithPath: $0) }, + line: $0.line, + column: $0.column + ) + }) + }) + } + } + + public func requestScopes( + frameID: Int, + completion: @escaping (Result<[DebugScope], Error>) -> Void + ) { + inspect(kind: "scopes", frameID: frameID) { result in + completion(result.flatMap { value in + guard value.kind == "scopes", let scopes = value.scopes else { + return .failure(DebugAdapterProtocolError.invalidResponse("scopes")) + } + return .success(scopes.enumerated().map { offset, scope in + DebugScope( + id: scope.variablesReference * 1_000 + offset, + name: scope.name, + variablesReference: scope.variablesReference, + expensive: scope.expensive + ) + }) + }) + } + } + + public func requestVariables( + reference: Int, + completion: @escaping (Result<[DebugVariable], Error>) -> Void + ) { + inspect(kind: "variables", variablesReference: reference) { result in + completion(result.flatMap { value in + guard value.kind == "variables", let variables = value.variables else { + return .failure(DebugAdapterProtocolError.invalidResponse("variables")) + } + return .success(variables.enumerated().map { offset, variable in + Self.makeVariable( + variable, + fallbackID: "\(reference):\(offset)", + containerReference: reference + ) + }) + }) + } + } + + public func setVariable( + variablesReference: Int, + name: String, + value: String, + completion: @escaping (Result) -> Void + ) { + guard ownsCoreSession else { + completion(.failure(DebugAdapterProtocolError.stopped)) + return + } + guard capabilities.supportsSetVariable else { + completion(.failure(DebugAdapterCapabilityError.unsupported("variable mutation"))) + return + } + let operationID = UUID().uuidString + registerOperation(operationID) { result in + completion(result.flatMap { result in + guard result.kind == "setVariable", let variable = result.variable else { + return .failure(DebugAdapterProtocolError.invalidResponse("setVariable")) + } + return .success(Self.makeVariable( + variable, + fallbackID: "\(variablesReference):\(name)", + containerReference: variablesReference + )) + }) + } + do { + try apply(core.setDebugVariable( + sessionID: sessionID, + operationID: operationID, + variablesReference: variablesReference, + name: name, + value: value + )) + } catch { + completeOperation(operationID, result: .failure(error)) + } + } + + public func evaluate( + _ expression: String, + frameID: Int?, + completion: @escaping (Result) -> Void + ) { + inspect(kind: "evaluate", frameID: frameID, expression: expression) { result in + completion(result.flatMap { value in + guard value.kind == "evaluate", let variable = value.variable else { + return .failure(DebugAdapterProtocolError.invalidResponse("evaluate")) + } + return .success(Self.makeVariable(variable, fallbackID: expression)) + }) + } + } + + public func requestStepInTargets( + frameID: Int, + completion: @escaping (Result<[DebugStepInTarget], Error>) -> Void + ) { + inspect(kind: "stepInTargets", frameID: frameID) { result in + completion(result.flatMap { value in + guard value.kind == "stepInTargets", let targets = value.targets else { + return .failure(DebugAdapterProtocolError.invalidResponse("stepInTargets")) + } + return .success(targets.map { + DebugStepInTarget( + id: $0.id, + label: $0.label, + line: $0.line, + column: $0.column, + endLine: $0.endLine, + endColumn: $0.endColumn + ) + }) + }) + } + } + + public func requestGotoTargets( + fileURL: URL, + line: Int, + column: Int?, + completion: @escaping (Result<[DebugGotoTarget], Error>) -> Void + ) { + inspect( + kind: "gotoTargets", + sourcePath: fileURL.standardizedFileURL.path, + line: line, + column: column + ) { result in + completion(result.flatMap { value in + guard value.kind == "gotoTargets", let targets = value.targets else { + return .failure(DebugAdapterProtocolError.invalidResponse("gotoTargets")) + } + return .success(targets.compactMap { + guard let line = $0.line else { return nil } + return DebugGotoTarget( + id: $0.id, + label: $0.label, + line: line, + column: $0.column, + endLine: $0.endLine, + endColumn: $0.endColumn, + instructionPointerReference: $0.instructionPointerReference + ) + }) + }) + } + } + + private func inspect( + kind: String, + threadID: Int? = nil, + frameID: Int? = nil, + variablesReference: Int? = nil, + expression: String? = nil, + sourcePath: String? = nil, + line: Int? = nil, + column: Int? = nil, + completion: @escaping OperationHandler + ) { + guard ownsCoreSession else { + completion(.failure(DebugAdapterProtocolError.stopped)) + return + } + let operationID = UUID().uuidString + registerOperation(operationID, handler: completion) + do { + try apply(core.inspectDebugSession( + sessionID: sessionID, + operationID: operationID, + kind: kind, + threadID: threadID, + frameID: frameID, + variablesReference: variablesReference, + expression: expression, + sourcePath: sourcePath, + line: line, + column: column + )) + } catch { + completeOperation(operationID, result: .failure(error)) + } + } + + public func cancelPendingOperations() { + for operationID in operationHandlers.keys.sorted() { + cancelOperation(operationID, reason: "cancelled") + } + } + + private func receive(_ data: Data) { + guard ownsCoreSession else { return } + do { + try apply(core.receiveDebugData(sessionID: sessionID, data: data)) + } catch { + onEvent?(.output(category: "stderr", output: error.localizedDescription + "\n")) + failSession() + } + } + + private func apply(_ update: DebugCoreUpdate) throws { + guard update.sessionID == sessionID else { + throw DebugAdapterProtocolError.invalidResponse("session update") + } + for frame in update.outboundFrames { + guard let data = Data(base64Encoded: frame) else { + throw DebugAdapterProtocolError.invalidResponse("outbound frame") + } + try transport.send(data) + } + for event in update.events.sorted(by: { $0.sequence < $1.sequence }) { + consume(event) + } + state = Self.adapterState(update.state) + } + + private func consume(_ event: DebugCoreEvent) { + switch event.type { + case "stateChanged": + if let state = event.state { self.state = Self.adapterState(state) } + case "initialized": + onEvent?(.initialized) + case "capabilities": + guard let value = event.capabilities else { return } + capabilities = Self.makeCapabilities(value) + onEvent?(.capabilities(capabilities)) + case "output": + onEvent?(.output(category: event.category, output: event.output ?? "")) + case "stopped": + onEvent?(.stopped( + reason: event.reason ?? "stopped", + threadID: event.threadID, + description: event.description + )) + case "continued": + onEvent?(.continued(threadID: event.threadID)) + case "terminated": + onEvent?(.terminated(exitCode: event.exitCode)) + case "breakpoint": + guard let breakpoint = event.breakpoint else { return } + onEvent?(.breakpoint(DebugBreakpoint( + id: breakpoint.id, + verified: breakpoint.verified, + message: breakpoint.message, + sourceURL: breakpoint.sourcePath.map { URL(fileURLWithPath: $0) }, + line: breakpoint.line, + column: breakpoint.column, + functionName: breakpoint.functionName, + dataID: breakpoint.dataID + ))) + case "operationCompleted": + guard let operationID = event.operationID, + let result = event.result else { return } + completeOperation(operationID, result: .success(result)) + case "operationFailed": + guard let operationID = event.operationID else { return } + let command = event.command ?? "request" + let error: DebugAdapterProtocolError = switch event.code { + case "cancelled": .cancelled(command) + case "timedOut": .timedOut(command) + default: .requestFailed( + command: command, + message: event.message ?? "The Debug Adapter rejected the request." + ) + } + if operationHandlers[operationID] != nil { + completeOperation(operationID, result: .failure(error)) + } else { + onEvent?(.output(category: "stderr", output: error.localizedDescription + "\n")) + } + default: + break + } + } + + private func transportTerminated(exitCode: Int) { + guard !isStopping else { return } + releaseCoreSession() + failPendingOperations(DebugAdapterProtocolError.stopped) + state = exitCode == 0 ? .terminated : .failed + onEvent?(.terminated(exitCode: exitCode)) + } + + private func failSession() { + transport.stop() + releaseCoreSession() + failPendingOperations(DebugAdapterProtocolError.stopped) + state = .failed + } + + private func releaseCoreSession() { + guard ownsCoreSession else { return } + ownsCoreSession = false + core.destroyDebugSession(sessionID: sessionID) + } + + private static func makeCapabilities( + _ value: DebugCoreCapabilities + ) -> DebugAdapterCapabilities { + DebugAdapterCapabilities( + negotiated: true, + supportsConfigurationDone: value.supportsConfigurationDone, + supportsConditionalBreakpoints: value.supportsConditionalBreakpoints, + supportsHitConditionalBreakpoints: value.supportsHitConditionalBreakpoints, + supportsLogPoints: value.supportsLogPoints, + supportsFunctionBreakpoints: value.supportsFunctionBreakpoints, + supportsDataBreakpoints: value.supportsDataBreakpoints, + supportsExceptionOptions: value.supportsExceptionOptions, + supportsExceptionFilterOptions: value.supportsExceptionFilterOptions, + supportsSetVariable: value.supportsSetVariable, + supportsCancelRequest: value.supportsCancelRequest, + supportsSingleThreadExecutionRequests: value.supportsSingleThreadExecutionRequests, + supportsRestartRequest: value.supportsRestartRequest, + supportsTerminateRequest: value.supportsTerminateRequest, + supportsStepBack: value.supportsStepBack, + supportsStepInTargetsRequest: value.supportsStepInTargetsRequest, + supportsGotoTargetsRequest: value.supportsGotoTargetsRequest, + exceptionBreakpointFilters: value.exceptionBreakpointFilters + ) + } + + private func failPendingOperations(_ error: Error) { + let handlers = operationHandlers.values + operationHandlers = [:] + handlers.forEach { + $0.deadline.cancel() + $0.handler(.failure(error)) + } + } + + private func registerOperation(_ operationID: String, handler: @escaping OperationHandler) { + let deadline = deadlineScheduler.schedule( + afterMilliseconds: operationTimeoutMilliseconds + ) { [weak self] in + self?.cancelOperation(operationID, reason: "timedOut") + } + operationHandlers[operationID] = PendingOperation(handler: handler, deadline: deadline) + } + + private func completeOperation( + _ operationID: String, + result: Result + ) { + guard let operation = operationHandlers.removeValue(forKey: operationID) else { return } + operation.deadline.cancel() + operation.handler(result) + } + + private func cancelOperation(_ operationID: String, reason: String) { + guard operationHandlers[operationID] != nil, ownsCoreSession else { return } + do { + try apply(core.cancelDebugOperation( + sessionID: sessionID, + operationID: operationID, + reason: reason + )) + } catch { + completeOperation(operationID, result: .failure(error)) + } + } + + private static func adapterState(_ state: DebugCoreSessionState) -> DebugAdapterState { + switch state { + case .idle: .idle + case .initializing: .initializing + case .ready: .ready + case .launching: .launching + case .running: .running + case .paused: .paused + case .terminating, .terminated: .terminated + case .failed: .failed + } + } + + private static func makeVariable( + _ variable: DebugCoreVariable, + fallbackID: String, + containerReference: Int? = nil + ) -> DebugVariable { + DebugVariable( + id: variable.evaluateName ?? fallbackID + ":" + variable.name, + name: variable.name, + value: variable.value, + type: variable.type, + evaluateName: variable.evaluateName, + variablesReference: variable.variablesReference, + containerReference: containerReference + ) + } +} diff --git a/macos/Sources/LitheDebugModule/Runtime/DebugAdapterProtocolSession.swift b/macos/Sources/LitheDebugModule/Runtime/DebugAdapterProtocolSession.swift index 5fc30445..45319c49 100644 --- a/macos/Sources/LitheDebugModule/Runtime/DebugAdapterProtocolSession.swift +++ b/macos/Sources/LitheDebugModule/Runtime/DebugAdapterProtocolSession.swift @@ -6,6 +6,8 @@ public enum DebugAdapterProtocolError: LocalizedError { case stopped case invalidResponse(String) case requestFailed(command: String, message: String) + case cancelled(String) + case timedOut(String) public var errorDescription: String? { switch self { @@ -17,6 +19,10 @@ public enum DebugAdapterProtocolError: LocalizedError { "The Debug Adapter returned an invalid \(command) response." case .requestFailed(let command, let message): "\(command) failed: \(message)" + case .cancelled(let command): + "\(command) was cancelled." + case .timedOut(let command): + "\(command) timed out." } } } @@ -35,6 +41,9 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession { private var nextSequence = 1 private var responseHandlers: [Int: ResponseHandler] = [:] private var breakpointsBySource: [URL: [DebugSourceBreakpoint]] = [:] + private var exceptionBreakpoints: [DebugExceptionBreakpoint] = [] + private var functionBreakpoints: [DebugFunctionBreakpoint] = [] + private var dataBreakpoints: [DebugDataBreakpoint] = [] private var didReceiveInitializedEvent = false private var supportsConfigurationDone = false private var pendingLaunch: DebugLaunchConfiguration? @@ -49,6 +58,7 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession { } public var onStateChange: ((DebugAdapterState) -> Void)? public var onEvent: ((DebugAdapterEvent) -> Void)? + public private(set) var capabilities: DebugAdapterCapabilities = .unknown public init(adapterID: String, transport: any DebugAdapterTransport) { self.adapterID = adapterID @@ -95,7 +105,9 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession { switch result { case .success(let response): let body = response["body"] as? [String: Any] - self.supportsConfigurationDone = body?["supportsConfigurationDoneRequest"] as? Bool ?? false + self.capabilities = Self.parseCapabilities(body ?? [:]) + self.supportsConfigurationDone = self.capabilities.supportsConfigurationDone + self.onEvent?(.capabilities(self.capabilities)) self.state = .ready if let pendingLaunch = self.pendingLaunch { self.pendingLaunch = nil @@ -137,6 +149,43 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession { } } + private static func parseCapabilities(_ body: [String: Any]) -> DebugAdapterCapabilities { + let filters = (body["exceptionBreakpointFilters"] as? [[String: Any]] ?? []) + .compactMap { value -> DebugExceptionBreakpointFilter? in + guard let filter = value["filter"] as? String, !filter.isEmpty, + let label = value["label"] as? String, !label.isEmpty else { return nil } + return DebugExceptionBreakpointFilter( + filter: filter, + label: label, + description: value["description"] as? String, + isDefault: value["default"] as? Bool ?? false, + supportsCondition: value["supportsCondition"] as? Bool ?? false, + conditionDescription: value["conditionDescription"] as? String + ) + } + return DebugAdapterCapabilities( + negotiated: true, + supportsConfigurationDone: body["supportsConfigurationDoneRequest"] as? Bool ?? false, + supportsConditionalBreakpoints: body["supportsConditionalBreakpoints"] as? Bool ?? false, + supportsHitConditionalBreakpoints: body["supportsHitConditionalBreakpoints"] as? Bool ?? false, + supportsLogPoints: body["supportsLogPoints"] as? Bool ?? false, + supportsFunctionBreakpoints: body["supportsFunctionBreakpoints"] as? Bool ?? false, + supportsDataBreakpoints: body["supportsDataBreakpoints"] as? Bool ?? false, + supportsExceptionOptions: body["supportsExceptionOptions"] as? Bool ?? false, + supportsExceptionFilterOptions: body["supportsExceptionFilterOptions"] as? Bool ?? false, + supportsSetVariable: body["supportsSetVariable"] as? Bool ?? false, + supportsCancelRequest: body["supportsCancelRequest"] as? Bool ?? false, + supportsSingleThreadExecutionRequests: + body["supportsSingleThreadExecutionRequests"] as? Bool ?? false, + supportsRestartRequest: body["supportsRestartRequest"] as? Bool ?? false, + supportsTerminateRequest: body["supportsTerminateRequest"] as? Bool ?? false, + supportsStepBack: body["supportsStepBack"] as? Bool ?? false, + supportsStepInTargetsRequest: body["supportsStepInTargetsRequest"] as? Bool ?? false, + supportsGotoTargetsRequest: body["supportsGotoTargetsRequest"] as? Bool ?? false, + exceptionBreakpointFilters: filters + ) + } + public func setBreakpoints(_ breakpoints: [DebugSourceBreakpoint], in fileURL: URL) { let normalizedURL = fileURL.standardizedFileURL breakpointsBySource[normalizedURL] = breakpoints.sorted { $0.line < $1.line } @@ -145,20 +194,117 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession { sendBreakpoints(for: normalizedURL) } + public func setExceptionBreakpoints(_ breakpoints: [DebugExceptionBreakpoint]) { + exceptionBreakpoints = breakpoints.sorted { $0.filter < $1.filter } + childSessions.forEach { $0.setExceptionBreakpoints(breakpoints) } + guard didReceiveInitializedEvent else { return } + sendExceptionBreakpoints() + } + + public func setFunctionBreakpoints(_ breakpoints: [DebugFunctionBreakpoint]) { + functionBreakpoints = breakpoints.sorted { $0.name < $1.name } + childSessions.forEach { $0.setFunctionBreakpoints(breakpoints) } + guard didReceiveInitializedEvent, capabilities.supportsFunctionBreakpoints else { return } + sendFunctionBreakpoints() + } + + public func setDataBreakpoints(_ breakpoints: [DebugDataBreakpoint]) { + dataBreakpoints = breakpoints.sorted { + ($0.dataID, $0.accessType ?? "") < ($1.dataID, $1.accessType ?? "") + } + childSessions.forEach { $0.setDataBreakpoints(breakpoints) } + guard didReceiveInitializedEvent, capabilities.supportsDataBreakpoints else { return } + sendDataBreakpoints() + } + + public func requestDataBreakpointInfo( + name: String, + variablesReference: Int?, + frameID: Int?, + completion: @escaping (Result) -> Void + ) { + if let activeChildSession { + activeChildSession.requestDataBreakpointInfo( + name: name, + variablesReference: variablesReference, + frameID: frameID, + completion: completion + ) + return + } + guard capabilities.supportsDataBreakpoints else { + completion(.failure(DebugAdapterCapabilityError.unsupported("data breakpoints"))) + return + } + var arguments: [String: Any] = ["name": name] + if let variablesReference { arguments["variablesReference"] = variablesReference } + if let frameID { arguments["frameId"] = frameID } + sendRequest(command: "dataBreakpointInfo", arguments: arguments) { result in + completion(result.flatMap { response in + guard let body = response["body"] as? [String: Any], + let description = body["description"] as? String else { + return .failure(DebugAdapterProtocolError.invalidResponse("dataBreakpointInfo")) + } + return .success(DebugDataBreakpointInfo( + dataID: body["dataId"] as? String, + description: description, + accessTypes: body["accessTypes"] as? [String] ?? [], + canPersist: body["canPersist"] as? Bool ?? false + )) + }) + } + } + public func execute(_ command: DebugExecutionCommand, threadID: Int?) { + execute(command, threadID: threadID, targetID: nil, singleThread: false) + } + + public func execute(_ command: DebugExecutionCommand, threadID: Int?, targetID: Int?) { + execute(command, threadID: threadID, targetID: targetID, singleThread: false) + } + + public func execute( + _ command: DebugExecutionCommand, + threadID: Int?, + targetID: Int?, + singleThread: Bool + ) { if let activeChildSession { - activeChildSession.execute(command, threadID: threadID) + activeChildSession.execute( + command, + threadID: threadID, + targetID: targetID, + singleThread: singleThread + ) return } guard transport.isRunning else { return } + if command == .stepBack, !capabilities.supportsStepBack { return } + if command == .goto, !capabilities.supportsGotoTargetsRequest { return } + if command == .restart, !capabilities.supportsRestartRequest { return } + if command == .terminate, !capabilities.supportsTerminateRequest { return } + if singleThread, !capabilities.supportsSingleThreadExecutionRequests { return } + if [.next, .stepIn, .stepOut, .stepBack, .goto].contains(command), state != .paused { return } + if command == .pause, state != .running { return } + if command == .continueExecution, state != .paused { return } var arguments: [String: Any] = [:] - if let threadID { arguments["threadId"] = threadID } - if command == .continueExecution || command == .next || command == .stepIn || command == .stepOut { - arguments["singleThread"] = false + if command != .restart, command != .terminate, let threadID { + arguments["threadId"] = threadID + } + if let targetID, command == .stepIn || command == .goto { + arguments["targetId"] = targetID + } + if command == .continueExecution || command == .next || command == .stepIn + || command == .stepOut || command == .stepBack || command == .goto + || command == .pause { + arguments["singleThread"] = singleThread } sendRequest(command: command.rawValue, arguments: arguments) { [weak self] result in - if case .success = result, command != .pause { - self?.state = .running + if case .success = result { + if command != .pause, command != .terminate, + !(singleThread && command == .continueExecution) { + self?.state = .running + } } } } @@ -228,12 +374,58 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession { return .failure(DebugAdapterProtocolError.invalidResponse("variables")) } return .success(values.enumerated().compactMap { index, value in - Self.parseVariable(value, fallbackID: "\(reference):\(index)") + Self.parseVariable( + value, + fallbackID: "\(reference):\(index)", + containerReference: reference + ) }) }) } } + public func setVariable( + variablesReference: Int, + name: String, + value: String, + completion: @escaping (Result) -> Void + ) { + if let activeChildSession { + activeChildSession.setVariable( + variablesReference: variablesReference, + name: name, + value: value, + completion: completion + ) + return + } + guard capabilities.supportsSetVariable else { + completion(.failure(DebugAdapterCapabilityError.unsupported("variable mutation"))) + return + } + sendRequest(command: "setVariable", arguments: [ + "variablesReference": variablesReference, + "name": name, + "value": value + ]) { result in + completion(result.flatMap { response in + guard let body = response["body"] as? [String: Any], + let resolvedValue = body["value"] as? String else { + return .failure(DebugAdapterProtocolError.invalidResponse("setVariable")) + } + return .success(DebugVariable( + id: "\(variablesReference):\(name)", + name: name, + value: resolvedValue, + type: body["type"] as? String, + evaluateName: nil, + variablesReference: body["variablesReference"] as? Int ?? 0, + containerReference: variablesReference + )) + }) + } + } + public func evaluate( _ expression: String, frameID: Int?, @@ -263,6 +455,59 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession { } } + public func requestStepInTargets( + frameID: Int, + completion: @escaping (Result<[DebugStepInTarget], Error>) -> Void + ) { + if let activeChildSession { + activeChildSession.requestStepInTargets(frameID: frameID, completion: completion) + return + } + guard capabilities.supportsStepInTargetsRequest else { + completion(.failure(DebugAdapterCapabilityError.unsupported("smart step into"))) + return + } + sendRequest(command: "stepInTargets", arguments: ["frameId": frameID]) { result in + completion(result.flatMap { response in + guard let values = (response["body"] as? [String: Any])?["targets"] as? [[String: Any]] else { + return .failure(DebugAdapterProtocolError.invalidResponse("stepInTargets")) + } + return .success(values.compactMap(Self.parseStepInTarget)) + }) + } + } + + public func requestGotoTargets( + fileURL: URL, + line: Int, + column: Int?, + completion: @escaping (Result<[DebugGotoTarget], Error>) -> Void + ) { + if let activeChildSession { + activeChildSession.requestGotoTargets( + fileURL: fileURL, + line: line, + column: column, + completion: completion + ) + return + } + guard capabilities.supportsGotoTargetsRequest else { + completion(.failure(DebugAdapterCapabilityError.unsupported("run to cursor"))) + return + } + var arguments: [String: Any] = ["source": ["path": fileURL.path], "line": line] + if let column { arguments["column"] = column } + sendRequest(command: "gotoTargets", arguments: arguments) { result in + completion(result.flatMap { response in + guard let values = (response["body"] as? [String: Any])?["targets"] as? [[String: Any]] else { + return .failure(DebugAdapterProtocolError.invalidResponse("gotoTargets")) + } + return .success(values.compactMap(Self.parseGotoTarget)) + }) + } + } + public func stop() { let children = childSessions childSessions = [] @@ -281,11 +526,17 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession { } private func sendBreakpoints(for fileURL: URL) { - let breakpoints = breakpointsBySource[fileURL] ?? [] + let breakpoints = (breakpointsBySource[fileURL] ?? []).filter(\.enabled) let values: [[String: Any]] = breakpoints.map { breakpoint in var value: [String: Any] = ["line": breakpoint.line] if let column = breakpoint.column { value["column"] = column } if let condition = breakpoint.condition, !condition.isEmpty { value["condition"] = condition } + if let hitCondition = breakpoint.hitCondition, !hitCondition.isEmpty { + value["hitCondition"] = hitCondition + } + if let logMessage = breakpoint.logMessage, !logMessage.isEmpty { + value["logMessage"] = logMessage + } return value } sendRequest(command: "setBreakpoints", arguments: [ @@ -298,7 +549,92 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession { else { return } for (index, value) in returned.enumerated() { let fallback = breakpoints.indices.contains(index) ? breakpoints[index].line : nil - if let parsed = Self.parseBreakpoint(value, fallbackLine: fallback, sourceURL: fileURL, index: index) { + if let parsed = Self.parseBreakpoint( + value, + fallbackLine: fallback, + sourceURL: fileURL, + functionName: nil, + index: index + ) { + self.onEvent?(.breakpoint(parsed)) + } + } + } + } + + private func sendExceptionBreakpoints() { + let active = exceptionBreakpoints.filter(\.enabled) + var arguments: [String: Any] = ["filters": active.map(\.filter)] + if capabilities.supportsExceptionFilterOptions { + let options = active.compactMap { breakpoint -> [String: Any]? in + guard let condition = breakpoint.condition, !condition.isEmpty else { return nil } + return ["filterId": breakpoint.filter, "condition": condition] + } + if !options.isEmpty { arguments["filterOptions"] = options } + } + sendRequest(command: "setExceptionBreakpoints", arguments: arguments) { _ in } + } + + private func sendFunctionBreakpoints() { + let active = functionBreakpoints.filter(\.enabled) + let values: [[String: Any]] = active.map { breakpoint in + var value: [String: Any] = ["name": breakpoint.name] + if let condition = breakpoint.condition, !condition.isEmpty { + value["condition"] = condition + } + if let hitCondition = breakpoint.hitCondition, !hitCondition.isEmpty { + value["hitCondition"] = hitCondition + } + return value + } + sendRequest(command: "setFunctionBreakpoints", arguments: ["breakpoints": values]) { [weak self] result in + guard let self, case .success(let response) = result, + let returned = (response["body"] as? [String: Any])?["breakpoints"] as? [[String: Any]] + else { return } + for (index, value) in returned.enumerated() { + let functionName = active.indices.contains(index) ? active[index].name : nil + if let parsed = Self.parseBreakpoint( + value, + fallbackLine: nil, + sourceURL: nil, + functionName: functionName, + index: index + ) { + self.onEvent?(.breakpoint(parsed)) + } + } + } + } + + private func sendDataBreakpoints() { + let active = dataBreakpoints.filter(\.enabled) + let values: [[String: Any]] = active.map { breakpoint in + var value: [String: Any] = ["dataId": breakpoint.dataID] + if let accessType = breakpoint.accessType, !accessType.isEmpty { + value["accessType"] = accessType + } + if let condition = breakpoint.condition, !condition.isEmpty { + value["condition"] = condition + } + if let hitCondition = breakpoint.hitCondition, !hitCondition.isEmpty { + value["hitCondition"] = hitCondition + } + return value + } + sendRequest(command: "setDataBreakpoints", arguments: ["breakpoints": values]) { [weak self] result in + guard let self, case .success(let response) = result, + let returned = (response["body"] as? [String: Any])?["breakpoints"] as? [[String: Any]] + else { return } + for (index, value) in returned.enumerated() { + let dataID = active.indices.contains(index) ? active[index].dataID : nil + if let parsed = Self.parseBreakpoint( + value, + fallbackLine: nil, + sourceURL: nil, + functionName: nil, + dataID: dataID, + index: index + ) { self.onEvent?(.breakpoint(parsed)) } } @@ -416,6 +752,13 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession { case "initialized": didReceiveInitializedEvent = true onEvent?(.initialized) + sendExceptionBreakpoints() + if capabilities.supportsFunctionBreakpoints { + sendFunctionBreakpoints() + } + if capabilities.supportsDataBreakpoints { + sendDataBreakpoints() + } for source in breakpointsBySource.keys.sorted(by: { $0.path < $1.path }) { sendBreakpoints(for: source) } @@ -440,7 +783,14 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession { onEvent?(.terminated(exitCode: body["exitCode"] as? Int)) case "breakpoint": if let value = body["breakpoint"] as? [String: Any], - let breakpoint = Self.parseBreakpoint(value, fallbackLine: nil, sourceURL: nil, index: 0) { + let breakpoint = Self.parseBreakpoint( + value, + fallbackLine: nil, + sourceURL: nil, + functionName: nil, + dataID: nil, + index: 0 + ) { onEvent?(.breakpoint(breakpoint)) } default: break @@ -482,6 +832,9 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession { arguments: childArguments ) let child = DebugAdapterProtocolSession(adapterID: adapterID, transport: childTransport) + child.setExceptionBreakpoints(exceptionBreakpoints) + child.setFunctionBreakpoints(functionBreakpoints) + child.setDataBreakpoints(dataBreakpoints) for (source, breakpoints) in breakpointsBySource { child.setBreakpoints(breakpoints, in: source) } @@ -555,6 +908,7 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession { responseHandlers = [:] didReceiveInitializedEvent = false supportsConfigurationDone = false + capabilities = .unknown pendingLaunch = nil activeChildSession = nil childSessions = [] @@ -591,7 +945,11 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession { ) } - private static func parseVariable(_ value: [String: Any], fallbackID: String) -> DebugVariable? { + private static func parseVariable( + _ value: [String: Any], + fallbackID: String, + containerReference: Int? = nil + ) -> DebugVariable? { guard let name = value["name"] as? String, let rendered = value["value"] as? String else { return nil } return DebugVariable( @@ -600,7 +958,35 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession { value: rendered, type: value["type"] as? String, evaluateName: value["evaluateName"] as? String, - variablesReference: value["variablesReference"] as? Int ?? 0 + variablesReference: value["variablesReference"] as? Int ?? 0, + containerReference: containerReference + ) + } + + private static func parseStepInTarget(_ value: [String: Any]) -> DebugStepInTarget? { + guard let id = value["id"] as? Int, let label = value["label"] as? String else { return nil } + return DebugStepInTarget( + id: id, + label: label, + line: value["line"] as? Int, + column: value["column"] as? Int, + endLine: value["endLine"] as? Int, + endColumn: value["endColumn"] as? Int + ) + } + + private static func parseGotoTarget(_ value: [String: Any]) -> DebugGotoTarget? { + guard let id = value["id"] as? Int, + let label = value["label"] as? String, + let line = value["line"] as? Int else { return nil } + return DebugGotoTarget( + id: id, + label: label, + line: line, + column: value["column"] as? Int, + endLine: value["endLine"] as? Int, + endColumn: value["endColumn"] as? Int, + instructionPointerReference: value["instructionPointerReference"] as? String ) } @@ -608,6 +994,8 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession { _ value: [String: Any], fallbackLine: Int?, sourceURL: URL?, + functionName: String?, + dataID: String? = nil, index: Int ) -> DebugBreakpoint? { let line = value["line"] as? Int ?? fallbackLine @@ -618,7 +1006,9 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession { message: value["message"] as? String, sourceURL: source, line: line, - column: value["column"] as? Int + column: value["column"] as? Int, + functionName: functionName, + dataID: dataID ) } diff --git a/macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift b/macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift index 09eb2421..0ecc63a1 100644 --- a/macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift +++ b/macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift @@ -23,6 +23,9 @@ public final class DebugAdapterSessionManager: ObservableObject { private var sessions: [String: any DebugAdapterSession] = [:] private var roots: [String: URL] = [:] private var requestedBreakpoints: [String: [URL: [DebugSourceBreakpoint]]] = [:] + private var requestedExceptionBreakpoints: [String: [DebugExceptionBreakpoint]] = [:] + private var requestedFunctionBreakpoints: [String: [DebugFunctionBreakpoint]] = [:] + private var requestedDataBreakpoints: [String: [DebugDataBreakpoint]] = [:] public init( providers: [DebugProviderDescriptor], @@ -71,6 +74,15 @@ public final class DebugAdapterSessionManager: ObservableObject { for (source, breakpoints) in requestedBreakpoints[descriptor.id] ?? [:] { controlling.setBreakpoints(breakpoints, in: source) } + if let breakpoints = requestedExceptionBreakpoints[descriptor.id] { + controlling.setExceptionBreakpoints(breakpoints) + } + if let breakpoints = requestedFunctionBreakpoints[descriptor.id] { + controlling.setFunctionBreakpoints(breakpoints) + } + if let breakpoints = requestedDataBreakpoints[descriptor.id] { + controlling.setDataBreakpoints(breakpoints) + } } return session } @@ -104,6 +116,45 @@ public final class DebugAdapterSessionManager: ObservableObject { session(providerID: descriptor.id)?.setBreakpoints(breakpoints, in: fileURL) } + public func setExceptionBreakpoints( + _ breakpoints: [DebugExceptionBreakpoint], + for fileURL: URL + ) throws { + guard let descriptor = provider(for: fileURL) else { + throw DebugProviderError.noProvider( + fileExtension: fileURL.pathExtension.lowercased() + ) + } + requestedExceptionBreakpoints[descriptor.id] = breakpoints + session(providerID: descriptor.id)?.setExceptionBreakpoints(breakpoints) + } + + public func setFunctionBreakpoints( + _ breakpoints: [DebugFunctionBreakpoint], + for fileURL: URL + ) throws { + guard let descriptor = provider(for: fileURL) else { + throw DebugProviderError.noProvider( + fileExtension: fileURL.pathExtension.lowercased() + ) + } + requestedFunctionBreakpoints[descriptor.id] = breakpoints + session(providerID: descriptor.id)?.setFunctionBreakpoints(breakpoints) + } + + public func setDataBreakpoints( + _ breakpoints: [DebugDataBreakpoint], + for fileURL: URL + ) throws { + guard let descriptor = provider(for: fileURL) else { + throw DebugProviderError.noProvider( + fileExtension: fileURL.pathExtension.lowercased() + ) + } + requestedDataBreakpoints[descriptor.id] = breakpoints + session(providerID: descriptor.id)?.setDataBreakpoints(breakpoints) + } + public func session(providerID: String) -> (any DebugAdapterControllingSession)? { sessions[providerID] as? any DebugAdapterControllingSession } @@ -122,6 +173,9 @@ public final class DebugAdapterSessionManager: ObservableObject { lastEvents.removeAll() verifiedBreakpoints.removeAll() requestedBreakpoints.removeAll() + requestedExceptionBreakpoints.removeAll() + requestedFunctionBreakpoints.removeAll() + requestedDataBreakpoints.removeAll() } private func configureCallbacks( diff --git a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift index 95faa8e0..ec155fdd 100644 --- a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift +++ b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift @@ -373,12 +373,28 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { _ command: LanguageServerCommand, fileURL: URL, completion: @escaping (Result) -> Void + ) throws { + try executeReturningValue(command, fileURL: fileURL) { result in + completion(result.map { _ in () }) + } + } + + package func executeReturningValue( + _ command: LanguageServerCommand, + fileURL: URL, + completion: @escaping (Result) -> Void ) throws { // A workspace command belongs to the server rather than to a document, so // it carries no document URI and is not gated on one being open. _ = fileURL try request(.executeCommand, fileURL: nil, command: command) { result in - completion(result.map { _ in () }) + completion(result.flatMap { event in + guard case .object(let object)? = event.result, + let value = object["value"] else { + return .failure(LanguageServerRuntimeSessionError.missingResult) + } + return .success(value) + }) } } diff --git a/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift b/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift index 27d81305..dfe44077 100644 --- a/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift +++ b/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift @@ -8,6 +8,7 @@ package enum LanguageToolingSessionError: LocalizedError, Equatable, Sendable { case providerNotInstalled(String) case toolingUnavailable(String) case capabilityUnavailable(provider: String, capability: String) + case invalidJavaDebugServerPort package var errorDescription: String? { switch self { @@ -19,6 +20,8 @@ package enum LanguageToolingSessionError: LocalizedError, Equatable, Sendable { return message case .capabilityUnavailable(let provider, let capability): return "The \(provider) provider does not support \(capability)." + case .invalidJavaDebugServerPort: + return "The Java Debug Server returned an invalid TCP port." } } } @@ -51,6 +54,7 @@ package final class LanguageToolingSessionManager: ObservableObject { private var diagnosticsByProviderID: [String: [URL: [LanguageServerDiagnostic]]] = [:] private var languageFeatureProviders: [any LanguageFeatureProvider] private var languageServerFeatureProviders: [String: LanguageServerFeatureProvider] = [:] + private var languageServerReadyWaiters: [UUID: LanguageServerReadyWaiter] = [:] private let workspaceFingerprintProvider: (LanguageProviderDescriptor, URL) throws -> String? private let workspaceStateResetter: ((LanguageProviderDescriptor, URL, String?) throws -> Void)? private let workspaceStateCleaner: ((LanguageProviderDescriptor, URL, String?) throws -> Int)? @@ -265,6 +269,154 @@ package final class LanguageToolingSessionManager: ObservableObject { return languageServerOperationIDs[providerID] ?? operationID } + /// Starts or reuses JDT LS, then asks its bundled Java Debug extension for + /// the loopback DAP port. The caller remains responsible for the socket. + package func startJavaDebugServer(rootURL: URL) async throws -> UInt16 { + let normalizedRoot = rootURL.standardizedFileURL + _ = try startLanguageServer(providerID: "java", rootURL: normalizedRoot) + try await waitUntilLanguageServerReady(providerID: "java", rootURL: normalizedRoot) + let value = try await executeJavaCommand( + "vscode.java.startDebugSession", + arguments: [], + rootURL: normalizedRoot + ) + let portValue: Int? + switch value { + case .integer(let value): portValue = value + case .string(let value): portValue = Int(value) + default: portValue = nil + } + guard let portValue, (1...Int(UInt16.max)).contains(portValue) else { + throw LanguageToolingSessionError.invalidJavaDebugServerPort + } + return UInt16(portValue) + } + + /// Resolves the current Java source through JDT LS project metadata instead + /// of deriving package or module names from its filesystem path. + package func resolveJavaDebugLaunchTarget( + fileURL: URL, + rootURL: URL + ) async throws -> JavaDebugLaunchTarget { + let normalizedRoot = rootURL.standardizedFileURL + let resolvedFile = fileURL.standardizedFileURL.resolvingSymlinksInPath() + _ = try startLanguageServer(providerID: "java", rootURL: normalizedRoot) + try await waitUntilLanguageServerReady(providerID: "java", rootURL: normalizedRoot) + let value = try await executeJavaCommand( + "vscode.java.resolveMainClass", + arguments: [], + rootURL: normalizedRoot + ) + guard case .array(let values) = value else { + throw LanguageToolingSessionError.toolingUnavailable( + "The Java language service returned an invalid main-class list." + ) + } + let targets = values.compactMap(Self.javaDebugLaunchTarget) + let exactMatches = targets.filter { target in + guard let filePath = target.filePath else { return false } + return URL(fileURLWithPath: filePath) + .standardizedFileURL + .resolvingSymlinksInPath() == resolvedFile + } + let selected: JavaDebugLaunchTarget + if exactMatches.count == 1 { + selected = exactMatches[0].target + } else if targets.count == 1 { + selected = targets[0].target + } else { + let message = exactMatches.isEmpty + ? "No Java main method was found in \(resolvedFile.lastPathComponent)." + : "More than one Java main method was found in \(resolvedFile.lastPathComponent)." + throw LanguageToolingSessionError.toolingUnavailable(message) + } + let classpathValue = try await executeJavaCommand( + "vscode.java.resolveClasspath", + arguments: [ + .string(selected.mainClass), + .string(selected.projectName ?? ""), + .string("runtime"), + ], + rootURL: normalizedRoot + ) + guard case .array(let pathGroups) = classpathValue, + pathGroups.count == 2 else { + throw LanguageToolingSessionError.toolingUnavailable( + "The Java language service returned an invalid runtime classpath." + ) + } + let modulePaths = Self.stringValues(pathGroups[0]) + let classPaths = Self.stringValues(pathGroups[1]) + guard !modulePaths.isEmpty || !classPaths.isEmpty else { + throw LanguageToolingSessionError.toolingUnavailable( + "The Java language service could not resolve the runtime classpath." + ) + } + return JavaDebugLaunchTarget( + mainClass: selected.mainClass, + projectName: selected.projectName, + modulePaths: modulePaths, + classPaths: classPaths + ) + } + + private func executeJavaCommand( + _ commandID: String, + arguments: [ToolingJSONValue], + rootURL: URL + ) async throws -> ToolingJSONValue { + let command = LanguageServerCommand( + title: commandID, + command: commandID, + arguments: arguments + ) + return try await withCheckedThrowingContinuation { continuation in + do { + try executeReturningValue( + command, + fileURL: rootURL.appendingPathComponent("Main.java"), + rootURL: rootURL + ) { result in + continuation.resume(with: result) + } + } catch { + continuation.resume(throwing: error) + } + } + } + + private static func javaDebugLaunchTarget( + _ value: ToolingJSONValue + ) -> ResolvedJavaDebugLaunchTarget? { + guard case .object(let object) = value, + case .string(let mainClass)? = object["mainClass"], + mainClass.isEmpty == false else { return nil } + let projectName: String? + if case .string(let value)? = object["projectName"], value.isEmpty == false { + projectName = value + } else { + projectName = nil + } + let filePath: String? + if case .string(let value)? = object["filePath"], value.isEmpty == false { + filePath = value + } else { + filePath = nil + } + return ResolvedJavaDebugLaunchTarget( + target: JavaDebugLaunchTarget(mainClass: mainClass, projectName: projectName), + filePath: filePath + ) + } + + private static func stringValues(_ value: ToolingJSONValue) -> [String] { + guard case .array(let values) = value else { return [] } + return values.compactMap { value in + guard case .string(let value) = value, !value.isEmpty else { return nil } + return value + } + } + package func notifyWorkspaceFilesChanged( providerID: String, changes: [LanguageServerWorkspaceFileChange] @@ -359,6 +511,11 @@ package final class LanguageToolingSessionManager: ObservableObject { languageServerInfos[providerID] = nil languageServerFeatureProviders[providerID] = nil languageServerStates[providerID] = .stopped + resumeLanguageServerReadyWaiters( + providerID: providerID, + state: .stopped, + rootURL: nil + ) onLanguageServerStateChange?( providerID, .stopped, @@ -578,6 +735,25 @@ package final class LanguageToolingSessionManager: ObservableObject { throw unavailableLanguageServerError(for: fileURL) } + package func executeReturningValue( + _ command: LanguageServerCommand, + fileURL: URL, + rootURL _: URL, + completion: @escaping (Result) -> Void + ) throws { + guard command.command.isEmpty == false else { + throw LanguageToolingSessionError.capabilityUnavailable( + provider: catalog.provider(for: fileURL)?.displayName ?? fileURL.pathExtension, + capability: "execute command" + ) + } + if let session = readyLanguageServerSession(for: fileURL) { + try session.executeReturningValue(command, fileURL: fileURL, completion: completion) + return + } + throw unavailableLanguageServerError(for: fileURL) + } + package func resolveVirtualDocument( providerID: String, uri: URL, @@ -1131,6 +1307,11 @@ package final class LanguageToolingSessionManager: ObservableObject { ) { guard languageServerSessionIdentities[providerID] == sessionIdentity else { return } languageServerStates[providerID] = state + resumeLanguageServerReadyWaiters( + providerID: providerID, + state: state, + rootURL: languageServerRoots[providerID] + ) switch state { case .stopped, .failed: clearLanguageServerSession( @@ -1168,6 +1349,80 @@ package final class LanguageToolingSessionManager: ObservableObject { ) } + private func waitUntilLanguageServerReady( + providerID: String, + rootURL: URL + ) async throws { + if languageServerStates[providerID] == .ready, + languageServerRoots[providerID] == rootURL { + return + } + let waiterID = UUID() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + if Task.isCancelled { + continuation.resume(throwing: CancellationError()) + return + } + languageServerReadyWaiters[waiterID] = LanguageServerReadyWaiter( + providerID: providerID, + rootURL: rootURL, + continuation: continuation + ) + } + } onCancel: { + Task { @MainActor [weak self] in + self?.cancelLanguageServerReadyWaiter(waiterID) + } + } + } + + private func cancelLanguageServerReadyWaiter(_ waiterID: UUID) { + languageServerReadyWaiters.removeValue(forKey: waiterID)? + .continuation.resume(throwing: CancellationError()) + } + + private func resumeLanguageServerReadyWaiters( + providerID: String, + state: LanguageServerSessionState, + rootURL: URL? + ) { + let matching = languageServerReadyWaiters.filter { _, waiter in + waiter.providerID == providerID + } + for (waiterID, waiter) in matching { + switch state { + case .ready where rootURL == waiter.rootURL: + languageServerReadyWaiters.removeValue(forKey: waiterID)? + .continuation.resume() + case .failed(let failure): + languageServerReadyWaiters.removeValue(forKey: waiterID)? + .continuation.resume(throwing: LanguageToolingSessionError.toolingUnavailable( + failure.message ?? "The \(providerID) language server failed." + )) + case .stopped: + languageServerReadyWaiters.removeValue(forKey: waiterID)? + .continuation.resume(throwing: LanguageToolingSessionError.toolingUnavailable( + "The \(providerID) language server stopped before becoming ready." + )) + default: + break + } + } + } + + private struct LanguageServerReadyWaiter { + let providerID: String + let rootURL: URL + let continuation: CheckedContinuation + } + + private struct ResolvedJavaDebugLaunchTarget { + let target: JavaDebugLaunchTarget + let filePath: String? + } + private func replaceDiagnostics( _ updatedDiagnostics: [LanguageServerDiagnostic], for fileURL: URL, diff --git a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift index 18ab259c..be2b6cf9 100644 --- a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift +++ b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift @@ -7,6 +7,442 @@ import Testing @MainActor struct DebugModuleTests { + @Test + func coreProtocolSessionProjectsRustUpdatesThroughInjectedTransport() throws { + let transport = RecordingTransport() + let core = RecordingDebugProtocolCore() + let deadlines = RecordingDebugDeadlineScheduler() + let session = CoreDebugAdapterProtocolSession( + adapterID: "java", + transport: transport, + core: core, + sessionID: "java-session", + deadlineScheduler: deadlines + ) + + try session.start(rootURL: URL(fileURLWithPath: "/tmp/java-core", isDirectory: true)) + + #expect(session.state == .initializing) + #expect(transport.sentData == [Data("initialize-frame".utf8)]) + core.enqueueReceive(state: "ready", events: [[ + "sequence": 2, + "type": "stateChanged", + "state": "ready" + ], [ + "sequence": 3, + "type": "capabilities", + "capabilities": [ + "supportsConfigurationDone": true, + "supportsConditionalBreakpoints": true, + "supportsHitConditionalBreakpoints": true, + "supportsLogPoints": true, + "supportsFunctionBreakpoints": true, + "supportsDataBreakpoints": true, + "supportsExceptionOptions": true, + "supportsExceptionFilterOptions": true, + "supportsSetVariable": true, + "supportsCancelRequest": true, + "supportsSingleThreadExecutionRequests": true, + "supportsRestartRequest": true, + "supportsTerminateRequest": true, + "supportsStepBack": true, + "supportsStepInTargetsRequest": true, + "supportsGotoTargetsRequest": true, + "exceptionBreakpointFilters": [[ + "filter": "caught", + "label": "Caught Exceptions", + "default": false, + "supportsCondition": true + ]] + ] + ]]) + transport.emitData(Data("initialize-response".utf8)) + #expect(session.state == .ready) + #expect(session.capabilities.negotiated) + #expect(session.capabilities.supportsConditionalBreakpoints) + #expect(session.capabilities.supportsFunctionBreakpoints) + #expect(session.capabilities.supportsDataBreakpoints) + #expect(session.capabilities.exceptionBreakpointFilters.first?.filter == "caught") + + var dataInfoResult: Result? + session.requestDataBreakpointInfo( + name: "count", + variablesReference: 42, + frameID: 7 + ) { dataInfoResult = $0 } + let dataOperationID = try #require(core.lastDataBreakpointInfoOperationID) + core.enqueueReceive(state: "paused", events: [[ + "sequence": 4, + "type": "operationCompleted", + "operationId": dataOperationID, + "result": [ + "kind": "dataBreakpointInfo", + "dataId": "field:count", + "description": "Main.count", + "accessTypes": ["read", "write"], + "canPersist": true + ] + ]]) + transport.emitData(Data("data-info-response".utf8)) + #expect(try dataInfoResult?.get() == DebugDataBreakpointInfo( + dataID: "field:count", + description: "Main.count", + accessTypes: ["read", "write"], + canPersist: true + )) + + var setVariableResult: Result? + session.setVariable( + variablesReference: 42, + name: "count", + value: "7" + ) { setVariableResult = $0 } + let setVariableOperationID = try #require(core.lastSetVariableOperationID) + core.enqueueReceive(state: "paused", events: [[ + "sequence": 5, + "type": "operationCompleted", + "operationId": setVariableOperationID, + "result": [ + "kind": "setVariable", + "variable": [ + "name": "count", + "value": "7", + "type": "int", + "variablesReference": 0 + ] + ] + ]]) + transport.emitData(Data("set-variable-response".utf8)) + #expect(try setVariableResult?.get().value == "7") + #expect(try setVariableResult?.get().containerReference == 42) + + var threadsResult: Result<[DebugThread], Error>? + session.requestThreads { threadsResult = $0 } + let operationID = try #require(core.lastInspectionOperationID) + core.enqueueReceive(state: "paused", events: [[ + "sequence": 5, + "type": "operationCompleted", + "operationId": operationID, + "result": [ + "kind": "threads", + "threads": [["id": 7, "name": "main"]] + ] + ]]) + transport.emitData(Data("threads-response".utf8)) + + #expect(try threadsResult?.get() == [DebugThread(id: 7, name: "main")]) + + var stepTargetsResult: Result<[DebugStepInTarget], Error>? + session.requestStepInTargets(frameID: 7) { stepTargetsResult = $0 } + let stepTargetsOperationID = try #require(core.lastInspectionOperationID) + core.enqueueReceive(state: "paused", events: [[ + "sequence": 6, + "type": "operationCompleted", + "operationId": stepTargetsOperationID, + "result": [ + "kind": "stepInTargets", + "targets": [["id": 21, "label": "service.load()", "line": 12]] + ] + ]]) + transport.emitData(Data("step-targets-response".utf8)) + #expect(try stepTargetsResult?.get().first?.label == "service.load()") + + var gotoTargetsResult: Result<[DebugGotoTarget], Error>? + session.requestGotoTargets( + fileURL: URL(fileURLWithPath: "/tmp/Main.java"), + line: 20, + column: 5 + ) { gotoTargetsResult = $0 } + let gotoTargetsOperationID = try #require(core.lastInspectionOperationID) + core.enqueueReceive(state: "paused", events: [[ + "sequence": 7, + "type": "operationCompleted", + "operationId": gotoTargetsOperationID, + "result": [ + "kind": "gotoTargets", + "targets": [["id": 31, "label": "Main.java:20", "line": 20]] + ] + ]]) + transport.emitData(Data("goto-targets-response".utf8)) + #expect(try gotoTargetsResult?.get().first?.line == 20) + session.execute( + .continueExecution, + threadID: 7, + targetID: nil, + singleThread: true + ) + #expect(core.lastExecutionSingleThread == true) + #expect(core.lastExecutionThreadID == 7) + + var timedOutResult: Result<[DebugThread], Error>? + session.requestThreads { timedOutResult = $0 } + deadlines.fireLast() + #expect(core.cancelledOperationReasons.last == "timedOut") + #expect(throws: (any Error).self) { try timedOutResult?.get() } + session.stop() + #expect(session.state == .idle) + #expect(core.destroyedSessionIDs == ["java-session"]) + #expect(transport.stopCalls == 1) + } + + @Test + func genericBreakpointsPreserveAdvancedOptionsAcrossMuteAndClear() throws { + let transport = RecordingTransport() + let core = RecordingDebugProtocolCore() + let descriptor = DebugProviderDescriptor( + id: "java", + displayName: "Java", + fileExtensions: ["java"] + ) + let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in + CoreDebugAdapterProtocolSession( + adapterID: "java", + transport: transport, + core: core, + sessionID: "java-breakpoints", + deadlineScheduler: RecordingDebugDeadlineScheduler() + ) + } + let feature = GenericDebugFeatureModel(sessions: manager) + let root = URL(fileURLWithPath: "/tmp/java-breakpoints", isDirectory: true) + let source = root.appendingPathComponent("Main.java") + + feature.toggleBreakpoint(fileURL: source, line: 12) + feature.updateBreakpoint( + fileURL: source, + line: 12, + enabled: true, + condition: "value > 1", + hitCondition: "3", + logMessage: "value = {value}" + ) + feature.toggleBreakpointMute() + + #expect(feature.breakpoints.count == 1) + #expect(feature.breakpoints[0].condition == "value > 1") + #expect(feature.breakpoints[0].hitCondition == "3") + #expect(feature.breakpoints[0].logMessage == "value = {value}") + #expect(feature.areBreakpointsMuted) + #expect(feature.start( + fileURL: source, + rootURL: root, + configuration: DebugLaunchConfiguration( + name: "Main", + request: .launch, + arguments: ["mainClass": .string("example.Main")] + ) + )) + #expect(core.breakpointUpdates.last == [DebugSourceBreakpoint( + line: 12, + enabled: false, + condition: "value > 1", + hitCondition: "3", + logMessage: "value = {value}" + )]) + + core.enqueueReceive(sessionID: "java-breakpoints", state: "ready", events: [[ + "sequence": 2, + "type": "capabilities", + "capabilities": [ + "supportsConfigurationDone": true, + "supportsConditionalBreakpoints": true, + "supportsHitConditionalBreakpoints": true, + "supportsLogPoints": true, + "supportsFunctionBreakpoints": true, + "supportsDataBreakpoints": true, + "supportsExceptionOptions": false, + "supportsExceptionFilterOptions": true, + "supportsSetVariable": false, + "supportsCancelRequest": false, + "supportsSingleThreadExecutionRequests": false, + "supportsRestartRequest": false, + "supportsTerminateRequest": false, + "supportsStepBack": false, + "supportsStepInTargetsRequest": false, + "supportsGotoTargetsRequest": false, + "exceptionBreakpointFilters": [[ + "filter": "caught", + "label": "Caught Exceptions", + "description": "Pause when an exception is caught.", + "default": false, + "supportsCondition": true, + "conditionDescription": "Exception class pattern" + ], [ + "filter": "uncaught", + "label": "Uncaught Exceptions", + "default": true, + "supportsCondition": false + ]] + ] + ]]) + transport.emitData(Data("capabilities-response".utf8)) + #expect(feature.exceptionBreakpoints.map(\.filter) == ["caught", "uncaught"]) + #expect(feature.exceptionBreakpoints.last?.enabled == true) + feature.updateExceptionBreakpoint( + try #require(feature.exceptionBreakpoints.first), + enabled: true, + condition: "example.CustomException" + ) + #expect(core.exceptionBreakpointUpdates.last == [ + DebugExceptionBreakpoint( + filter: "caught", + enabled: true, + condition: "example.CustomException" + ), + DebugExceptionBreakpoint(filter: "uncaught", enabled: true) + ]) + feature.addFunctionBreakpoint( + name: " example.Main.run ", + condition: "ready", + hitCondition: "2" + ) + #expect(feature.functionBreakpoints.first?.name == "example.Main.run") + #expect(core.functionBreakpointUpdates.last == [ + DebugFunctionBreakpoint( + name: "example.Main.run", + enabled: true, + condition: "ready", + hitCondition: "2" + ) + ]) + core.enqueueReceive(sessionID: "java-breakpoints", state: "ready", events: [[ + "sequence": 3, + "type": "breakpoint", + "breakpoint": [ + "id": 8, + "verified": true, + "functionName": "example.Main.run" + ] + ]]) + transport.emitData(Data("function-breakpoint-response".utf8)) + #expect(feature.functionBreakpoints.first?.verified == true) + + core.enqueueReceive(sessionID: "java-breakpoints", state: "paused", events: [[ + "sequence": 4, + "type": "stopped", + "reason": "breakpoint" + ]]) + transport.emitData(Data("stopped-event".utf8)) + feature.addWatch(" count ") + let staleWatchOperationID = try #require(core.lastInspectionOperationID) + feature.updateWatch(try #require(feature.watches.first), expression: "count + 1") + let watchOperationID = try #require(core.lastInspectionOperationID) + core.enqueueReceive(sessionID: "java-breakpoints", state: "paused", events: [[ + "sequence": 5, + "type": "operationCompleted", + "operationId": staleWatchOperationID, + "result": [ + "kind": "evaluate", + "variable": [ + "name": "count", + "value": "7", + "type": "int", + "variablesReference": 0 + ] + ] + ]]) + transport.emitData(Data("stale-watch-response".utf8)) + #expect(feature.watches.first?.expression == "count + 1") + #expect(feature.watches.first?.value == nil) + core.enqueueReceive(sessionID: "java-breakpoints", state: "paused", events: [[ + "sequence": 6, + "type": "operationCompleted", + "operationId": watchOperationID, + "result": [ + "kind": "evaluate", + "variable": [ + "name": "count + 1", + "value": "8", + "type": "int", + "variablesReference": 0 + ] + ] + ]]) + transport.emitData(Data("watch-response".utf8)) + #expect(feature.watches.first?.value == "8") + + feature.requestDataBreakpoint(for: DebugVariable( + id: "count", + name: "count", + value: "1", + type: "int", + evaluateName: "this.count", + variablesReference: 0, + containerReference: 42 + )) + let dataOperationID = try #require(core.lastDataBreakpointInfoOperationID) + core.enqueueReceive(sessionID: "java-breakpoints", state: "paused", events: [[ + "sequence": 5, + "type": "operationCompleted", + "operationId": dataOperationID, + "result": [ + "kind": "dataBreakpointInfo", + "dataId": "field:count", + "description": "Main.count", + "accessTypes": ["read", "write"], + "canPersist": true + ] + ]]) + transport.emitData(Data("data-info-response".utf8)) + #expect(feature.dataBreakpoints.first?.accessType == "write") + #expect(core.dataBreakpointUpdates.last == [DebugDataBreakpoint( + dataID: "field:count", + label: "Main.count", + accessType: "write" + )]) + + feature.loadVariables(reference: 100) + let rootVariablesOperationID = try #require(core.lastInspectionOperationID) + core.enqueueReceive(sessionID: "java-breakpoints", state: "paused", events: [[ + "sequence": 7, + "type": "operationCompleted", + "operationId": rootVariablesOperationID, + "result": [ + "kind": "variables", + "variables": [[ + "name": "user", + "value": "User@1", + "type": "User", + "variablesReference": 101 + ]] + ] + ]]) + transport.emitData(Data("root-variables-response".utf8)) + let user = try #require(feature.variables.first) + feature.toggleVariableExpansion(user) + let childVariablesOperationID = try #require(core.lastInspectionOperationID) + core.enqueueReceive(sessionID: "java-breakpoints", state: "paused", events: [[ + "sequence": 8, + "type": "operationCompleted", + "operationId": childVariablesOperationID, + "result": [ + "kind": "variables", + "variables": [[ + "name": "name", + "value": "Ada", + "type": "String", + "variablesReference": 0 + ]] + ] + ]]) + transport.emitData(Data("child-variables-response".utf8)) + #expect(feature.visibleVariableRows.map(\.variable.name) == ["user", "name"]) + #expect(feature.visibleVariableRows.map(\.depth) == [0, 1]) + feature.toggleVariableExpansion(user) + #expect(feature.visibleVariableRows.map(\.variable.name) == ["user"]) + #expect(feature.variables.first?.name == "user") + + feature.toggleBreakpointMute() + #expect(core.breakpointUpdates.last?.first?.enabled == true) + feature.removeAllBreakpoints() + #expect(feature.breakpoints.isEmpty) + #expect(core.breakpointUpdates.last?.isEmpty == true) + + feature.stop() + #expect(feature.watches.first?.expression == "count + 1") + #expect(feature.watches.first?.value == nil) + } + @Test func protocolSessionInitializesAndStopsThroughInjectedTransport() throws { let transport = RecordingTransport() @@ -26,9 +462,154 @@ struct DebugModuleTests { "request_seq": initialize["seq"] as! Int, "success": true, "command": "initialize", - "body": ["supportsConfigurationDoneRequest": true] + "body": [ + "supportsConfigurationDoneRequest": true, + "supportsFunctionBreakpoints": true, + "supportsDataBreakpoints": true, + "supportsSetVariable": true, + "supportsStepBack": true, + "supportsStepInTargetsRequest": true, + "supportsGotoTargetsRequest": true, + "supportsRestartRequest": true, + "supportsTerminateRequest": true + ] ]) #expect(session.state == .ready) + session.setExceptionBreakpoints([ + DebugExceptionBreakpoint(filter: "uncaught", enabled: true), + DebugExceptionBreakpoint(filter: "caught", enabled: false) + ]) + session.setFunctionBreakpoints([ + DebugFunctionBreakpoint( + name: "example.Main.run", + enabled: true, + condition: "ready", + hitCondition: "2" + ), + DebugFunctionBreakpoint(name: "example.Main.skip", enabled: false) + ]) + session.setDataBreakpoints([ + DebugDataBreakpoint( + dataID: "field:count", + label: "Main.count", + accessType: "write", + condition: "count > 1" + ) + ]) + transport.emitJSON([ + "seq": 3, + "type": "event", + "event": "initialized" + ]) + let exceptionRequest = try #require(transport.request(named: "setExceptionBreakpoints")) + let exceptionArguments = try #require(exceptionRequest["arguments"] as? [String: Any]) + #expect(exceptionArguments["filters"] as? [String] == ["uncaught"]) + let functionRequest = try #require(transport.request(named: "setFunctionBreakpoints")) + let functionArguments = try #require(functionRequest["arguments"] as? [String: Any]) + let functionValues = try #require(functionArguments["breakpoints"] as? [[String: Any]]) + #expect(functionValues.count == 1) + #expect(functionValues.first?["name"] as? String == "example.Main.run") + #expect(functionValues.first?["condition"] as? String == "ready") + #expect(functionValues.first?["hitCondition"] as? String == "2") + let dataRequest = try #require(transport.request(named: "setDataBreakpoints")) + let dataArguments = try #require(dataRequest["arguments"] as? [String: Any]) + let dataValues = try #require(dataArguments["breakpoints"] as? [[String: Any]]) + #expect(dataValues.first?["dataId"] as? String == "field:count") + #expect(dataValues.first?["accessType"] as? String == "write") + var dataInfo: Result? + session.requestDataBreakpointInfo( + name: "count", + variablesReference: 42, + frameID: 7 + ) { dataInfo = $0 } + let dataInfoRequest = try #require(transport.request(named: "dataBreakpointInfo")) + transport.emitJSON([ + "seq": 8, + "type": "response", + "request_seq": dataInfoRequest["seq"] as! Int, + "success": true, + "command": "dataBreakpointInfo", + "body": [ + "dataId": "field:count", + "description": "Main.count", + "accessTypes": ["write"], + "canPersist": false + ] + ]) + #expect(try dataInfo?.get().dataID == "field:count") + #expect(transport.request(named: "configurationDone") != nil) + + transport.emitJSON([ + "seq": 9, + "type": "event", + "event": "stopped", + "body": ["reason": "breakpoint", "threadId": 11] + ]) + var setVariable: Result? + session.setVariable( + variablesReference: 42, + name: "count", + value: "7" + ) { setVariable = $0 } + let setVariableRequest = try #require(transport.request(named: "setVariable")) + let setVariableArguments = try #require(setVariableRequest["arguments"] as? [String: Any]) + #expect(setVariableArguments["variablesReference"] as? Int == 42) + #expect(setVariableArguments["name"] as? String == "count") + #expect(setVariableArguments["value"] as? String == "7") + transport.emitJSON([ + "seq": 10, + "type": "response", + "request_seq": setVariableRequest["seq"] as! Int, + "success": true, + "command": "setVariable", + "body": ["value": "7", "type": "int", "variablesReference": 0] + ]) + #expect(try setVariable?.get().value == "7") + var stepTargets: Result<[DebugStepInTarget], Error>? + session.requestStepInTargets(frameID: 7) { stepTargets = $0 } + let stepTargetsRequest = try #require(transport.request(named: "stepInTargets")) + transport.emitJSON([ + "seq": 10, + "type": "response", + "request_seq": stepTargetsRequest["seq"] as! Int, + "success": true, + "command": "stepInTargets", + "body": ["targets": [["id": 21, "label": "service.load()", "line": 12]]] + ]) + #expect(try stepTargets?.get().first?.id == 21) + var gotoTargets: Result<[DebugGotoTarget], Error>? + session.requestGotoTargets( + fileURL: URL(fileURLWithPath: "/tmp/Main.java"), + line: 20, + column: 5 + ) { gotoTargets = $0 } + let gotoTargetsRequest = try #require(transport.request(named: "gotoTargets")) + transport.emitJSON([ + "seq": 11, + "type": "response", + "request_seq": gotoTargetsRequest["seq"] as! Int, + "success": true, + "command": "gotoTargets", + "body": ["targets": [["id": 31, "label": "Main.java:20", "line": 20]]] + ]) + #expect(try gotoTargets?.get().first?.id == 31) + session.execute(.stepIn, threadID: 11, targetID: 21) + session.execute(.goto, threadID: 11, targetID: 31) + session.execute(.stepBack, threadID: 11) + session.execute(.restart, threadID: 11) + session.execute(.terminate, threadID: 11) + let stepBack = try #require(transport.request(named: "stepBack")) + let stepBackArguments = try #require(stepBack["arguments"] as? [String: Any]) + #expect(stepBackArguments["threadId"] as? Int == 11) + #expect(stepBackArguments["singleThread"] as? Bool == false) + let smartStep = try #require(transport.request(named: "stepIn")) + #expect((smartStep["arguments"] as? [String: Any])?["targetId"] as? Int == 21) + let goto = try #require(transport.request(named: "goto")) + #expect((goto["arguments"] as? [String: Any])?["targetId"] as? Int == 31) + let restart = try #require(transport.request(named: "restart")) + #expect((restart["arguments"] as? [String: Any])?["threadId"] == nil) + let terminate = try #require(transport.request(named: "terminate")) + #expect((terminate["arguments"] as? [String: Any])?["threadId"] == nil) session.stop() @@ -119,7 +700,7 @@ struct DebugModuleTests { let first = try #require( try await runtime.activateCapability(.debugWorkspace) as? DebugModuleCapability ) - let firstJavaID = ObjectIdentifier(first.javaFeature) + let firstFeatureID = ObjectIdentifier(first.genericFeature) weak var released = recorder.latestGraph try await runtime.sleep(.debug) @@ -130,7 +711,7 @@ struct DebugModuleTests { let second = try #require( try await runtime.activateCapability(.debugWorkspace) as? DebugModuleCapability ) - #expect(ObjectIdentifier(second.javaFeature) != firstJavaID) + #expect(ObjectIdentifier(second.genericFeature) != firstFeatureID) #expect(recorder.factoryCalls == 2) #expect(recorder.graphCalls == 2) } @@ -190,6 +771,10 @@ private final class RecordingTransport: DebugAdapterTransport, DebugAdapterChild onData?(frame) } + func emitData(_ data: Data) { + onData?(data) + } + func request(named command: String) -> [String: Any]? { messages.first { $0["type"] as? String == "request" && $0["command"] as? String == command @@ -213,6 +798,221 @@ private final class RecordingTransport: DebugAdapterTransport, DebugAdapterChild } } +@MainActor +private final class RecordingDebugProtocolCore: DebugProtocolCore { + private var receiveUpdates: [DebugCoreUpdate] = [] + private(set) var lastInspectionOperationID: String? + private(set) var lastDataBreakpointInfoOperationID: String? + private(set) var lastSetVariableOperationID: String? + private(set) var destroyedSessionIDs: [String] = [] + private(set) var breakpointUpdates: [[DebugSourceBreakpoint]] = [] + private(set) var exceptionBreakpointUpdates: [[DebugExceptionBreakpoint]] = [] + private(set) var functionBreakpointUpdates: [[DebugFunctionBreakpoint]] = [] + private(set) var dataBreakpointUpdates: [[DebugDataBreakpoint]] = [] + private(set) var cancelledOperationReasons: [String] = [] + private(set) var lastExecutionSingleThread: Bool? + private(set) var lastExecutionThreadID: Int? + + func createDebugSession( + sessionID: String, + adapterID _: String, + rootPath _: String + ) throws -> DebugCoreUpdate { + update( + sessionID: sessionID, + state: "initializing", + frames: [Data("initialize-frame".utf8)] + ) + } + + func launchDebugSession( + sessionID: String, + operationID _: String, + configuration _: DebugLaunchConfiguration + ) throws -> DebugCoreUpdate { + update(sessionID: sessionID, state: "launching") + } + + func setDebugBreakpoints( + sessionID: String, + sourcePath _: String, + breakpoints: [DebugSourceBreakpoint] + ) throws -> DebugCoreUpdate { + breakpointUpdates.append(breakpoints) + return update(sessionID: sessionID, state: "ready") + } + + func setDebugExceptionBreakpoints( + sessionID: String, + breakpoints: [DebugExceptionBreakpoint] + ) throws -> DebugCoreUpdate { + exceptionBreakpointUpdates.append(breakpoints) + return update(sessionID: sessionID, state: "ready") + } + + func setDebugFunctionBreakpoints( + sessionID: String, + breakpoints: [DebugFunctionBreakpoint] + ) throws -> DebugCoreUpdate { + functionBreakpointUpdates.append(breakpoints) + return update(sessionID: sessionID, state: "ready") + } + + func debugDataBreakpointInfo( + sessionID: String, + operationID: String, + name _: String, + variablesReference _: Int?, + frameID _: Int? + ) throws -> DebugCoreUpdate { + lastDataBreakpointInfoOperationID = operationID + return update(sessionID: sessionID, state: "paused") + } + + func setDebugDataBreakpoints( + sessionID: String, + breakpoints: [DebugDataBreakpoint] + ) throws -> DebugCoreUpdate { + dataBreakpointUpdates.append(breakpoints) + return update(sessionID: sessionID, state: "paused") + } + + func setDebugVariable( + sessionID: String, + operationID: String, + variablesReference _: Int, + name _: String, + value _: String + ) throws -> DebugCoreUpdate { + lastSetVariableOperationID = operationID + return update(sessionID: sessionID, state: "paused") + } + + func cancelDebugOperation( + sessionID: String, + operationID: String, + reason: String + ) throws -> DebugCoreUpdate { + cancelledOperationReasons.append(reason) + return update(sessionID: sessionID, state: "paused", events: [[ + "sequence": 90, + "type": "operationFailed", + "operationId": operationID, + "command": "threads", + "code": reason, + "message": reason == "timedOut" + ? "Debug operation timed out." + : "Debug operation was cancelled." + ]]) + } + + func executeDebugCommand( + sessionID: String, + operationID _: String, + command _: DebugExecutionCommand, + threadID: Int?, + targetID _: Int?, + singleThread: Bool + ) throws -> DebugCoreUpdate { + lastExecutionThreadID = threadID + lastExecutionSingleThread = singleThread + return update(sessionID: sessionID, state: "running") + } + + func inspectDebugSession( + sessionID: String, + operationID: String, + kind _: String, + threadID _: Int?, + frameID _: Int?, + variablesReference _: Int?, + expression _: String?, + sourcePath _: String?, + line _: Int?, + column _: Int? + ) throws -> DebugCoreUpdate { + lastInspectionOperationID = operationID + return update(sessionID: sessionID, state: "paused") + } + + func receiveDebugData(sessionID _: String, data _: Data) throws -> DebugCoreUpdate { + receiveUpdates.removeFirst() + } + + func disconnectDebugSession(sessionID: String) throws -> DebugCoreUpdate { + update(sessionID: sessionID, state: "terminating") + } + + func destroyDebugSession(sessionID: String) { + destroyedSessionIDs.append(sessionID) + } + + func enqueueReceive( + sessionID: String = "java-session", + state: String, + events: [[String: Any]] + ) { + receiveUpdates.append(update( + sessionID: sessionID, + state: state, + events: events + )) + } + + private func update( + sessionID: String, + state: String, + frames: [Data] = [], + events: [[String: Any]] = [] + ) -> DebugCoreUpdate { + let object: [String: Any] = [ + "sessionId": sessionID, + "state": state, + "outboundFrames": frames.map { $0.base64EncodedString() }, + "events": events + ] + let data = try! JSONSerialization.data(withJSONObject: object) + return try! JSONDecoder().decode(DebugCoreUpdate.self, from: data) + } +} + +@MainActor +private final class RecordingDebugDeadlineScheduler: DebugOperationDeadlineScheduling { + private var deadlines: [RecordingDebugDeadline] = [] + + func schedule( + afterMilliseconds _: Int, + action: @escaping @MainActor () -> Void + ) -> any DebugOperationDeadline { + let deadline = RecordingDebugDeadline(action: action) + deadlines.append(deadline) + return deadline + } + + func fireLast() { + deadlines.last?.fire() + } +} + +@MainActor +private final class RecordingDebugDeadline: DebugOperationDeadline { + private var action: (@MainActor () -> Void)? + + init(action: @escaping @MainActor () -> Void) { + self.action = action + } + + func cancel() { + action = nil + } + + func fire() { + let action = action + self.action = nil + action?() + } +} + @MainActor private final class Recorder { var factoryCalls = 0 var graphCalls = 0 @@ -220,7 +1020,6 @@ private final class RecordingTransport: DebugAdapterTransport, DebugAdapterChild } @MainActor private final class TestGraph: DebugServiceGraph { - let javaFeatureTarget: any JavaDebugFeatureTarget = TestJavaDebugFeatureTarget() let genericFeatureTarget: any GenericDebugFeatureTarget = TestGenericDebugFeatureTarget() var hasActiveDebugWork = false func activate(context: ModuleContext) {} @@ -228,7 +1027,6 @@ private final class RecordingTransport: DebugAdapterTransport, DebugAdapterChild func stop() async {} } -@MainActor private final class TestJavaDebugFeatureTarget: JavaDebugFeatureTarget {} @MainActor private final class TestGenericDebugFeatureTarget: GenericDebugFeatureTarget {} @MainActor private final class EmptyModule: LitheModule { diff --git a/macos/Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift b/macos/Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift index eb01b4f3..52b4a676 100644 --- a/macos/Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift +++ b/macos/Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift @@ -188,6 +188,122 @@ struct LanguageIntelligenceModuleTests { #expect(manager.languageServerOperationIDs["java"] == nil) } + @Test + func javaDebugServerWaitsForJdtlsReadyAndReturnsItsPort() async throws { + let root = URL(fileURLWithPath: "/workspace/java-debug", isDirectory: true) + let descriptor = try #require( + LanguageProviderCatalog.compatibilityFallback.provider( + for: root.appendingPathComponent("Main.java") + ) + ) + let session = WorkspaceStateLanguageServerSession() + let manager = LanguageToolingSessionManager( + catalog: .compatibilityFallback, + runtimes: [WorkspaceStateLanguageProviderRuntime( + descriptor: descriptor, + session: session + )] + ) + let task = Task { try await manager.startJavaDebugServer(rootURL: root) } + defer { task.cancel() } + + await session.waitUntilStarted() + #expect(session.executedCommands.isEmpty) + session.publish(.ready) + let command = await session.waitForExecuteCommand() + #expect(command.command == "vscode.java.startDebugSession") + #expect(command.arguments.isEmpty) + session.completeExecuteReturningValue(.success(.integer(5005))) + + #expect(try await task.value == 5005) + } + + @Test + func javaDebugLaunchTargetUsesJdtlsProjectMetadataForTheCurrentFile() async throws { + let root = URL(fileURLWithPath: "/workspace/java-debug", isDirectory: true) + let source = root.appendingPathComponent("service/src/main/java/example/Main.java") + let descriptor = try #require( + LanguageProviderCatalog.compatibilityFallback.provider(for: source) + ) + let session = WorkspaceStateLanguageServerSession() + let manager = LanguageToolingSessionManager( + catalog: .compatibilityFallback, + runtimes: [WorkspaceStateLanguageProviderRuntime( + descriptor: descriptor, + session: session + )] + ) + let task = Task { + try await manager.resolveJavaDebugLaunchTarget(fileURL: source, rootURL: root) + } + defer { task.cancel() } + + await session.waitUntilStarted() + session.publish(.ready) + let command = await session.waitForExecuteCommand() + #expect(command.command == "vscode.java.resolveMainClass") + #expect(command.arguments.isEmpty) + session.completeExecuteReturningValue(.success(.array([ + .object([ + "mainClass": .string("other/example.Main"), + "projectName": .string("other"), + "filePath": .string(root.appendingPathComponent("other/Main.java").path), + ]), + .object([ + "mainClass": .string("service/example.Main"), + "projectName": .string("service"), + "filePath": .string(source.path), + ]), + ]))) + + let classpathCommand = await session.waitForExecuteCommand(number: 2) + #expect(classpathCommand.command == "vscode.java.resolveClasspath") + #expect(classpathCommand.arguments == [ + .string("service/example.Main"), + .string("service"), + .string("runtime"), + ]) + session.completeExecuteReturningValue(.success(.array([ + .array([.string("/workspace/modules")]), + .array([.string("/workspace/classes")]), + ]))) + + #expect(try await task.value == JavaDebugLaunchTarget( + mainClass: "service/example.Main", + projectName: "service", + modulePaths: ["/workspace/modules"], + classPaths: ["/workspace/classes"] + )) + } + + @Test + func cancellingJavaDebugServerStartupReleasesTheReadyWait() async throws { + let root = URL(fileURLWithPath: "/workspace/java-debug-cancel", isDirectory: true) + let descriptor = try #require( + LanguageProviderCatalog.compatibilityFallback.provider( + for: root.appendingPathComponent("Main.java") + ) + ) + let session = WorkspaceStateLanguageServerSession() + let manager = LanguageToolingSessionManager( + catalog: .compatibilityFallback, + runtimes: [WorkspaceStateLanguageProviderRuntime( + descriptor: descriptor, + session: session + )] + ) + let task = Task { try await manager.startJavaDebugServer(rootURL: root) } + + await session.waitUntilStarted() + task.cancel() + + await #expect(throws: CancellationError.self) { + try await task.value + } + session.publish(.ready) + #expect(session.executedCommands.isEmpty) + } + @Test func languageServerStartTimeoutIsLoggedWithTheOperationID() throws { let root = URL(fileURLWithPath: "/workspace/java", isDirectory: true) @@ -479,17 +595,48 @@ private final class WorkspaceStateLanguageServerSession: LanguageServerSession { private(set) var startedFingerprint: String? private(set) var stopCallCount = 0 var startError: Error? + private(set) var executedCommands: [LanguageServerCommand] = [] + private var startWaiters: [CheckedContinuation] = [] + private var executeWaiters: [( + number: Int, + continuation: CheckedContinuation + )] = [] + private var executeValueCompletion: ((Result) -> Void)? func start(rootURL _: URL, workspaceFingerprint: String?) throws { if let startError { throw startError } startedFingerprint = workspaceFingerprint isRunning = true + let waiters = startWaiters + startWaiters = [] + waiters.forEach { $0.resume() } } func publish(_ state: LanguageServerSessionState) { onStateChange?(state) } + func waitUntilStarted() async { + if isRunning { return } + await withCheckedContinuation { continuation in + startWaiters.append(continuation) + } + } + + func waitForExecuteCommand(number: Int = 1) async -> LanguageServerCommand { + precondition(number > 0) + if executedCommands.count >= number { return executedCommands[number - 1] } + return await withCheckedContinuation { continuation in + executeWaiters.append((number, continuation)) + } + } + + func completeExecuteReturningValue(_ result: Result) { + let completion = executeValueCompletion + executeValueCompletion = nil + completion?(result) + } + func synchronize(fileURL _: URL, text _: String, languageID _: String) throws {} func closeDocument(_: URL) {} @@ -567,6 +714,20 @@ private final class WorkspaceStateLanguageServerSession: LanguageServerSession { throw WorkspaceStateSessionError.unexpectedOperation } + func executeReturningValue( + _ command: LanguageServerCommand, + fileURL _: URL, + completion: @escaping (Result) -> Void + ) throws { + executedCommands.append(command) + executeValueCompletion = completion + let readyWaiters = executeWaiters.filter { $0.number <= executedCommands.count } + executeWaiters.removeAll { $0.number <= executedCommands.count } + readyWaiters.forEach { + $0.continuation.resume(returning: executedCommands[$0.number - 1]) + } + } + func resolveVirtualDocument( uri _: String, completion _: @escaping (Result) -> Void diff --git a/macos/Tests/LitheTests/JavaLanguageServerRuntimeTests.swift b/macos/Tests/LitheTests/JavaLanguageServerRuntimeTests.swift index 7981937e..f250a99f 100644 --- a/macos/Tests/LitheTests/JavaLanguageServerRuntimeTests.swift +++ b/macos/Tests/LitheTests/JavaLanguageServerRuntimeTests.swift @@ -94,7 +94,7 @@ struct JavaLanguageServerRuntimeTests { let root = fileManager.temporaryDirectory .appendingPathComponent("lithe-jdtls-resolver-\(UUID().uuidString)", isDirectory: true) defer { try? fileManager.removeItem(at: root) } - for directory in ["bin", "plugins", "config_mac", "config_mac_arm", "lombok"] { + for directory in ["bin", "plugins", "config_mac", "config_mac_arm", "lombok", "java-debug"] { try fileManager.createDirectory( at: root.appendingPathComponent(directory, isDirectory: true), withIntermediateDirectories: true @@ -108,7 +108,8 @@ struct JavaLanguageServerRuntimeTests { executable, firstLauncher, root.appendingPathComponent("plugins/org.eclipse.equinox.launcher_2.0.0.jar"), - root.appendingPathComponent("lombok/lombok.jar") + root.appendingPathComponent("lombok/lombok.jar"), + root.appendingPathComponent("java-debug/com.microsoft.java.debug.plugin-0.53.1.jar") ] { try Data().write(to: file) } @@ -125,6 +126,10 @@ struct JavaLanguageServerRuntimeTests { #expect(resources.configurationDirectoryURL.lastPathComponent == "config_mac") #endif #expect(resources.lombokAgentURL.lastPathComponent == "lombok.jar") + #expect( + resources.javaDebugBundleURL?.lastPathComponent + == "com.microsoft.java.debug.plugin-0.53.1.jar" + ) } @Test @@ -153,7 +158,7 @@ struct JavaLanguageServerRuntimeTests { let root = fileManager.temporaryDirectory .appendingPathComponent("lithe-jdtls-architecture-\(UUID().uuidString)", isDirectory: true) defer { try? fileManager.removeItem(at: root) } - for directory in ["bin", "plugins", "lombok"] { + for directory in ["bin", "plugins", "lombok", "java-debug"] { try fileManager.createDirectory( at: root.appendingPathComponent(directory, isDirectory: true), withIntermediateDirectories: true @@ -174,7 +179,8 @@ struct JavaLanguageServerRuntimeTests { for file in [ root.appendingPathComponent("bin/jdtls"), root.appendingPathComponent("plugins/org.eclipse.equinox.launcher_1.0.0.jar"), - root.appendingPathComponent("lombok/lombok.jar") + root.appendingPathComponent("lombok/lombok.jar"), + root.appendingPathComponent("java-debug/com.microsoft.java.debug.plugin-0.53.1.jar") ] { try Data().write(to: file) } @@ -347,7 +353,6 @@ private struct JavaLanguageServerTestRuntimeLocator: RuntimeLocator { func systemMavenExecutable() -> URL? { nil } func mavenExecutable(forHomePath path: String) -> URL? { nil } func mavenRuntime(at executableURL: URL) -> MavenRuntimeCandidate? { nil } - func systemJDBExecutable() -> URL? { nil } func bundledJdkHome() -> URL? { bundledHomePath.map { URL(fileURLWithPath: $0, isDirectory: true) } } diff --git a/macos/Tests/LitheTests/LanguageServerToolServiceTests.swift b/macos/Tests/LitheTests/LanguageServerToolServiceTests.swift index a4489358..13560759 100644 --- a/macos/Tests/LitheTests/LanguageServerToolServiceTests.swift +++ b/macos/Tests/LitheTests/LanguageServerToolServiceTests.swift @@ -315,7 +315,6 @@ private struct LanguageServerToolTestRuntimeLocator: RuntimeLocator { func systemMavenExecutable() -> URL? { nil } func mavenExecutable(forHomePath _: String) -> URL? { nil } func mavenRuntime(at _: URL) -> MavenRuntimeCandidate? { nil } - func systemJDBExecutable() -> URL? { nil } } private struct LanguageServerToolTestDiscovery: RuntimeToolDiscovery { diff --git a/macos/Tests/LitheTests/MavenRuntimeTests.swift b/macos/Tests/LitheTests/MavenRuntimeTests.swift index d08ec1d3..45ee9d9d 100644 --- a/macos/Tests/LitheTests/MavenRuntimeTests.swift +++ b/macos/Tests/LitheTests/MavenRuntimeTests.swift @@ -142,7 +142,6 @@ private struct ProjectRelativeRuntimeLocator: RuntimeLocator { func systemMavenExecutable() -> URL? { nil } func mavenExecutable(forHomePath path: String) -> URL? { nil } func mavenRuntime(at executableURL: URL) -> MavenRuntimeCandidate? { nil } - func systemJDBExecutable() -> URL? { nil } func javaLanguageServerExecutable() -> URL? { nil } } @@ -172,7 +171,6 @@ private final class BlockingRuntimeLocator: RuntimeLocator, @unchecked Sendable func systemMavenExecutable() -> URL? { nil } func mavenExecutable(forHomePath path: String) -> URL? { nil } func mavenRuntime(at executableURL: URL) -> MavenRuntimeCandidate? { nil } - func systemJDBExecutable() -> URL? { nil } func javaLanguageServerExecutable() -> URL? { nil } } diff --git a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift index 0e5b4446..cf4655df 100644 --- a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -100,7 +100,7 @@ struct RunConfigurationIntegrationTests { #expect(go?.activationPolicy == .onDemand) #expect(go?.capabilities.contains(.languageServer) == true) #expect(go?.capabilities.contains(.debugAdapter) == true) - #expect(catalog.provider(for: URL(fileURLWithPath: "/tmp/Main.java"))?.capabilities.contains(.debugAdapter) == false) + #expect(catalog.provider(for: URL(fileURLWithPath: "/tmp/Main.java"))?.capabilities.contains(.debugAdapter) == true) if !RustCoreBridge().isAvailable { #expect(catalog.provider(for: URL(fileURLWithPath: "/tmp/Package.swift")) == nil) #expect(catalog.provider(for: URL(fileURLWithPath: "/tmp/Dockerfile")) == nil) @@ -127,7 +127,7 @@ struct RunConfigurationIntegrationTests { #expect(registry.pack(id: "go")?.debugAdapterLaunch?.executableNames == ["dlv"]) #expect(registry.pack(id: "python")?.debugAdapterLaunch?.adapterID == "python") #expect(registry.pack(id: "rust")?.debugAdapterLaunch?.fallbacks.first?.executableName == "xcrun") - #expect(registry.pack(id: "java")?.debugAdapterLaunch?.adapterID == "java") + #expect(registry.pack(id: "java")?.debugAdapterLaunch == nil) if !RustCoreBridge().isAvailable { #expect(providerIDSet == ["java", "go", "python", "node", "rust"]) #expect(registry.catalog.provider(for: URL(fileURLWithPath: "/tmp/Dockerfile")) == nil) @@ -243,7 +243,7 @@ struct RunConfigurationIntegrationTests { } @Test - func aFutureJavaDAPRuntimeCanOverrideTheLegacyDebugBoundary() throws { + func javaDAPRuntimeActivatesThroughTheSharedDebugBoundary() throws { let javaDescriptor = LanguageProviderDescriptor( id: "java", displayName: "Java", @@ -286,11 +286,24 @@ struct RunConfigurationIntegrationTests { workspaceURL: root, configurations: [], selectedConfiguration: nil, + javaTarget: JavaDebugLaunchTarget( + mainClass: "service/com.acme.Main", + projectName: "service", + modulePaths: ["/tmp/java-project/modules"], + classPaths: ["/tmp/java-project/classes"] + ), options: { _ in RunOptions() } ) #expect(configuration.request == .launch) - #expect(configuration.arguments["mainClass"] == .string("com.acme.Main")) + #expect(configuration.arguments["mainClass"] == .string("service/com.acme.Main")) + #expect(configuration.arguments["projectName"] == .string("service")) + #expect(configuration.arguments["modulePaths"] == .array([ + .string("/tmp/java-project/modules"), + ])) + #expect(configuration.arguments["classPaths"] == .array([ + .string("/tmp/java-project/classes"), + ])) #expect(configuration.arguments["cwd"] == .string(root.path)) } @@ -327,7 +340,7 @@ struct RunConfigurationIntegrationTests { ) #expect(lldbCandidates.first?.source == .xcode) #expect(discovery.guidance(for: "java-debug-adapter", projectURL: root, environment: [:]) - .recovery.contains("LITHE_JAVA_DEBUG_PATH")) + .recovery.contains("bundled Java language and Debug Adapter resources")) } @Test @@ -366,14 +379,16 @@ struct RunConfigurationIntegrationTests { } @Test - func legacyJavaDoesNotAcceptGenericDAPBreakpointsWithoutAnAdapter() throws { + func javaDAPBreakpointsCanBeStoredBeforeTheAdapterStarts() throws { let source = URL(fileURLWithPath: "/tmp/Main.java") - #expect(throws: DebugProviderError.noProvider(fileExtension: "java")) { - try DebugAdapterSessionManager(providers: LanguageProviderCatalog.standard.debugProviders) { _, _ in nil }.setBreakpoints( - [DebugSourceBreakpoint(line: 1)], - in: source - ) - } + let manager = DebugAdapterSessionManager( + providers: LanguageProviderCatalog.standard.debugProviders + ) { _, _ in nil } + + try manager.setBreakpoints([DebugSourceBreakpoint(line: 1)], in: source) + + #expect(manager.provider(for: source)?.id == "java") + #expect(manager.activeAdapterIDs.isEmpty) } @Test @@ -747,6 +762,62 @@ struct RunConfigurationIntegrationTests { #expect(received == [response]) } + @Test + func javaTransportQueuesDAPBytesUntilJdtlsPortAndSocketAreReady() async throws { + let portGate = JavaDebugPortGate() + let socket = TestDlvSocketConnection() + let endpointRecorder = DebugEndpointRecorder() + let transport = MacJavaDebugAdapterTransport( + portResolver: { rootURL in try await portGate.resolve(rootURL: rootURL) }, + socketFactory: { host, port in + endpointRecorder.record(host: host, port: port) + return socket + } + ) + let root = URL(fileURLWithPath: "/tmp/java-dap", isDirectory: true) + let initializeFrame = Data("Content-Length: 2\r\n\r\n{}".utf8) + + try transport.start(rootURL: root) + try transport.send(initializeFrame) + #expect(socket.sent.isEmpty) + #expect(await portGate.waitUntilRequested() == root.standardizedFileURL) + + portGate.succeed(port: 5005) + let endpoint = await endpointRecorder.waitUntilRecorded() + #expect(endpoint.host == "127.0.0.1") + #expect(endpoint.port == 5005) + #expect(socket.startCount == 1) + #expect(socket.sent.isEmpty) + + socket.onReady?() + #expect(socket.sent == [initializeFrame]) + transport.stop() + #expect(socket.stopCount == 1) + } + + @Test + func stoppingJavaTransportCancelsPortDiscoveryWithoutOpeningSocket() async throws { + let portGate = JavaDebugPortGate() + let socket = TestDlvSocketConnection() + let endpointRecorder = DebugEndpointRecorder() + let transport = MacJavaDebugAdapterTransport( + portResolver: { rootURL in try await portGate.resolve(rootURL: rootURL) }, + socketFactory: { host, port in + endpointRecorder.record(host: host, port: port) + return socket + } + ) + + try transport.start(rootURL: URL(fileURLWithPath: "/tmp/java-dap-cancel")) + _ = await portGate.waitUntilRequested() + transport.stop() + + await portGate.waitUntilCancelled() + #expect(!endpointRecorder.didRecord) + #expect(socket.startCount == 0) + #expect(!transport.isRunning) + } + @Test func nodeTransportDiscoversTheOfficialBundleAndQueuesUntilTCPIsReady() async throws { let root = URL(fileURLWithPath: "/tmp/node-dap", isDirectory: true) @@ -1203,7 +1274,10 @@ struct RunConfigurationIntegrationTests { let resources = JDTLSLaunchResources( launcherJarURL: URL(fileURLWithPath: "/jdtls/plugins/equinox.jar"), configurationDirectoryURL: URL(fileURLWithPath: "/jdtls/config_mac"), - lombokAgentURL: URL(fileURLWithPath: "/jdtls/lombok/lombok.jar") + lombokAgentURL: URL(fileURLWithPath: "/jdtls/lombok/lombok.jar"), + javaDebugBundleURL: URL( + fileURLWithPath: "/jdtls/java-debug/com.microsoft.java.debug.plugin-0.53.1.jar" + ) ) let runtime = StdioLanguageProviderRuntime( descriptor: descriptor, @@ -1758,6 +1832,10 @@ struct RunConfigurationIntegrationTests { text: "struct App {}\n", rootURL: root ) + #expect(await Self.waitForMainActorCondition { + core.startCalls.count == 1 + && core.syncCalls.last?.fileURL == source.standardizedFileURL + }) let startCall = try #require(core.startCalls.first) #expect(startCall.providerID == "swift") #expect(startCall.executableURL.path == "/usr/bin/sourcekit-lsp") @@ -2000,7 +2078,9 @@ struct RunConfigurationIntegrationTests { #expect(executeCall.operation == .executeCommand) #expect(executeCall.fileURL == nil) #expect(executeCall.command?.command == "source.fix") - core.enqueueRequestSuccess(operation: .executeCommand, result: ["ok": true]) + core.enqueueRequestSuccess(operation: .executeCommand, result: [ + "value": ["ok": true], + ]) #expect(await Self.waitForMainActorCondition { executeResult != nil }) #expect(executeResult != nil) try executeResult?.get() @@ -2986,7 +3066,6 @@ struct RunConfigurationIntegrationTests { let report = try #require(runtime.javaEnvironmentReport) #expect(report.status == .ready) #expect(report.javaHomePath == "/toolchains/jdk") - #expect(report.jdbExecutablePath == "/toolchains/jdk/bin/jdb") #expect(!report.status.blocksJavaRun) } @@ -3006,69 +3085,6 @@ struct RunConfigurationIntegrationTests { #expect(report.recovery.contains("JAVA_HOME")) } - @Test - func mavenDebugUsesSharedDebugLaunchPlan() throws { - let root = URL(fileURLWithPath: "/tmp/lithe-debug-service", isDirectory: true) - let configuration = JavaRunConfiguration( - id: "spring:com.example.App", - name: "App", - kind: .springBoot, - modulePath: "backend", - mainClass: "com.example.App" - ) - let arguments = [ - "-B", "-ntp", "-pl", "backend", - "-Dspring-boot.run.jvmArguments=-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127.0.0.1:5555", - "spring-boot:run" - ] - let operations = RecordingRunConfigurationOperations( - status: .ready, - effective: [], - plans: [configuration.id: SharedLaunchPlan( - executable: .toolchain("project-maven"), - arguments: arguments, - workingDirectory: "backend" - )] - ) - let processFactory = RecordingProcessFactory() - let runtime = ProjectRuntimeService( - runtimeLocator: RunTestRuntimeLocator(), - store: RunTestKeyValueStore() - ) - runtime.openProject(at: root) - let project = MavenProject( - rootURL: root, - pomURL: root.appendingPathComponent("pom.xml"), - groupID: nil, - artifactID: "fixture", - version: nil, - packaging: "jar", - modules: [], - profiles: [], - hasWrapper: false - ) - let service = JavaDebugService( - runtimeService: runtime, - processFactory: { processFactory.make() }, - fileStorage: RunTestFileStorage(), - javaMavenOperations: RunTestJavaMavenOperations(), - runConfigurationOperations: operations - ) - - service.startMaven( - configuration: configuration, - project: project, - projectURL: root, - options: JavaRunOptions() - ) - - let request = try #require(processFactory.processes.first?.requests.first) - #expect(request.arguments == arguments) - #expect(request.workingDirectory == root.appendingPathComponent("backend").path) - #expect(operations.debugPorts.count == 1) - #expect(operations.debugPorts[0] != nil) - } - @Test func generationPublishesNoEntryResultAndKeepsCurrentFileAvailable() async { let current = JavaRunConfiguration.currentFile @@ -4724,7 +4740,6 @@ private struct RunTestRuntimeLocator: RuntimeLocator { version: "3.9.9" ) } - func systemJDBExecutable() -> URL? { URL(fileURLWithPath: "/toolchains/jdk/bin/jdb") } } private struct NestedMavenWrapperRuntimeLocator: RuntimeLocator { @@ -4742,7 +4757,6 @@ private struct NestedMavenWrapperRuntimeLocator: RuntimeLocator { func systemMavenExecutable() -> URL? { nil } func mavenExecutable(forHomePath path: String) -> URL? { nil } func mavenRuntime(at executableURL: URL) -> MavenRuntimeCandidate? { nil } - func systemJDBExecutable() -> URL? { nil } } private struct MissingJavaRuntimeLocator: RuntimeLocator { @@ -4756,7 +4770,6 @@ private struct MissingJavaRuntimeLocator: RuntimeLocator { func systemMavenExecutable() -> URL? { nil } func mavenExecutable(forHomePath path: String) -> URL? { nil } func mavenRuntime(at executableURL: URL) -> MavenRuntimeCandidate? { nil } - func systemJDBExecutable() -> URL? { nil } } private struct XcrunOnlyRuntimeLocator: RuntimeLocator { @@ -4768,7 +4781,6 @@ private struct XcrunOnlyRuntimeLocator: RuntimeLocator { func systemMavenExecutable() -> URL? { nil } func mavenExecutable(forHomePath path: String) -> URL? { nil } func mavenRuntime(at executableURL: URL) -> MavenRuntimeCandidate? { nil } - func systemJDBExecutable() -> URL? { nil } } @MainActor @@ -4869,6 +4881,87 @@ private final class TestDlvSocketConnection: DlvSocketConnection { func stop() { stopCount += 1 } } +@MainActor +private final class JavaDebugPortGate { + private var requestedRoot: URL? + private var requestWaiters: [CheckedContinuation] = [] + private var portContinuation: CheckedContinuation? + private var cancelled = false + private var cancellationWaiters: [CheckedContinuation] = [] + + func resolve(rootURL: URL) async throws -> UInt16 { + requestedRoot = rootURL.standardizedFileURL + let waiters = requestWaiters + requestWaiters = [] + waiters.forEach { $0.resume(returning: rootURL.standardizedFileURL) } + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + portContinuation = continuation + } + } onCancel: { + Task { @MainActor [weak self] in self?.cancel() } + } + } + + func waitUntilRequested() async -> URL { + if let requestedRoot { return requestedRoot } + return await withCheckedContinuation { continuation in + requestWaiters.append(continuation) + } + } + + func succeed(port: UInt16) { + let continuation = portContinuation + portContinuation = nil + continuation?.resume(returning: port) + } + + func waitUntilCancelled() async { + if cancelled { return } + await withCheckedContinuation { continuation in + cancellationWaiters.append(continuation) + } + } + + private func cancel() { + guard !cancelled else { return } + cancelled = true + let portContinuation = portContinuation + self.portContinuation = nil + portContinuation?.resume(throwing: CancellationError()) + let waiters = cancellationWaiters + cancellationWaiters = [] + waiters.forEach { $0.resume() } + } +} + +@MainActor +private final class DebugEndpointRecorder { + struct Endpoint { + let host: String + let port: UInt16 + } + + private var endpoint: Endpoint? + private var waiters: [CheckedContinuation] = [] + var didRecord: Bool { endpoint != nil } + + func record(host: String, port: UInt16) { + let endpoint = Endpoint(host: host, port: port) + self.endpoint = endpoint + let waiters = waiters + self.waiters = [] + waiters.forEach { $0.resume(returning: endpoint) } + } + + func waitUntilRecorded() async -> Endpoint { + if let endpoint { return endpoint } + return await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } +} + private struct RunTestFileStorage: FileStorage { func homeDirectory() -> URL { URL(fileURLWithPath: "/tmp") } func cacheDirectory() -> URL { URL(fileURLWithPath: "/tmp") } diff --git a/rust/lithe-core/resources/lsp/language-providers.json b/rust/lithe-core/resources/lsp/language-providers.json index 113241ef..6bf7f001 100644 --- a/rust/lithe-core/resources/lsp/language-providers.json +++ b/rust/lithe-core/resources/lsp/language-providers.json @@ -5,7 +5,7 @@ "id": "java", "displayName": "Java", "fileExtensions": ["java"], - "capabilities": ["run", "languageServer", "formatting", "testing"], + "capabilities": ["run", "languageServer", "debugAdapter", "formatting", "testing"], "activationPolicy": "onDemand", "languageId": "java", "languageServerLaunch": { diff --git a/rust/lithe-core/src/debug/engine.rs b/rust/lithe-core/src/debug/engine.rs new file mode 100644 index 00000000..0c7a89a4 --- /dev/null +++ b/rust/lithe-core/src/debug/engine.rs @@ -0,0 +1,2524 @@ +//! Stateful DAP reducer whose byte transport and process lifecycle remain platform owned. + +use super::protocol::{frame_message, parse_messages}; +use super::types::*; +use crate::protocol::{CoreError, ErrorCode}; +use base64::engine::general_purpose::STANDARD as BASE64; +use base64::Engine as _; +use serde_json::{json, Map, Value}; +use std::collections::{BTreeMap, HashMap}; +use std::sync::{Mutex, OnceLock}; + +static SESSIONS: OnceLock>> = OnceLock::new(); + +#[derive(Debug)] +struct DebugSession { + id: String, + adapter_id: String, + root_path: String, + state: DebugSessionState, + next_request_sequence: i64, + next_event_sequence: u64, + read_buffer: Vec, + pending_requests: BTreeMap, + breakpoints: BTreeMap>, + exception_breakpoints: Vec, + did_configure_exception_breakpoints: bool, + function_breakpoints: Vec, + data_breakpoints: Vec, + did_receive_initialized: bool, + supports_configuration_done: bool, + capabilities: DebugCapabilities, + pending_launch: Option<(String, DebugLaunchConfiguration)>, + outbound_frames: Vec>, + events: Vec, +} + +#[derive(Debug)] +enum PendingRequest { + Initialize, + Launch { + operation_id: String, + }, + SetBreakpoints { + source_path: String, + requested: Vec, + }, + SetExceptionBreakpoints, + SetFunctionBreakpoints { + requested: Vec, + }, + DataBreakpointInfo { + operation_id: String, + }, + SetDataBreakpoints { + requested: Vec, + }, + SetVariable { + operation_id: String, + name: String, + }, + Cancel, + ConfigurationDone, + Execute { + operation_id: String, + command: DebugExecutionCommand, + single_thread: bool, + }, + Inspect { + operation_id: String, + kind: DebugInspectKind, + }, + Disconnect, +} + +/// Creates a session and returns the framed DAP `initialize` request to send. +pub(crate) fn create_session( + request: CreateSessionRequest, +) -> Result { + validate_identifier(&request.session_id, "sessionId")?; + validate_identifier(&request.adapter_id, "adapterId")?; + validate_path(&request.root_path, "rootPath")?; + let mut sessions = sessions_lock()?; + if sessions.contains_key(&request.session_id) { + return Err(invalid_request( + "A debug session with this sessionId already exists.", + )); + } + let mut session = DebugSession { + id: request.session_id.clone(), + adapter_id: request.adapter_id, + root_path: request.root_path, + state: DebugSessionState::Idle, + next_request_sequence: 1, + next_event_sequence: 1, + read_buffer: Vec::new(), + pending_requests: BTreeMap::new(), + breakpoints: BTreeMap::new(), + exception_breakpoints: Vec::new(), + did_configure_exception_breakpoints: false, + function_breakpoints: Vec::new(), + data_breakpoints: Vec::new(), + did_receive_initialized: false, + supports_configuration_done: false, + capabilities: DebugCapabilities::default(), + pending_launch: None, + outbound_frames: Vec::new(), + events: Vec::new(), + }; + session.transition(DebugSessionState::Initializing); + session.send_request( + "initialize", + json!({ + "clientID": "lithe", + "clientName": "Lithe", + "adapterID": session.adapter_id, + "linesStartAt1": true, + "columnsStartAt1": true, + "pathFormat": "path", + "supportsVariableType": true, + "supportsVariablePaging": true, + "supportsRunInTerminalRequest": false, + "supportsMemoryReferences": false, + "supportsProgressReporting": false, + "supportsInvalidatedEvent": true + }), + PendingRequest::Initialize, + )?; + let update = session.take_update(); + sessions.insert(request.session_id, session); + Ok(update) +} + +/// Queues launch or attach now, or stores it until initialize completes. +pub(crate) fn launch(request: LaunchRequest) -> Result { + validate_identifier(&request.operation_id, "operationId")?; + with_session(&request.session_id, |session| { + match session.state { + DebugSessionState::Initializing => { + session.pending_launch = Some((request.operation_id, request.configuration)); + } + DebugSessionState::Ready => { + session.perform_launch(request.operation_id, request.configuration)?; + } + _ => { + return Err(invalid_request( + "The debug session is not ready to launch or attach.", + )) + } + } + Ok(session.take_update()) + }) +} + +/// Stores a deterministic breakpoint set and sends it after DAP initialization. +pub(crate) fn set_breakpoints( + mut request: SetBreakpointsRequest, +) -> Result { + validate_path(&request.source_path, "sourcePath")?; + for breakpoint in &request.breakpoints { + if breakpoint.line < 1 || breakpoint.column.is_some_and(|column| column < 1) { + return Err(invalid_request( + "Debug breakpoint line and column values must be one-based.", + )); + } + } + request.breakpoints.sort_by_key(|breakpoint| { + ( + breakpoint.line, + breakpoint.column.unwrap_or(0), + breakpoint.enabled, + breakpoint.condition.clone().unwrap_or_default(), + breakpoint.hit_condition.clone().unwrap_or_default(), + breakpoint.log_message.clone().unwrap_or_default(), + ) + }); + request.breakpoints.dedup(); + with_session(&request.session_id, |session| { + session + .breakpoints + .insert(request.source_path.clone(), request.breakpoints); + if session.did_receive_initialized { + session.send_breakpoints(&request.source_path)?; + } + Ok(session.take_update()) + }) +} + +/// Stores deterministic exception filters and sends them after DAP initialization. +pub(crate) fn set_exception_breakpoints( + mut request: SetExceptionBreakpointsRequest, +) -> Result { + for breakpoint in &mut request.breakpoints { + breakpoint.filter = breakpoint.filter.trim().to_string(); + if breakpoint.filter.is_empty() { + return Err(invalid_request( + "Debug exception breakpoint filters cannot be empty.", + )); + } + breakpoint.condition = breakpoint + .condition + .take() + .map(|condition| condition.trim().to_string()) + .filter(|condition| !condition.is_empty()); + } + request.breakpoints.sort_by(|left, right| { + (&left.filter, left.enabled, &left.condition).cmp(&( + &right.filter, + right.enabled, + &right.condition, + )) + }); + request + .breakpoints + .dedup_by(|left, right| left.filter == right.filter); + with_session(&request.session_id, |session| { + session.exception_breakpoints = request.breakpoints; + session.did_configure_exception_breakpoints = true; + if session.did_receive_initialized { + session.send_exception_breakpoints()?; + } + Ok(session.take_update()) + }) +} + +/// Stores deterministic function breakpoints and sends them when supported. +pub(crate) fn set_function_breakpoints( + mut request: SetFunctionBreakpointsRequest, +) -> Result { + for breakpoint in &mut request.breakpoints { + breakpoint.name = breakpoint.name.trim().to_string(); + if breakpoint.name.is_empty() { + return Err(invalid_request( + "Debug function breakpoint names cannot be empty.", + )); + } + breakpoint.condition = normalize_optional_text(breakpoint.condition.take()); + breakpoint.hit_condition = normalize_optional_text(breakpoint.hit_condition.take()); + } + request.breakpoints.sort_by(|left, right| { + ( + &left.name, + left.enabled, + &left.condition, + &left.hit_condition, + ) + .cmp(&( + &right.name, + right.enabled, + &right.condition, + &right.hit_condition, + )) + }); + request + .breakpoints + .dedup_by(|left, right| left.name == right.name); + with_session(&request.session_id, |session| { + session.function_breakpoints = request.breakpoints; + if session.did_receive_initialized && session.capabilities.supports_function_breakpoints { + session.send_function_breakpoints()?; + } + Ok(session.take_update()) + }) +} + +/// Resolves one adapter-owned data breakpoint identity for the selected variable. +pub(crate) fn data_breakpoint_info( + mut request: DataBreakpointInfoRequest, +) -> Result { + validate_identifier(&request.operation_id, "operationId")?; + request.name = request.name.trim().to_string(); + if request.name.is_empty() { + return Err(invalid_request( + "Debug data breakpoint info requires a variable name.", + )); + } + if request + .variables_reference + .is_some_and(|reference| reference < 1) + { + return Err(invalid_request( + "Debug variablesReference must be positive.", + )); + } + if request.frame_id.is_some_and(|frame_id| frame_id < 0) { + return Err(invalid_request("Debug frameId cannot be negative.")); + } + if request.variables_reference.is_none() && request.frame_id.is_none() { + return Err(invalid_request( + "Debug data breakpoint info requires a variable reference or frame.", + )); + } + with_session(&request.session_id, |session| { + if !session.capabilities.supports_data_breakpoints { + return Err(invalid_request( + "The debug adapter does not support data breakpoints.", + )); + } + let mut arguments = Map::new(); + arguments.insert("name".to_string(), json!(request.name)); + insert_option( + &mut arguments, + "variablesReference", + request.variables_reference, + ); + insert_option(&mut arguments, "frameId", request.frame_id); + session.send_request( + "dataBreakpointInfo", + Value::Object(arguments), + PendingRequest::DataBreakpointInfo { + operation_id: request.operation_id, + }, + )?; + Ok(session.take_update()) + }) +} + +/// Stores deterministic adapter-resolved data breakpoints and sends them when supported. +pub(crate) fn set_data_breakpoints( + mut request: SetDataBreakpointsRequest, +) -> Result { + for breakpoint in &mut request.breakpoints { + breakpoint.data_id = breakpoint.data_id.trim().to_string(); + if breakpoint.data_id.is_empty() { + return Err(invalid_request( + "Debug data breakpoint identifiers cannot be empty.", + )); + } + breakpoint.label = normalize_optional_text(breakpoint.label.take()); + breakpoint.access_type = normalize_optional_text(breakpoint.access_type.take()); + breakpoint.condition = normalize_optional_text(breakpoint.condition.take()); + breakpoint.hit_condition = normalize_optional_text(breakpoint.hit_condition.take()); + } + request.breakpoints.sort_by(|left, right| { + ( + &left.data_id, + &left.access_type, + left.enabled, + &left.condition, + &left.hit_condition, + ) + .cmp(&( + &right.data_id, + &right.access_type, + right.enabled, + &right.condition, + &right.hit_condition, + )) + }); + request.breakpoints.dedup_by(|left, right| { + left.data_id == right.data_id && left.access_type == right.access_type + }); + with_session(&request.session_id, |session| { + session.data_breakpoints = request.breakpoints; + if session.did_receive_initialized && session.capabilities.supports_data_breakpoints { + session.send_data_breakpoints()?; + } + Ok(session.take_update()) + }) +} + +/// Queues one capability-gated variable mutation while execution is paused. +pub(crate) fn set_variable( + mut request: SetVariableRequest, +) -> Result { + validate_identifier(&request.operation_id, "operationId")?; + if request.variables_reference < 1 { + return Err(invalid_request( + "Debug variablesReference must be positive.", + )); + } + request.name = request.name.trim().to_string(); + if request.name.is_empty() { + return Err(invalid_request( + "Debug setVariable requires a variable name.", + )); + } + with_session(&request.session_id, |session| { + if session.state != DebugSessionState::Paused { + return Err(invalid_request( + "Variable mutation requires a paused debug session.", + )); + } + if !session.capabilities.supports_set_variable { + return Err(invalid_request( + "The debug adapter does not support variable mutation.", + )); + } + session.send_request( + "setVariable", + json!({ + "variablesReference": request.variables_reference, + "name": request.name, + "value": request.value + }), + PendingRequest::SetVariable { + operation_id: request.operation_id, + name: request.name, + }, + )?; + Ok(session.take_update()) + }) +} + +/// Ends one caller-owned operation and ignores any later adapter response. +pub(crate) fn cancel_operation( + request: CancelOperationRequest, +) -> Result { + validate_identifier(&request.operation_id, "operationId")?; + with_session(&request.session_id, |session| { + let pending_sequence = session + .pending_requests + .iter() + .find_map(|(sequence, pending)| { + (pending.operation_id() == Some(request.operation_id.as_str())).then_some(*sequence) + }); + let Some(pending_sequence) = pending_sequence else { + return Ok(session.take_update()); + }; + let pending = session + .pending_requests + .remove(&pending_sequence) + .expect("located pending debug operation should still exist"); + let command = pending.command().to_string(); + let message = match request.reason { + DebugCancellationReason::Cancelled => "Debug operation was cancelled.", + DebugCancellationReason::TimedOut => "Debug operation timed out.", + }; + session.emit(DebugEventBody::OperationFailed { + operation_id: request.operation_id, + command, + code: match request.reason { + DebugCancellationReason::Cancelled => DebugOperationFailureCode::Cancelled, + DebugCancellationReason::TimedOut => DebugOperationFailureCode::TimedOut, + }, + message: message.to_string(), + }); + if matches!(pending, PendingRequest::Launch { .. }) { + session.transition(DebugSessionState::Failed); + } + if session.capabilities.supports_cancel_request { + session.send_request( + "cancel", + json!({"requestId": pending_sequence}), + PendingRequest::Cancel, + )?; + } + Ok(session.take_update()) + }) +} + +/// Queues one continue, pause, or stepping request. +pub(crate) fn execute(request: ExecuteRequest) -> Result { + validate_identifier(&request.operation_id, "operationId")?; + with_session(&request.session_id, |session| { + if !matches!( + session.state, + DebugSessionState::Running | DebugSessionState::Paused + ) { + return Err(invalid_request( + "Execution control requires a running or paused debug session.", + )); + } + if matches!( + request.command, + DebugExecutionCommand::Next + | DebugExecutionCommand::StepIn + | DebugExecutionCommand::StepOut + | DebugExecutionCommand::StepBack + | DebugExecutionCommand::Goto + ) && session.state != DebugSessionState::Paused + { + return Err(invalid_request("Stepping requires a paused debug session.")); + } + if request.command == DebugExecutionCommand::Pause + && session.state != DebugSessionState::Running + { + return Err(invalid_request("Pause requires a running debug session.")); + } + if request.command == DebugExecutionCommand::Continue + && session.state != DebugSessionState::Paused + { + return Err(invalid_request("Continue requires a paused debug session.")); + } + if request.single_thread + && !session + .capabilities + .supports_single_thread_execution_requests + { + return Err(invalid_request( + "The debug adapter does not support single-thread execution control.", + )); + } + if request.command == DebugExecutionCommand::StepBack + && !session.capabilities.supports_step_back + { + return Err(invalid_request( + "The debug adapter does not support stepping backwards.", + )); + } + if request.command == DebugExecutionCommand::Goto + && !session.capabilities.supports_goto_targets_request + { + return Err(invalid_request( + "The debug adapter does not support run to cursor.", + )); + } + if request.command == DebugExecutionCommand::Restart + && !session.capabilities.supports_restart_request + { + return Err(invalid_request( + "The debug adapter does not support restart requests.", + )); + } + if request.command == DebugExecutionCommand::Terminate + && !session.capabilities.supports_terminate_request + { + return Err(invalid_request( + "The debug adapter does not support terminate requests.", + )); + } + if matches!( + request.command, + DebugExecutionCommand::Next + | DebugExecutionCommand::StepIn + | DebugExecutionCommand::StepOut + | DebugExecutionCommand::StepBack + | DebugExecutionCommand::Goto + ) && request.thread_id.is_none() + { + return Err(invalid_request("Stepping requires a selected thread.")); + } + let mut arguments = Map::new(); + if !matches!( + request.command, + DebugExecutionCommand::Restart | DebugExecutionCommand::Terminate + ) { + if let Some(thread_id) = request.thread_id { + arguments.insert("threadId".to_string(), json!(thread_id)); + } + } + if request.command == DebugExecutionCommand::Goto { + insert_option( + &mut arguments, + "targetId", + Some(required_positive(request.target_id, "targetId")?), + ); + } else if request.command == DebugExecutionCommand::StepIn { + if let Some(target_id) = request.target_id { + if target_id < 1 { + return Err(invalid_request("Debug targetId must be positive.")); + } + arguments.insert("targetId".to_string(), json!(target_id)); + } + } else if request.target_id.is_some() { + return Err(invalid_request( + "Debug targetId is only valid for stepIn or goto.", + )); + } + if matches!( + request.command, + DebugExecutionCommand::Continue + | DebugExecutionCommand::Next + | DebugExecutionCommand::StepIn + | DebugExecutionCommand::StepOut + | DebugExecutionCommand::StepBack + | DebugExecutionCommand::Goto + | DebugExecutionCommand::Pause + ) { + arguments.insert( + "singleThread".to_string(), + Value::Bool(request.single_thread), + ); + } + session.send_request( + request.command.command(), + Value::Object(arguments), + PendingRequest::Execute { + operation_id: request.operation_id, + command: request.command, + single_thread: request.single_thread, + }, + )?; + Ok(session.take_update()) + }) +} + +/// Queues one typed thread, stack, scope, variable, or evaluation request. +pub(crate) fn inspect(request: InspectRequest) -> Result { + validate_identifier(&request.operation_id, "operationId")?; + let arguments = inspect_arguments(&request)?; + with_session(&request.session_id, |session| { + if !matches!( + session.state, + DebugSessionState::Running | DebugSessionState::Paused + ) { + return Err(invalid_request( + "Debugger inspection requires a running or paused session.", + )); + } + if request.kind == DebugInspectKind::StepInTargets + && !session.capabilities.supports_step_in_targets_request + { + return Err(invalid_request( + "The debug adapter does not support smart step into.", + )); + } + if request.kind == DebugInspectKind::GotoTargets + && !session.capabilities.supports_goto_targets_request + { + return Err(invalid_request( + "The debug adapter does not support run to cursor.", + )); + } + session.send_request( + request.kind.command(), + Value::Object(arguments), + PendingRequest::Inspect { + operation_id: request.operation_id, + kind: request.kind, + }, + )?; + Ok(session.take_update()) + }) +} + +/// Reduces one transport byte chunk and returns ordered writes and events. +pub(crate) fn receive(request: ReceiveRequest) -> Result { + let bytes = BASE64.decode(request.data_base64).map_err(|error| { + invalid_request("Debug transport dataBase64 was invalid.").with_details(error.to_string()) + })?; + with_session(&request.session_id, |session| { + let messages = match parse_messages(&mut session.read_buffer, &bytes) { + Ok(messages) => messages, + Err(error) => { + session.transition(DebugSessionState::Failed); + return Err(error); + } + }; + for message in messages { + session.handle_message(message)?; + } + Ok(session.take_update()) + }) +} + +/// Begins a graceful DAP disconnect while the host keeps transport ownership. +pub(crate) fn disconnect(request: SessionRequest) -> Result { + with_session(&request.session_id, |session| { + if matches!( + session.state, + DebugSessionState::Terminating | DebugSessionState::Terminated + ) { + return Ok(session.take_update()); + } + session.send_request( + "disconnect", + json!({"restart": false, "terminateDebuggee": true}), + PendingRequest::Disconnect, + )?; + session.transition(DebugSessionState::Terminating); + Ok(session.take_update()) + }) +} + +/// Removes a session after the platform has closed its socket or process. +pub(crate) fn destroy_session(request: SessionRequest) -> Result<(), CoreError> { + let mut sessions = sessions_lock()?; + if sessions.remove(&request.session_id).is_none() { + return Err(session_not_found(&request.session_id)); + } + Ok(()) +} + +impl DebugSession { + fn perform_launch( + &mut self, + operation_id: String, + configuration: DebugLaunchConfiguration, + ) -> Result<(), CoreError> { + let mut arguments = configuration.arguments; + arguments + .entry("name".to_string()) + .or_insert(Value::String(configuration.name)); + arguments + .entry("cwd".to_string()) + .or_insert(Value::String(self.root_path.clone())); + self.transition(DebugSessionState::Launching); + self.send_request( + configuration.request.command(), + Value::Object(arguments), + PendingRequest::Launch { operation_id }, + ) + } + + fn send_breakpoints(&mut self, source_path: &str) -> Result<(), CoreError> { + let requested = self + .breakpoints + .get(source_path) + .cloned() + .unwrap_or_default(); + let active: Vec = requested + .iter() + .filter(|breakpoint| breakpoint.enabled) + .cloned() + .collect(); + let breakpoints: Vec = active + .iter() + .map(|breakpoint| { + let mut value = Map::new(); + value.insert("line".to_string(), json!(breakpoint.line)); + insert_option(&mut value, "column", breakpoint.column); + insert_nonempty(&mut value, "condition", breakpoint.condition.as_deref()); + insert_nonempty( + &mut value, + "hitCondition", + breakpoint.hit_condition.as_deref(), + ); + insert_nonempty(&mut value, "logMessage", breakpoint.log_message.as_deref()); + Value::Object(value) + }) + .collect(); + let source_name = source_path + .rsplit(['/', '\\']) + .next() + .unwrap_or(source_path); + self.send_request( + "setBreakpoints", + json!({ + "source": {"name": source_name, "path": source_path}, + "breakpoints": breakpoints, + "sourceModified": false + }), + PendingRequest::SetBreakpoints { + source_path: source_path.to_string(), + requested: active, + }, + ) + } + + fn send_exception_breakpoints(&mut self) -> Result<(), CoreError> { + let active: Vec<&ExceptionBreakpoint> = self + .exception_breakpoints + .iter() + .filter(|breakpoint| breakpoint.enabled) + .collect(); + let filters: Vec<&str> = active + .iter() + .map(|breakpoint| breakpoint.filter.as_str()) + .collect(); + let filter_options: Vec = if self.capabilities.supports_exception_filter_options { + active + .iter() + .filter_map(|breakpoint| { + breakpoint.condition.as_ref().map(|condition| { + json!({ + "filterId": breakpoint.filter, + "condition": condition + }) + }) + }) + .collect() + } else { + Vec::new() + }; + let mut arguments = Map::new(); + arguments.insert("filters".to_string(), json!(filters)); + if !filter_options.is_empty() { + arguments.insert("filterOptions".to_string(), Value::Array(filter_options)); + } + self.send_request( + "setExceptionBreakpoints", + Value::Object(arguments), + PendingRequest::SetExceptionBreakpoints, + ) + } + + fn send_function_breakpoints(&mut self) -> Result<(), CoreError> { + let requested: Vec = self + .function_breakpoints + .iter() + .filter(|breakpoint| breakpoint.enabled) + .cloned() + .collect(); + let breakpoints: Vec = requested + .iter() + .map(|breakpoint| { + let mut value = Map::new(); + value.insert("name".to_string(), json!(breakpoint.name)); + insert_nonempty(&mut value, "condition", breakpoint.condition.as_deref()); + insert_nonempty( + &mut value, + "hitCondition", + breakpoint.hit_condition.as_deref(), + ); + Value::Object(value) + }) + .collect(); + self.send_request( + "setFunctionBreakpoints", + json!({"breakpoints": breakpoints}), + PendingRequest::SetFunctionBreakpoints { requested }, + ) + } + + fn send_data_breakpoints(&mut self) -> Result<(), CoreError> { + let requested: Vec = self + .data_breakpoints + .iter() + .filter(|breakpoint| breakpoint.enabled) + .cloned() + .collect(); + let breakpoints: Vec = requested + .iter() + .map(|breakpoint| { + let mut value = Map::new(); + value.insert("dataId".to_string(), json!(breakpoint.data_id)); + insert_nonempty(&mut value, "accessType", breakpoint.access_type.as_deref()); + insert_nonempty(&mut value, "condition", breakpoint.condition.as_deref()); + insert_nonempty( + &mut value, + "hitCondition", + breakpoint.hit_condition.as_deref(), + ); + Value::Object(value) + }) + .collect(); + self.send_request( + "setDataBreakpoints", + json!({"breakpoints": breakpoints}), + PendingRequest::SetDataBreakpoints { requested }, + ) + } + + fn send_request( + &mut self, + command: &str, + arguments: Value, + pending: PendingRequest, + ) -> Result<(), CoreError> { + let sequence = self.next_request_sequence; + self.next_request_sequence += 1; + let message = json!({ + "seq": sequence, + "type": "request", + "command": command, + "arguments": arguments + }); + self.outbound_frames.push(frame_message(&message)?); + self.pending_requests.insert(sequence, pending); + Ok(()) + } + + fn send_response( + &mut self, + request_sequence: i64, + command: &str, + success: bool, + message: Option<&str>, + ) -> Result<(), CoreError> { + let sequence = self.next_request_sequence; + self.next_request_sequence += 1; + let mut response = json!({ + "seq": sequence, + "type": "response", + "request_seq": request_sequence, + "success": success, + "command": command + }); + if let Some(message) = message { + response["message"] = Value::String(message.to_string()); + } + self.outbound_frames.push(frame_message(&response)?); + Ok(()) + } + + fn handle_message(&mut self, message: Value) -> Result<(), CoreError> { + match message.get("type").and_then(Value::as_str) { + Some("response") => self.handle_response(&message), + Some("event") => self.handle_event(&message), + Some("request") => self.handle_server_request(&message), + _ => Err(CoreError::new( + ErrorCode::ParseFailed, + "DAP message did not contain a supported type.", + )), + } + } + + fn handle_response(&mut self, message: &Value) -> Result<(), CoreError> { + let request_sequence = required_i64(message, "request_seq")?; + let Some(pending) = self.pending_requests.remove(&request_sequence) else { + return Ok(()); + }; + let success = message + .get("success") + .and_then(Value::as_bool) + .unwrap_or(false); + if !success { + let command = pending.command().to_string(); + let detail = message + .get("message") + .and_then(Value::as_str) + .unwrap_or("The debug adapter rejected the request.") + .to_string(); + if let Some(operation_id) = pending.operation_id() { + self.emit(DebugEventBody::OperationFailed { + operation_id: operation_id.to_string(), + command, + code: DebugOperationFailureCode::AdapterRejected, + message: detail, + }); + } + if matches!( + pending, + PendingRequest::Initialize | PendingRequest::Launch { .. } + ) { + self.transition(DebugSessionState::Failed); + } + return Ok(()); + } + let body = message.get("body").cloned().unwrap_or_else(|| json!({})); + match pending { + PendingRequest::Initialize => { + self.capabilities = parse_capabilities(&body); + if !self.did_configure_exception_breakpoints { + self.exception_breakpoints = self + .capabilities + .exception_breakpoint_filters + .iter() + .map(|filter| ExceptionBreakpoint { + filter: filter.filter.clone(), + enabled: filter.default, + condition: None, + }) + .collect(); + } + self.supports_configuration_done = self.capabilities.supports_configuration_done; + self.emit(DebugEventBody::Capabilities { + capabilities: self.capabilities.clone(), + }); + self.transition(DebugSessionState::Ready); + if let Some((operation_id, configuration)) = self.pending_launch.take() { + self.perform_launch(operation_id, configuration)?; + } + } + PendingRequest::Launch { operation_id } => { + self.transition(DebugSessionState::Running); + self.emit(DebugEventBody::OperationCompleted { + operation_id, + result: DebugOperationResult::Acknowledged { + command: "launch".to_string(), + }, + }); + } + PendingRequest::SetBreakpoints { + source_path, + requested, + } => self.emit_breakpoint_results(&body, &source_path, &requested), + PendingRequest::SetExceptionBreakpoints => {} + PendingRequest::SetFunctionBreakpoints { requested } => { + self.emit_function_breakpoint_results(&body, &requested) + } + PendingRequest::DataBreakpointInfo { operation_id } => { + self.emit(DebugEventBody::OperationCompleted { + operation_id, + result: DebugOperationResult::DataBreakpointInfo { + data_id: string_field(&body, "dataId"), + description: body + .get("description") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + access_types: body + .get("accessTypes") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::to_string) + .collect(), + can_persist: bool_field(&body, "canPersist"), + }, + }); + } + PendingRequest::SetDataBreakpoints { requested } => { + self.emit_data_breakpoint_results(&body, &requested) + } + PendingRequest::SetVariable { operation_id, name } => { + self.emit(DebugEventBody::OperationCompleted { + operation_id, + result: DebugOperationResult::SetVariable { + variable: DebugVariable { + name, + value: required_str(&body, "value")?.to_string(), + r#type: string_field(&body, "type"), + evaluate_name: None, + variables_reference: body + .get("variablesReference") + .and_then(Value::as_i64) + .unwrap_or(0), + }, + }, + }); + } + PendingRequest::Cancel => {} + PendingRequest::ConfigurationDone => {} + PendingRequest::Execute { + operation_id, + command, + single_thread, + } => { + if command != DebugExecutionCommand::Pause + && command != DebugExecutionCommand::Terminate + && !(single_thread && command == DebugExecutionCommand::Continue) + { + self.transition(DebugSessionState::Running); + } + self.emit(DebugEventBody::OperationCompleted { + operation_id, + result: DebugOperationResult::Acknowledged { + command: command.command().to_string(), + }, + }); + } + PendingRequest::Inspect { operation_id, kind } => { + let result = normalize_inspection(kind, &body)?; + self.emit(DebugEventBody::OperationCompleted { + operation_id, + result, + }); + } + PendingRequest::Disconnect => {} + } + Ok(()) + } + + fn handle_event(&mut self, message: &Value) -> Result<(), CoreError> { + let event = required_str(message, "event")?; + let body = message.get("body").cloned().unwrap_or_else(|| json!({})); + match event { + "initialized" => { + self.did_receive_initialized = true; + self.emit(DebugEventBody::Initialized); + self.send_exception_breakpoints()?; + if self.capabilities.supports_function_breakpoints { + self.send_function_breakpoints()?; + } + if self.capabilities.supports_data_breakpoints { + self.send_data_breakpoints()?; + } + let sources: Vec = self.breakpoints.keys().cloned().collect(); + for source in sources { + self.send_breakpoints(&source)?; + } + if self.supports_configuration_done { + self.send_request( + "configurationDone", + json!({}), + PendingRequest::ConfigurationDone, + )?; + } + } + "output" => self.emit(DebugEventBody::Output { + category: body + .get("category") + .and_then(Value::as_str) + .map(str::to_string), + output: body + .get("output") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + }), + "stopped" => { + self.transition(DebugSessionState::Paused); + self.emit(DebugEventBody::Stopped { + reason: body + .get("reason") + .and_then(Value::as_str) + .unwrap_or("pause") + .to_string(), + thread_id: body.get("threadId").and_then(Value::as_i64), + description: body + .get("description") + .and_then(Value::as_str) + .map(str::to_string), + }); + } + "continued" => { + if body + .get("allThreadsContinued") + .and_then(Value::as_bool) + .unwrap_or(true) + { + self.transition(DebugSessionState::Running); + } + self.emit(DebugEventBody::Continued { + thread_id: body.get("threadId").and_then(Value::as_i64), + }); + } + "terminated" => { + self.transition(DebugSessionState::Terminated); + self.emit(DebugEventBody::Terminated { exit_code: None }); + } + "exited" => { + self.transition(DebugSessionState::Terminated); + self.emit(DebugEventBody::Terminated { + exit_code: body.get("exitCode").and_then(Value::as_i64), + }); + } + "breakpoint" => { + if let Some(value) = body.get("breakpoint") { + self.emit(DebugEventBody::Breakpoint { + breakpoint: parse_breakpoint(value, None, None, 0), + }); + } + } + _ => {} + } + Ok(()) + } + + fn handle_server_request(&mut self, message: &Value) -> Result<(), CoreError> { + let request_sequence = required_i64(message, "seq")?; + let command = required_str(message, "command")?; + self.send_response( + request_sequence, + command, + false, + Some("This debug adapter request is not supported by Lithe."), + ) + } + + fn emit_breakpoint_results( + &mut self, + body: &Value, + source_path: &str, + requested: &[SourceBreakpoint], + ) { + let values = body + .get("breakpoints") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + for (index, value) in values.iter().enumerate() { + let fallback = requested.get(index).map(|breakpoint| breakpoint.line); + self.emit(DebugEventBody::Breakpoint { + breakpoint: parse_breakpoint(value, None, Some(source_path), fallback.unwrap_or(0)), + }); + } + } + + fn emit_function_breakpoint_results(&mut self, body: &Value, requested: &[FunctionBreakpoint]) { + let values = body + .get("breakpoints") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + for (index, value) in values.iter().enumerate() { + let function_name = requested + .get(index) + .map(|breakpoint| breakpoint.name.as_str()); + self.emit(DebugEventBody::Breakpoint { + breakpoint: parse_breakpoint(value, function_name, None, 0), + }); + } + } + + fn emit_data_breakpoint_results(&mut self, body: &Value, requested: &[DataBreakpoint]) { + let values = body + .get("breakpoints") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + for (index, value) in values.iter().enumerate() { + let mut breakpoint = parse_breakpoint(value, None, None, 0); + breakpoint.data_id = requested.get(index).map(|item| item.data_id.clone()); + self.emit(DebugEventBody::Breakpoint { breakpoint }); + } + } + + fn transition(&mut self, state: DebugSessionState) { + if self.state == state { + return; + } + self.state = state; + self.emit(DebugEventBody::StateChanged { state }); + } + + fn emit(&mut self, body: DebugEventBody) { + let sequence = self.next_event_sequence; + self.next_event_sequence += 1; + self.events.push(DebugEvent { sequence, body }); + } + + fn take_update(&mut self) -> DebugSessionUpdate { + DebugSessionUpdate { + session_id: self.id.clone(), + state: self.state, + outbound_frames: std::mem::take(&mut self.outbound_frames) + .into_iter() + .map(|frame| BASE64.encode(frame)) + .collect(), + events: std::mem::take(&mut self.events), + } + } +} + +impl PendingRequest { + fn command(&self) -> &'static str { + match self { + Self::Initialize => "initialize", + Self::Launch { .. } => "launch", + Self::SetBreakpoints { .. } => "setBreakpoints", + Self::SetExceptionBreakpoints => "setExceptionBreakpoints", + Self::SetFunctionBreakpoints { .. } => "setFunctionBreakpoints", + Self::DataBreakpointInfo { .. } => "dataBreakpointInfo", + Self::SetDataBreakpoints { .. } => "setDataBreakpoints", + Self::SetVariable { .. } => "setVariable", + Self::Cancel => "cancel", + Self::ConfigurationDone => "configurationDone", + Self::Execute { command, .. } => command.command(), + Self::Inspect { kind, .. } => kind.command(), + Self::Disconnect => "disconnect", + } + } + + fn operation_id(&self) -> Option<&str> { + match self { + Self::Launch { operation_id } + | Self::Execute { operation_id, .. } + | Self::Inspect { operation_id, .. } + | Self::DataBreakpointInfo { operation_id } + | Self::SetVariable { operation_id, .. } => Some(operation_id), + _ => None, + } + } +} + +fn inspect_arguments(request: &InspectRequest) -> Result, CoreError> { + let mut arguments = Map::new(); + match request.kind { + DebugInspectKind::Threads => {} + DebugInspectKind::StackTrace => { + arguments.insert( + "threadId".to_string(), + json!(required_positive(request.thread_id, "threadId")?), + ); + } + DebugInspectKind::Scopes => { + arguments.insert( + "frameId".to_string(), + json!(required_nonnegative(request.frame_id, "frameId")?), + ); + } + DebugInspectKind::Variables => { + arguments.insert( + "variablesReference".to_string(), + json!(required_positive( + request.variables_reference, + "variablesReference" + )?), + ); + } + DebugInspectKind::Evaluate => { + let expression = request.expression.as_deref().unwrap_or_default().trim(); + if expression.is_empty() { + return Err(invalid_request("Debug evaluate requires an expression.")); + } + arguments.insert("expression".to_string(), json!(expression)); + arguments.insert("context".to_string(), json!("watch")); + if let Some(frame_id) = request.frame_id { + if frame_id < 0 { + return Err(invalid_request("Debug frameId cannot be negative.")); + } + arguments.insert("frameId".to_string(), json!(frame_id)); + } + } + DebugInspectKind::StepInTargets => { + arguments.insert( + "frameId".to_string(), + json!(required_nonnegative(request.frame_id, "frameId")?), + ); + } + DebugInspectKind::GotoTargets => { + let source_path = request.source_path.as_deref().unwrap_or_default().trim(); + if source_path.is_empty() { + return Err(invalid_request("Debug gotoTargets requires a source path.")); + } + arguments.insert("source".to_string(), json!({"path": source_path})); + arguments.insert( + "line".to_string(), + json!(required_positive(request.line, "line")?), + ); + if let Some(column) = request.column { + if column < 1 { + return Err(invalid_request("Debug column must be positive.")); + } + arguments.insert("column".to_string(), json!(column)); + } + } + } + Ok(arguments) +} + +fn normalize_inspection( + kind: DebugInspectKind, + body: &Value, +) -> Result { + match kind { + DebugInspectKind::Threads => Ok(DebugOperationResult::Threads { + threads: required_array(body, "threads")? + .iter() + .filter_map(parse_thread) + .collect(), + }), + DebugInspectKind::StackTrace => Ok(DebugOperationResult::StackTrace { + stack_frames: required_array(body, "stackFrames")? + .iter() + .filter_map(parse_stack_frame) + .collect(), + }), + DebugInspectKind::Scopes => Ok(DebugOperationResult::Scopes { + scopes: required_array(body, "scopes")? + .iter() + .filter_map(parse_scope) + .collect(), + }), + DebugInspectKind::Variables => Ok(DebugOperationResult::Variables { + variables: required_array(body, "variables")? + .iter() + .filter_map(parse_variable) + .collect(), + }), + DebugInspectKind::Evaluate => Ok(DebugOperationResult::Evaluate { + variable: DebugVariable { + name: body + .get("evaluateName") + .and_then(Value::as_str) + .unwrap_or("Expression") + .to_string(), + value: required_str(body, "result")?.to_string(), + r#type: string_field(body, "type"), + evaluate_name: string_field(body, "evaluateName"), + variables_reference: body + .get("variablesReference") + .and_then(Value::as_i64) + .unwrap_or(0), + }, + }), + DebugInspectKind::StepInTargets => Ok(DebugOperationResult::StepInTargets { + targets: required_array(body, "targets")? + .iter() + .filter_map(parse_step_in_target) + .collect(), + }), + DebugInspectKind::GotoTargets => Ok(DebugOperationResult::GotoTargets { + targets: required_array(body, "targets")? + .iter() + .filter_map(parse_goto_target) + .collect(), + }), + } +} + +fn parse_step_in_target(value: &Value) -> Option { + Some(DebugStepInTarget { + id: value.get("id")?.as_i64()?, + label: value.get("label")?.as_str()?.to_string(), + line: value.get("line").and_then(Value::as_i64), + column: value.get("column").and_then(Value::as_i64), + end_line: value.get("endLine").and_then(Value::as_i64), + end_column: value.get("endColumn").and_then(Value::as_i64), + }) +} + +fn parse_goto_target(value: &Value) -> Option { + Some(DebugGotoTarget { + id: value.get("id")?.as_i64()?, + label: value.get("label")?.as_str()?.to_string(), + line: value.get("line")?.as_i64()?, + column: value.get("column").and_then(Value::as_i64), + end_line: value.get("endLine").and_then(Value::as_i64), + end_column: value.get("endColumn").and_then(Value::as_i64), + instruction_pointer_reference: string_field(value, "instructionPointerReference"), + }) +} + +fn parse_thread(value: &Value) -> Option { + Some(DebugThread { + id: value.get("id")?.as_i64()?, + name: value.get("name")?.as_str()?.to_string(), + }) +} + +fn parse_stack_frame(value: &Value) -> Option { + Some(DebugStackFrame { + id: value.get("id")?.as_i64()?, + name: value.get("name")?.as_str()?.to_string(), + source_path: value + .get("source") + .and_then(|source| source.get("path")) + .and_then(Value::as_str) + .map(str::to_string), + line: value.get("line").and_then(Value::as_i64).unwrap_or(1), + column: value.get("column").and_then(Value::as_i64).unwrap_or(1), + }) +} + +fn parse_scope(value: &Value) -> Option { + Some(DebugScope { + name: value.get("name")?.as_str()?.to_string(), + variables_reference: value.get("variablesReference")?.as_i64()?, + expensive: value + .get("expensive") + .and_then(Value::as_bool) + .unwrap_or(false), + }) +} + +fn parse_variable(value: &Value) -> Option { + Some(DebugVariable { + name: value.get("name")?.as_str()?.to_string(), + value: value.get("value")?.as_str()?.to_string(), + r#type: string_field(value, "type"), + evaluate_name: string_field(value, "evaluateName"), + variables_reference: value + .get("variablesReference") + .and_then(Value::as_i64) + .unwrap_or(0), + }) +} + +fn parse_breakpoint( + value: &Value, + function_name: Option<&str>, + source_path: Option<&str>, + fallback_line: i64, +) -> DebugBreakpoint { + DebugBreakpoint { + id: value.get("id").and_then(Value::as_i64).unwrap_or(0), + verified: value + .get("verified") + .and_then(Value::as_bool) + .unwrap_or(false), + message: string_field(value, "message"), + function_name: function_name.map(str::to_string), + data_id: None, + source_path: value + .get("source") + .and_then(|source| source.get("path")) + .and_then(Value::as_str) + .or(source_path) + .map(str::to_string), + line: value + .get("line") + .and_then(Value::as_i64) + .or((fallback_line > 0).then_some(fallback_line)), + column: value.get("column").and_then(Value::as_i64), + } +} + +fn parse_capabilities(value: &Value) -> DebugCapabilities { + DebugCapabilities { + supports_configuration_done: bool_field(value, "supportsConfigurationDoneRequest"), + supports_conditional_breakpoints: bool_field(value, "supportsConditionalBreakpoints"), + supports_hit_conditional_breakpoints: bool_field( + value, + "supportsHitConditionalBreakpoints", + ), + supports_log_points: bool_field(value, "supportsLogPoints"), + supports_function_breakpoints: bool_field(value, "supportsFunctionBreakpoints"), + supports_data_breakpoints: bool_field(value, "supportsDataBreakpoints"), + supports_exception_options: bool_field(value, "supportsExceptionOptions"), + supports_exception_filter_options: bool_field(value, "supportsExceptionFilterOptions"), + supports_set_variable: bool_field(value, "supportsSetVariable"), + supports_cancel_request: bool_field(value, "supportsCancelRequest"), + supports_single_thread_execution_requests: bool_field( + value, + "supportsSingleThreadExecutionRequests", + ), + supports_restart_request: bool_field(value, "supportsRestartRequest"), + supports_terminate_request: bool_field(value, "supportsTerminateRequest"), + supports_step_back: bool_field(value, "supportsStepBack"), + supports_step_in_targets_request: bool_field(value, "supportsStepInTargetsRequest"), + supports_goto_targets_request: bool_field(value, "supportsGotoTargetsRequest"), + exception_breakpoint_filters: value + .get("exceptionBreakpointFilters") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(parse_exception_breakpoint_filter) + .collect(), + } +} + +fn parse_exception_breakpoint_filter(value: &Value) -> Option { + let filter = value.get("filter")?.as_str()?.trim(); + let label = value.get("label")?.as_str()?.trim(); + if filter.is_empty() || label.is_empty() { + return None; + } + Some(DebugExceptionBreakpointFilter { + filter: filter.to_string(), + label: label.to_string(), + description: string_field(value, "description"), + default: bool_field(value, "default"), + supports_condition: bool_field(value, "supportsCondition"), + condition_description: string_field(value, "conditionDescription"), + }) +} + +fn with_session( + session_id: &str, + operation: impl FnOnce(&mut DebugSession) -> Result, +) -> Result { + let mut sessions = sessions_lock()?; + let session = sessions + .get_mut(session_id) + .ok_or_else(|| session_not_found(session_id))?; + operation(session) +} + +fn sessions_lock( +) -> Result>, CoreError> { + SESSIONS + .get_or_init(|| Mutex::new(HashMap::new())) + .lock() + .map_err(|_| CoreError::new(ErrorCode::Unknown, "Debug session state is unavailable.")) +} + +fn validate_identifier(value: &str, field: &str) -> Result<(), CoreError> { + if value.trim().is_empty() || value.contains('\0') || value.len() > 512 { + return Err(invalid_request(&format!("Debug {field} was invalid."))); + } + Ok(()) +} + +fn validate_path(value: &str, field: &str) -> Result<(), CoreError> { + if value.trim().is_empty() || value.contains('\0') { + return Err(invalid_request(&format!("Debug {field} was invalid."))); + } + Ok(()) +} + +fn required_positive(value: Option, field: &str) -> Result { + value + .filter(|value| *value > 0) + .ok_or_else(|| invalid_request(&format!("Debug {field} must be positive."))) +} + +fn required_nonnegative(value: Option, field: &str) -> Result { + value + .filter(|value| *value >= 0) + .ok_or_else(|| invalid_request(&format!("Debug {field} cannot be negative."))) +} + +fn required_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, CoreError> { + value.get(field).and_then(Value::as_str).ok_or_else(|| { + CoreError::new( + ErrorCode::ParseFailed, + format!("DAP message did not contain a valid {field}."), + ) + }) +} + +fn required_i64(value: &Value, field: &str) -> Result { + value.get(field).and_then(Value::as_i64).ok_or_else(|| { + CoreError::new( + ErrorCode::ParseFailed, + format!("DAP message did not contain a valid {field}."), + ) + }) +} + +fn required_array<'a>(value: &'a Value, field: &str) -> Result<&'a Vec, CoreError> { + value.get(field).and_then(Value::as_array).ok_or_else(|| { + CoreError::new( + ErrorCode::ParseFailed, + format!("DAP response did not contain a valid {field} array."), + ) + }) +} + +fn string_field(value: &Value, field: &str) -> Option { + value.get(field).and_then(Value::as_str).map(str::to_string) +} + +fn bool_field(value: &Value, field: &str) -> bool { + value.get(field).and_then(Value::as_bool).unwrap_or(false) +} + +fn insert_option(map: &mut Map, key: &str, value: Option) { + if let Some(value) = value { + map.insert(key.to_string(), json!(value)); + } +} + +fn insert_nonempty(map: &mut Map, key: &str, value: Option<&str>) { + if let Some(value) = value.filter(|value| !value.is_empty()) { + map.insert(key.to_string(), Value::String(value.to_string())); + } +} + +fn normalize_optional_text(value: Option) -> Option { + value + .map(|text| text.trim().to_string()) + .filter(|text| !text.is_empty()) +} + +fn invalid_request(message: &str) -> CoreError { + CoreError::new(ErrorCode::InvalidRequest, message) +} + +fn session_not_found(session_id: &str) -> CoreError { + CoreError::new(ErrorCode::InvalidRequest, "Debug session was not found.") + .with_details(session_id.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn request_message(sequence: i64, command: &str, arguments: Value) -> Value { + json!({ + "seq": sequence, + "type": "request", + "command": command, + "arguments": arguments + }) + } + + fn response_message(request_sequence: i64, command: &str, body: Value) -> Value { + json!({ + "seq": 100 + request_sequence, + "type": "response", + "request_seq": request_sequence, + "success": true, + "command": command, + "body": body + }) + } + + fn receive_messages(session_id: &str, messages: Vec) -> DebugSessionUpdate { + let bytes = messages + .into_iter() + .flat_map(|message| frame_message(&message).unwrap()) + .collect::>(); + receive(ReceiveRequest { + session_id: session_id.to_string(), + data_base64: BASE64.encode(bytes), + }) + .unwrap() + } + + fn decode_frame(frame: &str) -> Value { + let bytes = BASE64.decode(frame).unwrap(); + let body_start = bytes + .windows(4) + .position(|value| value == b"\r\n\r\n") + .unwrap() + + 4; + serde_json::from_slice(&bytes[body_start..]).unwrap() + } + + #[test] + fn initialize_launch_breakpoints_and_inspection_are_reduced_in_order() { + let session_id = "debug-engine-flow"; + let created = create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "java".to_string(), + root_path: "/workspace".to_string(), + }) + .unwrap(); + assert_eq!(created.state, DebugSessionState::Initializing); + assert_eq!( + decode_frame(&created.outbound_frames[0])["command"], + "initialize" + ); + + let queued = launch(LaunchRequest { + session_id: session_id.to_string(), + operation_id: "launch-1".to_string(), + configuration: DebugLaunchConfiguration { + name: "Main".to_string(), + request: DebugRequestKind::Launch, + arguments: Map::from_iter([("mainClass".to_string(), json!("example.Main"))]), + }, + }) + .unwrap(); + assert!(queued.outbound_frames.is_empty()); + set_breakpoints(SetBreakpointsRequest { + session_id: session_id.to_string(), + source_path: "/workspace/src/Main.java".to_string(), + breakpoints: vec![ + SourceBreakpoint { + line: 12, + column: None, + enabled: true, + condition: Some("value > 1".to_string()), + hit_condition: Some("3".to_string()), + log_message: Some("value = {value}".to_string()), + }, + SourceBreakpoint { + line: 14, + column: None, + enabled: false, + condition: None, + hit_condition: None, + log_message: None, + }, + ], + }) + .unwrap(); + set_exception_breakpoints(SetExceptionBreakpointsRequest { + session_id: session_id.to_string(), + breakpoints: vec![ + ExceptionBreakpoint { + filter: "uncaught".to_string(), + enabled: false, + condition: None, + }, + ExceptionBreakpoint { + filter: "caught".to_string(), + enabled: true, + condition: Some(" example.CustomException ".to_string()), + }, + ], + }) + .unwrap(); + set_function_breakpoints(SetFunctionBreakpointsRequest { + session_id: session_id.to_string(), + breakpoints: vec![ + FunctionBreakpoint { + name: " example.Main.run ".to_string(), + enabled: true, + condition: Some("ready".to_string()), + hit_condition: Some("2".to_string()), + }, + FunctionBreakpoint { + name: "example.Main.skip".to_string(), + enabled: false, + condition: None, + hit_condition: None, + }, + ], + }) + .unwrap(); + + let initialized = receive_messages( + session_id, + vec![response_message( + 1, + "initialize", + json!({ + "supportsConfigurationDoneRequest": true, + "supportsConditionalBreakpoints": true, + "supportsHitConditionalBreakpoints": true, + "supportsLogPoints": true, + "supportsFunctionBreakpoints": true, + "supportsDataBreakpoints": true, + "supportsSetVariable": true, + "supportsRestartRequest": true, + "supportsExceptionFilterOptions": true, + "exceptionBreakpointFilters": [{ + "filter": "caught", + "label": "Caught Exceptions", + "default": false, + "supportsCondition": true + }] + }), + )], + ); + assert_eq!(initialized.state, DebugSessionState::Launching); + assert!(initialized.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::Capabilities { capabilities } + if capabilities.supports_conditional_breakpoints + && capabilities.supports_hit_conditional_breakpoints + && capabilities.supports_log_points + && capabilities.supports_function_breakpoints + && capabilities.supports_data_breakpoints + && capabilities.supports_set_variable + && capabilities.supports_restart_request + && capabilities.exception_breakpoint_filters.len() == 1 + ))); + assert_eq!( + decode_frame(&initialized.outbound_frames[0])["command"], + "launch" + ); + + let configured = receive_messages( + session_id, + vec![json!({"seq": 102, "type": "event", "event": "initialized"})], + ); + assert_eq!( + decode_frame(&configured.outbound_frames[0])["command"], + "setExceptionBreakpoints" + ); + assert_eq!( + decode_frame(&configured.outbound_frames[1])["command"], + "setFunctionBreakpoints" + ); + assert_eq!( + decode_frame(&configured.outbound_frames[2])["command"], + "setDataBreakpoints" + ); + assert_eq!( + decode_frame(&configured.outbound_frames[3])["command"], + "setBreakpoints" + ); + assert_eq!( + decode_frame(&configured.outbound_frames[4])["command"], + "configurationDone" + ); + assert_eq!( + decode_frame(&configured.outbound_frames[0])["arguments"]["filters"], + json!(["caught"]) + ); + assert_eq!( + decode_frame(&configured.outbound_frames[0])["arguments"]["filterOptions"][0] + ["condition"], + "example.CustomException" + ); + assert_eq!( + decode_frame(&configured.outbound_frames[1])["arguments"]["breakpoints"][0]["name"], + "example.Main.run" + ); + assert_eq!( + decode_frame(&configured.outbound_frames[1])["arguments"]["breakpoints"][0] + ["condition"], + "ready" + ); + assert_eq!( + decode_frame(&configured.outbound_frames[1])["arguments"]["breakpoints"][0] + ["hitCondition"], + "2" + ); + assert_eq!( + decode_frame(&configured.outbound_frames[1])["arguments"]["breakpoints"] + .as_array() + .unwrap() + .len(), + 1 + ); + assert_eq!( + decode_frame(&configured.outbound_frames[3])["arguments"]["breakpoints"][0] + ["condition"], + "value > 1" + ); + assert_eq!( + decode_frame(&configured.outbound_frames[3])["arguments"]["breakpoints"][0] + ["hitCondition"], + "3" + ); + assert_eq!( + decode_frame(&configured.outbound_frames[3])["arguments"]["breakpoints"][0] + ["logMessage"], + "value = {value}" + ); + assert_eq!( + decode_frame(&configured.outbound_frames[3])["arguments"]["breakpoints"] + .as_array() + .unwrap() + .len(), + 1 + ); + + let running = receive_messages( + session_id, + vec![ + response_message(2, "launch", json!({})), + response_message(3, "setExceptionBreakpoints", json!({})), + response_message( + 4, + "setFunctionBreakpoints", + json!({"breakpoints": [{"id": 8, "verified": true}]}), + ), + response_message(5, "setDataBreakpoints", json!({"breakpoints": []})), + response_message( + 6, + "setBreakpoints", + json!({"breakpoints": [{"id": 7, "verified": true, "line": 12}]}), + ), + response_message(7, "configurationDone", json!({})), + ], + ); + assert_eq!(running.state, DebugSessionState::Running); + assert!(running + .events + .iter() + .any(|event| matches!(event.body, DebugEventBody::Breakpoint { .. }))); + assert!(running.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::Breakpoint { breakpoint } + if breakpoint.function_name.as_deref() == Some("example.Main.run") + && breakpoint.verified + ))); + + let inspection = inspect(InspectRequest { + session_id: session_id.to_string(), + operation_id: "threads-1".to_string(), + kind: DebugInspectKind::Threads, + thread_id: None, + frame_id: None, + variables_reference: None, + expression: None, + source_path: None, + line: None, + column: None, + }) + .unwrap(); + assert_eq!( + decode_frame(&inspection.outbound_frames[0])["command"], + "threads" + ); + let completed = receive_messages( + session_id, + vec![response_message( + 8, + "threads", + json!({"threads": [{"id": 1, "name": "main"}]}), + )], + ); + assert!(completed.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::OperationCompleted { operation_id, result: DebugOperationResult::Threads { threads } } + if operation_id == "threads-1" && threads.len() == 1 + ))); + + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); + } + + #[test] + fn data_breakpoint_identity_and_verification_are_correlated() { + let session_id = "debug-data-breakpoint"; + create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "java".to_string(), + root_path: "/workspace".to_string(), + }) + .unwrap(); + set_data_breakpoints(SetDataBreakpointsRequest { + session_id: session_id.to_string(), + breakpoints: vec![ + DataBreakpoint { + data_id: " field:count ".to_string(), + label: Some("count".to_string()), + enabled: true, + access_type: Some(" write ".to_string()), + condition: Some(" count > 1 ".to_string()), + hit_condition: Some(" 2 ".to_string()), + }, + DataBreakpoint { + data_id: "field:ignored".to_string(), + label: None, + enabled: false, + access_type: None, + condition: None, + hit_condition: None, + }, + ], + }) + .unwrap(); + receive_messages( + session_id, + vec![response_message( + 1, + "initialize", + json!({"supportsDataBreakpoints": true}), + )], + ); + let configured = receive_messages( + session_id, + vec![json!({"seq": 102, "type": "event", "event": "initialized"})], + ); + assert_eq!( + decode_frame(&configured.outbound_frames[1])["command"], + "setDataBreakpoints" + ); + let arguments = &decode_frame(&configured.outbound_frames[1])["arguments"]["breakpoints"]; + assert_eq!(arguments.as_array().unwrap().len(), 1); + assert_eq!(arguments[0]["dataId"], "field:count"); + assert_eq!(arguments[0]["accessType"], "write"); + assert_eq!(arguments[0]["condition"], "count > 1"); + assert_eq!(arguments[0]["hitCondition"], "2"); + + let verified = receive_messages( + session_id, + vec![ + response_message(2, "setExceptionBreakpoints", json!({})), + response_message( + 3, + "setDataBreakpoints", + json!({"breakpoints": [{"id": 9, "verified": true}]}), + ), + ], + ); + assert!(verified.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::Breakpoint { breakpoint } + if breakpoint.data_id.as_deref() == Some("field:count") + && breakpoint.verified + ))); + + let info = data_breakpoint_info(DataBreakpointInfoRequest { + session_id: session_id.to_string(), + operation_id: "field-info".to_string(), + name: " count ".to_string(), + variables_reference: Some(42), + frame_id: Some(7), + }) + .unwrap(); + let request = decode_frame(&info.outbound_frames[0]); + assert_eq!(request["command"], "dataBreakpointInfo"); + assert_eq!(request["arguments"]["name"], "count"); + assert_eq!(request["arguments"]["variablesReference"], 42); + assert_eq!(request["arguments"]["frameId"], 7); + let completed = receive_messages( + session_id, + vec![response_message( + 4, + "dataBreakpointInfo", + json!({ + "dataId": "field:count", + "description": "Main.count", + "accessTypes": ["read", "write"], + "canPersist": true + }), + )], + ); + assert!(completed.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::OperationCompleted { + operation_id, + result: DebugOperationResult::DataBreakpointInfo { + data_id, + description, + access_types, + can_persist + } + } if operation_id == "field-info" + && data_id.as_deref() == Some("field:count") + && description == "Main.count" + && access_types == &["read", "write"] + && *can_persist + ))); + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); + } + + #[test] + fn variable_mutation_is_capability_gated_and_correlated() { + let session_id = "debug-set-variable"; + create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "java".to_string(), + root_path: "/workspace".to_string(), + }) + .unwrap(); + launch(LaunchRequest { + session_id: session_id.to_string(), + operation_id: "launch".to_string(), + configuration: DebugLaunchConfiguration { + name: "Main".to_string(), + request: DebugRequestKind::Launch, + arguments: Map::new(), + }, + }) + .unwrap(); + receive_messages( + session_id, + vec![response_message( + 1, + "initialize", + json!({"supportsSetVariable": true}), + )], + ); + receive_messages(session_id, vec![response_message(2, "launch", json!({}))]); + receive_messages( + session_id, + vec![json!({ + "seq": 103, + "type": "event", + "event": "stopped", + "body": {"reason": "breakpoint", "threadId": 11} + })], + ); + + let update = set_variable(SetVariableRequest { + session_id: session_id.to_string(), + operation_id: "set-count".to_string(), + variables_reference: 42, + name: " count ".to_string(), + value: "7".to_string(), + }) + .unwrap(); + let request = decode_frame(&update.outbound_frames[0]); + assert_eq!(request["command"], "setVariable"); + assert_eq!(request["arguments"]["variablesReference"], 42); + assert_eq!(request["arguments"]["name"], "count"); + assert_eq!(request["arguments"]["value"], "7"); + + let completed = receive_messages( + session_id, + vec![response_message( + 3, + "setVariable", + json!({"value": "7", "type": "int", "variablesReference": 0}), + )], + ); + assert!(completed.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::OperationCompleted { + operation_id, + result: DebugOperationResult::SetVariable { variable } + } if operation_id == "set-count" + && variable.name == "count" + && variable.value == "7" + && variable.r#type.as_deref() == Some("int") + ))); + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); + } + + #[test] + fn cancelled_operation_forwards_dap_cancel_and_ignores_late_response() { + let session_id = "debug-cancel-operation"; + create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "java".to_string(), + root_path: "/workspace".to_string(), + }) + .unwrap(); + launch(LaunchRequest { + session_id: session_id.to_string(), + operation_id: "launch".to_string(), + configuration: DebugLaunchConfiguration { + name: "Main".to_string(), + request: DebugRequestKind::Launch, + arguments: Map::new(), + }, + }) + .unwrap(); + receive_messages( + session_id, + vec![response_message( + 1, + "initialize", + json!({ + "supportsCancelRequest": true, + "supportsSingleThreadExecutionRequests": true + }), + )], + ); + receive_messages(session_id, vec![response_message(2, "launch", json!({}))]); + receive_messages( + session_id, + vec![json!({ + "seq": 103, + "type": "event", + "event": "stopped", + "body": {"reason": "breakpoint", "threadId": 11} + })], + ); + let pending = inspect(InspectRequest { + session_id: session_id.to_string(), + operation_id: "threads-timeout".to_string(), + kind: DebugInspectKind::Threads, + thread_id: None, + frame_id: None, + variables_reference: None, + expression: None, + source_path: None, + line: None, + column: None, + }) + .unwrap(); + assert_eq!(decode_frame(&pending.outbound_frames[0])["seq"], 3); + + let cancelled = cancel_operation(CancelOperationRequest { + session_id: session_id.to_string(), + operation_id: "threads-timeout".to_string(), + reason: DebugCancellationReason::TimedOut, + }) + .unwrap(); + assert!(cancelled.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::OperationFailed { operation_id, command, code, message } + if operation_id == "threads-timeout" + && command == "threads" + && *code == DebugOperationFailureCode::TimedOut + && message == "Debug operation timed out." + ))); + let cancel = decode_frame(&cancelled.outbound_frames[0]); + assert_eq!(cancel["command"], "cancel"); + assert_eq!(cancel["arguments"]["requestId"], 3); + + let late = receive_messages( + session_id, + vec![ + response_message(3, "threads", json!({"threads": []})), + response_message(4, "cancel", json!({})), + ], + ); + assert!(!late + .events + .iter() + .any(|event| matches!(event.body, DebugEventBody::OperationCompleted { .. }))); + let resumed = execute(ExecuteRequest { + session_id: session_id.to_string(), + operation_id: "resume-main-thread".to_string(), + command: DebugExecutionCommand::Continue, + thread_id: Some(11), + target_id: None, + single_thread: true, + }) + .unwrap(); + let resumed_request = decode_frame(&resumed.outbound_frames[0]); + assert_eq!(resumed_request["command"], "continue"); + assert_eq!(resumed_request["arguments"]["threadId"], 11); + assert_eq!(resumed_request["arguments"]["singleThread"], true); + let resumed = receive_messages( + session_id, + vec![ + response_message(5, "continue", json!({})), + json!({ + "seq": 108, + "type": "event", + "event": "continued", + "body": {"threadId": 11, "allThreadsContinued": false} + }), + ], + ); + assert_eq!(resumed.state, DebugSessionState::Paused); + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); + } + + #[test] + fn advanced_execution_controls_are_capability_gated_and_correlated() { + let session_id = "debug-advanced-control"; + create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "java".to_string(), + root_path: "/workspace".to_string(), + }) + .unwrap(); + launch(LaunchRequest { + session_id: session_id.to_string(), + operation_id: "launch".to_string(), + configuration: DebugLaunchConfiguration { + name: "Main".to_string(), + request: DebugRequestKind::Launch, + arguments: Map::new(), + }, + }) + .unwrap(); + receive_messages( + session_id, + vec![response_message( + 1, + "initialize", + json!({ + "supportsStepBack": true, + "supportsRestartRequest": true, + "supportsTerminateRequest": true + }), + )], + ); + receive_messages(session_id, vec![response_message(2, "launch", json!({}))]); + receive_messages( + session_id, + vec![json!({ + "seq": 103, + "type": "event", + "event": "stopped", + "body": {"reason": "breakpoint", "threadId": 11} + })], + ); + + let step_back = execute(ExecuteRequest { + session_id: session_id.to_string(), + operation_id: "step-back".to_string(), + command: DebugExecutionCommand::StepBack, + thread_id: Some(11), + target_id: None, + single_thread: false, + }) + .unwrap(); + let request = decode_frame(&step_back.outbound_frames[0]); + assert_eq!(request["command"], "stepBack"); + assert_eq!(request["arguments"]["threadId"], 11); + assert_eq!(request["arguments"]["singleThread"], false); + let stepped = + receive_messages(session_id, vec![response_message(3, "stepBack", json!({}))]); + assert!(stepped.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::OperationCompleted { operation_id, result: DebugOperationResult::Acknowledged { command } } + if operation_id == "step-back" && command == "stepBack" + ))); + + let restart = execute(ExecuteRequest { + session_id: session_id.to_string(), + operation_id: "restart".to_string(), + command: DebugExecutionCommand::Restart, + thread_id: Some(11), + target_id: None, + single_thread: false, + }) + .unwrap(); + let request = decode_frame(&restart.outbound_frames[0]); + assert_eq!(request["command"], "restart"); + assert!(request["arguments"].get("threadId").is_none()); + receive_messages(session_id, vec![response_message(4, "restart", json!({}))]); + + let terminate = execute(ExecuteRequest { + session_id: session_id.to_string(), + operation_id: "terminate".to_string(), + command: DebugExecutionCommand::Terminate, + thread_id: Some(11), + target_id: None, + single_thread: false, + }) + .unwrap(); + let request = decode_frame(&terminate.outbound_frames[0]); + assert_eq!(request["command"], "terminate"); + assert!(request["arguments"].get("threadId").is_none()); + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); + } + + #[test] + fn smart_step_and_goto_targets_are_normalized_before_targeted_execution() { + let session_id = "debug-targeted-control"; + create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "java".to_string(), + root_path: "/workspace".to_string(), + }) + .unwrap(); + launch(LaunchRequest { + session_id: session_id.to_string(), + operation_id: "launch".to_string(), + configuration: DebugLaunchConfiguration { + name: "Main".to_string(), + request: DebugRequestKind::Launch, + arguments: Map::new(), + }, + }) + .unwrap(); + receive_messages( + session_id, + vec![response_message( + 1, + "initialize", + json!({ + "supportsStepInTargetsRequest": true, + "supportsGotoTargetsRequest": true + }), + )], + ); + receive_messages(session_id, vec![response_message(2, "launch", json!({}))]); + receive_messages( + session_id, + vec![json!({ + "seq": 103, + "type": "event", + "event": "stopped", + "body": {"reason": "breakpoint", "threadId": 11} + })], + ); + + let step_targets = inspect(InspectRequest { + session_id: session_id.to_string(), + operation_id: "step-targets".to_string(), + kind: DebugInspectKind::StepInTargets, + thread_id: None, + frame_id: Some(7), + variables_reference: None, + expression: None, + source_path: None, + line: None, + column: None, + }) + .unwrap(); + let request = decode_frame(&step_targets.outbound_frames[0]); + assert_eq!(request["command"], "stepInTargets"); + assert_eq!(request["arguments"]["frameId"], 7); + let step_targets = receive_messages( + session_id, + vec![response_message( + 3, + "stepInTargets", + json!({"targets": [{ + "id": 21, + "label": "service.load()", + "line": 12, + "column": 9, + "endLine": 12, + "endColumn": 23 + }]}), + )], + ); + assert!(step_targets.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::OperationCompleted { + operation_id, + result: DebugOperationResult::StepInTargets { targets } + } if operation_id == "step-targets" + && targets.first().map(|target| target.id) == Some(21) + ))); + let targeted_step = execute(ExecuteRequest { + session_id: session_id.to_string(), + operation_id: "targeted-step".to_string(), + command: DebugExecutionCommand::StepIn, + thread_id: Some(11), + target_id: Some(21), + single_thread: false, + }) + .unwrap(); + assert_eq!( + decode_frame(&targeted_step.outbound_frames[0])["arguments"]["targetId"], + 21 + ); + receive_messages(session_id, vec![response_message(4, "stepIn", json!({}))]); + receive_messages( + session_id, + vec![json!({ + "seq": 105, + "type": "event", + "event": "stopped", + "body": {"reason": "step", "threadId": 11} + })], + ); + + let goto_targets = inspect(InspectRequest { + session_id: session_id.to_string(), + operation_id: "goto-targets".to_string(), + kind: DebugInspectKind::GotoTargets, + thread_id: None, + frame_id: None, + variables_reference: None, + expression: None, + source_path: Some("/workspace/src/Main.java".to_string()), + line: Some(20), + column: Some(5), + }) + .unwrap(); + let request = decode_frame(&goto_targets.outbound_frames[0]); + assert_eq!(request["command"], "gotoTargets"); + assert_eq!( + request["arguments"]["source"]["path"], + "/workspace/src/Main.java" + ); + assert_eq!(request["arguments"]["line"], 20); + let goto_targets = receive_messages( + session_id, + vec![response_message( + 5, + "gotoTargets", + json!({"targets": [{"id": 31, "label": "Main.java:20", "line": 20}]}), + )], + ); + assert!(goto_targets.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::OperationCompleted { + operation_id, + result: DebugOperationResult::GotoTargets { targets } + } if operation_id == "goto-targets" + && targets.first().map(|target| target.id) == Some(31) + ))); + let goto = execute(ExecuteRequest { + session_id: session_id.to_string(), + operation_id: "goto".to_string(), + command: DebugExecutionCommand::Goto, + thread_id: Some(11), + target_id: Some(31), + single_thread: false, + }) + .unwrap(); + let request = decode_frame(&goto.outbound_frames[0]); + assert_eq!(request["command"], "goto"); + assert_eq!(request["arguments"]["targetId"], 31); + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); + } + + #[test] + fn unknown_server_request_gets_an_explicit_failure_response() { + let session_id = "debug-server-request"; + create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "java".to_string(), + root_path: "/workspace".to_string(), + }) + .unwrap(); + + let update = receive_messages( + session_id, + vec![request_message(44, "runInTerminal", json!({}))], + ); + + let response = decode_frame(&update.outbound_frames[0]); + assert_eq!(response["request_seq"], 44); + assert_eq!(response["success"], false); + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); + } +} diff --git a/rust/lithe-core/src/debug/mod.rs b/rust/lithe-core/src/debug/mod.rs new file mode 100644 index 00000000..a3d3370b --- /dev/null +++ b/rust/lithe-core/src/debug/mod.rs @@ -0,0 +1,8 @@ +//! Transport-neutral Debug Adapter Protocol state and normalized debugger models. + +mod engine; +mod protocol; +mod types; + +pub(crate) use engine::*; +pub(crate) use types::*; diff --git a/rust/lithe-core/src/debug/protocol.rs b/rust/lithe-core/src/debug/protocol.rs new file mode 100644 index 00000000..64dd12ad --- /dev/null +++ b/rust/lithe-core/src/debug/protocol.rs @@ -0,0 +1,105 @@ +//! Bounded DAP framing and JSON message helpers independent of native transport. + +use crate::protocol::{CoreError, ErrorCode}; +use serde_json::Value; + +const MAX_HEADER_BYTES: usize = 64 * 1024; +const MAX_MESSAGE_BYTES: usize = 64 * 1024 * 1024; + +pub(crate) fn frame_message(message: &Value) -> Result, CoreError> { + let body = serde_json::to_vec(message).map_err(|error| { + CoreError::new(ErrorCode::ParseFailed, "Could not encode DAP message.") + .with_details(error.to_string()) + })?; + let mut frame = format!("Content-Length: {}\r\n\r\n", body.len()).into_bytes(); + frame.extend(body); + Ok(frame) +} + +pub(crate) fn parse_messages(buffer: &mut Vec, chunk: &[u8]) -> Result, CoreError> { + buffer.extend_from_slice(chunk); + let mut messages = Vec::new(); + loop { + let Some(header_end) = buffer.windows(4).position(|window| window == b"\r\n\r\n") else { + if buffer.len() > MAX_HEADER_BYTES { + return Err(protocol_error("DAP header exceeded the maximum size.")); + } + break; + }; + if header_end > MAX_HEADER_BYTES { + return Err(protocol_error("DAP header exceeded the maximum size.")); + } + let header = String::from_utf8_lossy(&buffer[..header_end]); + let content_length = content_length(&header)?; + if content_length > MAX_MESSAGE_BYTES { + return Err(protocol_error("DAP message exceeded the maximum size.")); + } + let body_start = header_end + 4; + let body_end = body_start + .checked_add(content_length) + .ok_or_else(|| protocol_error("DAP Content-Length overflowed."))?; + if buffer.len() < body_end { + break; + } + let body = &buffer[body_start..body_end]; + let message = serde_json::from_slice(body).map_err(|error| { + protocol_error("DAP message body was not valid JSON.").with_details(error.to_string()) + })?; + messages.push(message); + buffer.drain(..body_end); + } + Ok(messages) +} + +fn content_length(header: &str) -> Result { + let value = header.lines().find_map(|line| { + let (name, value) = line.split_once(':')?; + name.trim() + .eq_ignore_ascii_case("content-length") + .then_some(value.trim()) + }); + value + .ok_or_else(|| protocol_error("DAP frame did not contain Content-Length."))? + .parse::() + .map_err(|error| { + protocol_error("DAP Content-Length was not a valid non-negative integer.") + .with_details(error.to_string()) + }) +} + +fn protocol_error(message: &str) -> CoreError { + CoreError::new(ErrorCode::ParseFailed, message) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn partial_and_consecutive_frames_are_parsed_in_order() { + let first = frame_message(&json!({"type": "event", "event": "initialized"})).unwrap(); + let second = frame_message(&json!({"type": "event", "event": "terminated"})).unwrap(); + let split = first.len() / 2; + let mut buffer = Vec::new(); + assert!(parse_messages(&mut buffer, &first[..split]) + .unwrap() + .is_empty()); + let mut remainder = first[split..].to_vec(); + remainder.extend(second); + + let messages = parse_messages(&mut buffer, &remainder).unwrap(); + + assert_eq!(messages.len(), 2); + assert_eq!(messages[0]["event"], "initialized"); + assert_eq!(messages[1]["event"], "terminated"); + assert!(buffer.is_empty()); + } + + #[test] + fn malformed_content_length_is_rejected() { + let mut buffer = Vec::new(); + let result = parse_messages(&mut buffer, b"Content-Length: nope\r\n\r\n{}"); + assert!(result.is_err()); + } +} diff --git a/rust/lithe-core/src/debug/types.rs b/rust/lithe-core/src/debug/types.rs new file mode 100644 index 00000000..9da4cef4 --- /dev/null +++ b/rust/lithe-core/src/debug/types.rs @@ -0,0 +1,539 @@ +//! Stable requests, updates, events, and inspection results for shared debugging. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +/// Lifecycle state reduced from DAP requests, responses, and events. +pub enum DebugSessionState { + Idle, + Initializing, + Ready, + Launching, + Running, + Paused, + Terminating, + Terminated, + Failed, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Creates a protocol session without opening a socket or starting a process. +pub struct CreateSessionRequest { + pub session_id: String, + pub adapter_id: String, + pub root_path: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Identifies one existing debug session. +pub struct SessionRequest { + pub session_id: String, +} + +#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +/// DAP request used to begin a debuggee session. +pub enum DebugRequestKind { + Launch, + Attach, +} + +impl DebugRequestKind { + pub(crate) fn command(self) -> &'static str { + match self { + Self::Launch => "launch", + Self::Attach => "attach", + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Provider-specific launch arguments wrapped in a language-neutral contract. +pub struct DebugLaunchConfiguration { + pub name: String, + pub request: DebugRequestKind, + #[serde(default)] + pub arguments: serde_json::Map, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Queues launch or attach, waiting for initialization when necessary. +pub struct LaunchRequest { + pub session_id: String, + pub operation_id: String, + pub configuration: DebugLaunchConfiguration, +} + +#[derive(Debug, Clone, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +/// One requested source breakpoint using one-based DAP coordinates. +pub struct SourceBreakpoint { + pub line: i64, + #[serde(default)] + pub column: Option, + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub condition: Option, + #[serde(default)] + pub hit_condition: Option, + #[serde(default)] + pub log_message: Option, +} + +fn default_true() -> bool { + true +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Replaces the complete breakpoint set for one absolute source path. +pub struct SetBreakpointsRequest { + pub session_id: String, + pub source_path: String, + #[serde(default)] + pub breakpoints: Vec, +} + +#[derive(Debug, Clone, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +/// One adapter-defined exception filter and its optional exception condition. +pub struct ExceptionBreakpoint { + pub filter: String, + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub condition: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Replaces the complete exception breakpoint selection for one session. +pub struct SetExceptionBreakpointsRequest { + pub session_id: String, + #[serde(default)] + pub breakpoints: Vec, +} + +#[derive(Debug, Clone, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +/// One named function or method breakpoint understood by the active adapter. +pub struct FunctionBreakpoint { + pub name: String, + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub condition: Option, + #[serde(default)] + pub hit_condition: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Replaces the complete function breakpoint set for one session. +pub struct SetFunctionBreakpointsRequest { + pub session_id: String, + #[serde(default)] + pub breakpoints: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Resolves an adapter-owned data identifier for one visible variable or expression. +pub struct DataBreakpointInfoRequest { + pub session_id: String, + pub operation_id: String, + pub name: String, + #[serde(default)] + pub variables_reference: Option, + #[serde(default)] + pub frame_id: Option, +} + +#[derive(Debug, Clone, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +/// One adapter-resolved field or data breakpoint. +pub struct DataBreakpoint { + pub data_id: String, + #[serde(default)] + pub label: Option, + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub access_type: Option, + #[serde(default)] + pub condition: Option, + #[serde(default)] + pub hit_condition: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Replaces the complete adapter-resolved data breakpoint set for one session. +pub struct SetDataBreakpointsRequest { + pub session_id: String, + #[serde(default)] + pub breakpoints: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Replaces one visible variable value in its adapter-owned parent container. +pub struct SetVariableRequest { + pub session_id: String, + pub operation_id: String, + pub variables_reference: i64, + pub name: String, + pub value: String, +} + +#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +/// Why a native host ended one pending debug operation. +pub enum DebugCancellationReason { + Cancelled, + TimedOut, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Ends one pending operation and optionally forwards DAP cancellation. +pub struct CancelOperationRequest { + pub session_id: String, + pub operation_id: String, + pub reason: DebugCancellationReason, +} + +#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +/// Supported execution controls shared by every DAP provider. +pub enum DebugExecutionCommand { + Continue, + Pause, + Next, + StepIn, + StepOut, + StepBack, + Goto, + Restart, + Terminate, +} + +impl DebugExecutionCommand { + pub(crate) fn command(self) -> &'static str { + match self { + Self::Continue => "continue", + Self::Pause => "pause", + Self::Next => "next", + Self::StepIn => "stepIn", + Self::StepOut => "stepOut", + Self::StepBack => "stepBack", + Self::Goto => "goto", + Self::Restart => "restart", + Self::Terminate => "terminate", + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Queues a control request and correlates its eventual result to an operation. +pub struct ExecuteRequest { + pub session_id: String, + pub operation_id: String, + pub command: DebugExecutionCommand, + #[serde(default)] + pub thread_id: Option, + #[serde(default)] + pub target_id: Option, + #[serde(default)] + pub single_thread: bool, +} + +#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +/// Normalized debugger data requests supported by the shared UI contract. +pub enum DebugInspectKind { + Threads, + StackTrace, + Scopes, + Variables, + Evaluate, + StepInTargets, + GotoTargets, +} + +impl DebugInspectKind { + pub(crate) fn command(self) -> &'static str { + match self { + Self::Threads => "threads", + Self::StackTrace => "stackTrace", + Self::Scopes => "scopes", + Self::Variables => "variables", + Self::Evaluate => "evaluate", + Self::StepInTargets => "stepInTargets", + Self::GotoTargets => "gotoTargets", + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Parameters for one thread, frame, variable, or expression inspection. +pub struct InspectRequest { + pub session_id: String, + pub operation_id: String, + pub kind: DebugInspectKind, + #[serde(default)] + pub thread_id: Option, + #[serde(default)] + pub frame_id: Option, + #[serde(default)] + pub variables_reference: Option, + #[serde(default)] + pub expression: Option, + #[serde(default)] + pub source_path: Option, + #[serde(default)] + pub line: Option, + #[serde(default)] + pub column: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Base64-encoded bytes received from a platform-owned DAP transport. +pub struct ReceiveRequest { + pub session_id: String, + pub data_base64: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Effects produced by one deterministic session reduction. +pub struct DebugSessionUpdate { + pub session_id: String, + pub state: DebugSessionState, + /// Complete framed byte sequences, base64 encoded in send order. + pub outbound_frames: Vec, + pub events: Vec, +} + +#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +/// Adapter abilities negotiated by the DAP initialize response. +pub struct DebugCapabilities { + pub supports_configuration_done: bool, + pub supports_conditional_breakpoints: bool, + pub supports_hit_conditional_breakpoints: bool, + pub supports_log_points: bool, + pub supports_function_breakpoints: bool, + pub supports_data_breakpoints: bool, + pub supports_exception_options: bool, + pub supports_exception_filter_options: bool, + pub supports_set_variable: bool, + pub supports_cancel_request: bool, + pub supports_single_thread_execution_requests: bool, + pub supports_restart_request: bool, + pub supports_terminate_request: bool, + pub supports_step_back: bool, + pub supports_step_in_targets_request: bool, + pub supports_goto_targets_request: bool, + pub exception_breakpoint_filters: Vec, +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +/// One adapter-defined exception category presented by native clients. +pub struct DebugExceptionBreakpointFilter { + pub filter: String, + pub label: String, + pub description: Option, + pub default: bool, + pub supports_condition: bool, + pub condition_description: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Ordered event projected to platform feature models. +pub struct DebugEvent { + pub sequence: u64, + #[serde(flatten)] + pub body: DebugEventBody, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type", rename_all = "camelCase")] +/// Provider-neutral lifecycle, output, breakpoint, and request result events. +pub enum DebugEventBody { + StateChanged { + state: DebugSessionState, + }, + Initialized, + Capabilities { + capabilities: DebugCapabilities, + }, + Output { + category: Option, + output: String, + }, + Stopped { + reason: String, + thread_id: Option, + description: Option, + }, + Continued { + thread_id: Option, + }, + Terminated { + exit_code: Option, + }, + Breakpoint { + breakpoint: DebugBreakpoint, + }, + OperationCompleted { + operation_id: String, + result: DebugOperationResult, + }, + OperationFailed { + operation_id: String, + command: String, + code: DebugOperationFailureCode, + message: String, + }, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +/// Stable reason for a terminal debug operation failure. +pub enum DebugOperationFailureCode { + AdapterRejected, + Cancelled, + TimedOut, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +/// Typed terminal data for one caller-owned debug operation. +pub enum DebugOperationResult { + Acknowledged { + command: String, + }, + Threads { + threads: Vec, + }, + StackTrace { + stack_frames: Vec, + }, + Scopes { + scopes: Vec, + }, + Variables { + variables: Vec, + }, + Evaluate { + variable: DebugVariable, + }, + SetVariable { + variable: DebugVariable, + }, + DataBreakpointInfo { + data_id: Option, + description: String, + access_types: Vec, + can_persist: bool, + }, + StepInTargets { + targets: Vec, + }, + GotoTargets { + targets: Vec, + }, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// One adapter-selected call expression eligible for targeted step-in. +pub struct DebugStepInTarget { + pub id: i64, + pub label: String, + pub line: Option, + pub column: Option, + pub end_line: Option, + pub end_column: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// One executable location returned for a run-to-cursor request. +pub struct DebugGotoTarget { + pub id: i64, + pub label: String, + pub line: i64, + pub column: Option, + pub end_line: Option, + pub end_column: Option, + pub instruction_pointer_reference: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Adapter-verified breakpoint and its optional resolved source location. +pub struct DebugBreakpoint { + pub id: i64, + pub verified: bool, + pub message: Option, + /// Requested function name for a function-breakpoint verification result. + pub function_name: Option, + /// Adapter-owned identity for a data-breakpoint verification result. + pub data_id: Option, + pub source_path: Option, + pub line: Option, + pub column: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// One debuggee thread. +pub struct DebugThread { + pub id: i64, + pub name: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// One stack frame using one-based DAP source coordinates. +pub struct DebugStackFrame { + pub id: i64, + pub name: String, + pub source_path: Option, + pub line: i64, + pub column: i64, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// One variable scope associated with a selected stack frame. +pub struct DebugScope { + pub name: String, + pub variables_reference: i64, + pub expensive: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// A debugger variable or evaluated expression result. +pub struct DebugVariable { + pub name: String, + pub value: String, + pub r#type: Option, + pub evaluate_name: Option, + pub variables_reference: i64, +} diff --git a/rust/lithe-core/src/lib.rs b/rust/lithe-core/src/lib.rs index a70c5669..981fbf6d 100644 --- a/rust/lithe-core/src/lib.rs +++ b/rust/lithe-core/src/lib.rs @@ -3,6 +3,7 @@ use std::path::Path; mod community; +mod debug; mod execution; mod git; mod github; diff --git a/rust/lithe-core/src/lsp/interface/client.rs b/rust/lithe-core/src/lsp/interface/client.rs index da6f60a1..ef8d5634 100644 --- a/rust/lithe-core/src/lsp/interface/client.rs +++ b/rust/lithe-core/src/lsp/interface/client.rs @@ -851,7 +851,7 @@ fn lsp_feature_result_for_method(method: Option<&str>, result: Option<&Value>) - "action": action }) }), - Some("workspace/executeCommand") => Some(json!({ "ok": true })), + Some("workspace/executeCommand") => Some(json!({ "value": result })), Some("textDocument/definition") | Some("textDocument/declaration") | Some("textDocument/typeDefinition") diff --git a/rust/lithe-core/src/lsp/interface/engine.rs b/rust/lithe-core/src/lsp/interface/engine.rs index c8367c55..5630f3ae 100644 --- a/rust/lithe-core/src/lsp/interface/engine.rs +++ b/rust/lithe-core/src/lsp/interface/engine.rs @@ -111,6 +111,9 @@ pub struct JdtlsLaunchResources { pub configuration_directory: String, /// Lombok agent shipped with the selected JDT LS installation. pub lombok_agent_path: String, + /// Optional Java Debug Server bundle loaded lazily by JDT LS. + #[serde(default)] + pub java_debug_bundle_path: Option, } #[derive(Debug, Clone, Serialize)] @@ -725,6 +728,10 @@ impl LspEngine { launcher_jar_path: PathBuf::from(&resources.launcher_jar_path), configuration_directory: PathBuf::from(&resources.configuration_directory), lombok_agent_path: PathBuf::from(&resources.lombok_agent_path), + java_debug_bundle_path: resources + .java_debug_bundle_path + .as_deref() + .map(PathBuf::from), } }), arguments: request.arguments.clone(), @@ -771,6 +778,11 @@ impl LspEngine { initialization_options: adapt_initialization_options( &request.provider_id, request.initialization_options, + request + .jdtls_launch_resources + .as_ref() + .and_then(|resources| resources.java_debug_bundle_path.as_deref()) + .map(Path::new), ), })?; let request_id = (initialize.state.next_request_id - 1).to_string(); @@ -2527,6 +2539,10 @@ fn validate_start_request(request: &StartServerRequest) -> Result<(), CoreError> || !is_valid_process_path(Some(&resources.launcher_jar_path)) || !is_valid_process_path(Some(&resources.configuration_directory)) || !is_valid_process_path(Some(&resources.lombok_agent_path)) + || resources + .java_debug_bundle_path + .as_deref() + .is_some_and(|path| !is_valid_process_path(Some(path))) { return Err(invalid_field("jdtlsLaunchResources/runtimeExecutablePath")); } @@ -4316,6 +4332,10 @@ mod tests { launcher_jar_path: "/opt/lithe/jdtls/plugins/equinox.jar".to_string(), configuration_directory: "/opt/lithe/jdtls/config_mac".to_string(), lombok_agent_path: "/opt/lithe/jdtls/lombok/lombok.jar".to_string(), + java_debug_bundle_path: Some( + "/opt/lithe/jdtls/java-debug/com.microsoft.java.debug.plugin-0.53.1.jar" + .to_string(), + ), }); request.cache_directory = Some(cache.to_string_lossy().into_owned()); engine @@ -5259,10 +5279,16 @@ public class Main { let source_path = source_directory.join("Main.java"); std::fs::write(&source_path, source).expect("smoke source should be written"); - let root_uri = url::Url::from_directory_path(&workspace) + let canonical_workspace = workspace + .canonicalize() + .expect("smoke workspace should canonicalize"); + let canonical_source_path = source_path + .canonicalize() + .expect("smoke source should canonicalize"); + let root_uri = url::Url::from_directory_path(&canonical_workspace) .expect("workspace should convert to a file URI") .to_string(); - let source_uri = url::Url::from_file_path(&source_path) + let source_uri = url::Url::from_file_path(&canonical_source_path) .expect("source should convert to a file URI") .to_string(); let engine = LspEngine::new(); @@ -6025,6 +6051,7 @@ public class Main { launcher_jar_path: "/jdtls/plugins/equinox.jar".to_string(), configuration_directory: "/jdtls/config_mac".to_string(), lombok_agent_path: "/jdtls/lombok/lombok.jar".to_string(), + java_debug_bundle_path: None, }); assert!(validate_start_request(&request).is_err()); diff --git a/rust/lithe-core/src/lsp/languages/jdt.rs b/rust/lithe-core/src/lsp/languages/jdt.rs index f100307c..34d40154 100644 --- a/rust/lithe-core/src/lsp/languages/jdt.rs +++ b/rust/lithe-core/src/lsp/languages/jdt.rs @@ -96,6 +96,8 @@ pub(crate) struct JdtDirectLaunchResources { pub launcher_jar_path: PathBuf, pub configuration_directory: PathBuf, pub lombok_agent_path: PathBuf, + #[serde(default)] + pub java_debug_bundle_path: Option, } #[derive(Debug, Clone, Deserialize, Eq, PartialEq)] @@ -213,6 +215,7 @@ pub(crate) fn adapt_start(context: &JdtStartContext) -> JdtStartAdaptation { pub(crate) fn adapt_initialization_options( provider_id: &str, initialization_options: Option, + java_debug_bundle_path: Option<&Path>, ) -> Option { if !is_java_provider(provider_id) { return initialization_options; @@ -233,6 +236,22 @@ pub(crate) fn adapt_initialization_options( .expect("the extended capabilities were normalized to an object") .insert("classFileContentsSupport".to_string(), Value::Bool(true)); + if let Some(bundle_path) = java_debug_bundle_path { + let bundle = Value::String(bundle_path.to_string_lossy().into_owned()); + let bundles = options + .entry("bundles") + .or_insert_with(|| Value::Array(Vec::new())); + if !bundles.is_array() { + *bundles = Value::Array(Vec::new()); + } + let bundles = bundles + .as_array_mut() + .expect("the Java extension bundles were normalized to an array"); + if !bundles.contains(&bundle) { + bundles.push(bundle); + } + } + Some(Value::Object(options)) } @@ -992,6 +1011,9 @@ mod tests { launcher_jar_path: PathBuf::from("/jdtls/plugins/equinox.jar"), configuration_directory: PathBuf::from("/jdtls/config_mac"), lombok_agent_path: PathBuf::from("/jdtls/lombok/lombok.jar"), + java_debug_bundle_path: Some(PathBuf::from( + "/jdtls/java-debug/com.microsoft.java.debug.plugin-0.53.1.jar", + )), }); context.arguments = vec![ "--stdio".to_string(), @@ -1107,7 +1129,7 @@ mod tests { assert!(initialized_notification("rust").is_none()); assert!(virtual_source_resolve_params("rust", "jdt://contents/A.class").is_none()); assert_eq!( - adapt_initialization_options("rust", Some(json!({ "custom": true }))), + adapt_initialization_options("rust", Some(json!({ "custom": true })), None), Some(json!({ "custom": true })) ); let location = ProviderLocation { @@ -1124,11 +1146,15 @@ mod tests { "JAVA", Some(json!({ "workspace": { "custom": true }, + "bundles": ["/plugins/custom.jar"], "extendedClientCapabilities": { "customCapability": true, "classFileContentsSupport": false } })), + Some(Path::new( + "/jdtls/java-debug/com.microsoft.java.debug.plugin-0.53.1.jar", + )), ) .unwrap(); @@ -1141,6 +1167,13 @@ mod tests { options["extendedClientCapabilities"]["classFileContentsSupport"], true ); + assert_eq!( + options["bundles"], + json!([ + "/plugins/custom.jar", + "/jdtls/java-debug/com.microsoft.java.debug.plugin-0.53.1.jar" + ]) + ); } #[test] diff --git a/rust/lithe-core/src/lsp/tests.rs b/rust/lithe-core/src/lsp/tests.rs index 29deb2b1..c1c6da10 100644 --- a/rust/lithe-core/src/lsp/tests.rs +++ b/rust/lithe-core/src/lsp/tests.rs @@ -1607,12 +1607,12 @@ fn client_core_shapes_feature_responses_for_swift_models() { message: r#"{ "jsonrpc": "2.0", "id": "7", - "result": null + "result": 5005 }"# .to_string(), }) .unwrap(); - assert_eq!(executed.events[0].result.as_ref().unwrap()["ok"], true); + assert_eq!(executed.events[0].result.as_ref().unwrap()["value"], 5005); } #[test] diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index fb3b3e3d..ed7967b2 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -83,6 +83,34 @@ pub enum CoreCommand { MavenDiagnostics, /// Renders and sanitizes shared Markdown (`markdown.render`). MarkdownRender, + /// Creates one transport-neutral Debug Adapter Protocol session (`debug.createSession`). + DebugCreateSession, + /// Queues a launch or attach request for a debug session (`debug.launch`). + DebugLaunch, + /// Replaces breakpoints for one source file (`debug.setBreakpoints`). + DebugSetBreakpoints, + /// Replaces exception filters for one debug session (`debug.setExceptionBreakpoints`). + DebugSetExceptionBreakpoints, + /// Replaces named function breakpoints for one debug session (`debug.setFunctionBreakpoints`). + DebugSetFunctionBreakpoints, + /// Resolves one adapter-owned data breakpoint identity (`debug.dataBreakpointInfo`). + DebugDataBreakpointInfo, + /// Replaces data breakpoints for one debug session (`debug.setDataBreakpoints`). + DebugSetDataBreakpoints, + /// Replaces one visible variable value (`debug.setVariable`). + DebugSetVariable, + /// Cancels or times out one pending debug operation (`debug.cancelOperation`). + DebugCancelOperation, + /// Queues continue, pause, or stepping control (`debug.execute`). + DebugExecute, + /// Queues one normalized debugger inspection request (`debug.inspect`). + DebugInspect, + /// Reduces bytes received from a platform-owned DAP transport (`debug.receive`). + DebugReceive, + /// Begins the DAP disconnect handshake (`debug.disconnect`). + DebugDisconnect, + /// Removes all state for a debug session (`debug.destroySession`). + DebugDestroySession, /// Applies validated UTF-16 LSP text edits (`lsp.applyTextEdits`). LspApplyTextEdits, /// Reduces an LSP snippet to insertion text (`lsp.plainSnippet`). @@ -229,6 +257,20 @@ impl CoreCommand { "maven.scan" => Some(Self::MavenScan), "maven.diagnostics" => Some(Self::MavenDiagnostics), "markdown.render" => Some(Self::MarkdownRender), + "debug.createSession" => Some(Self::DebugCreateSession), + "debug.launch" => Some(Self::DebugLaunch), + "debug.setBreakpoints" => Some(Self::DebugSetBreakpoints), + "debug.setExceptionBreakpoints" => Some(Self::DebugSetExceptionBreakpoints), + "debug.setFunctionBreakpoints" => Some(Self::DebugSetFunctionBreakpoints), + "debug.dataBreakpointInfo" => Some(Self::DebugDataBreakpointInfo), + "debug.setDataBreakpoints" => Some(Self::DebugSetDataBreakpoints), + "debug.setVariable" => Some(Self::DebugSetVariable), + "debug.cancelOperation" => Some(Self::DebugCancelOperation), + "debug.execute" => Some(Self::DebugExecute), + "debug.inspect" => Some(Self::DebugInspect), + "debug.receive" => Some(Self::DebugReceive), + "debug.disconnect" => Some(Self::DebugDisconnect), + "debug.destroySession" => Some(Self::DebugDestroySession), "lsp.applyTextEdits" => Some(Self::LspApplyTextEdits), "lsp.plainSnippet" => Some(Self::LspPlainSnippet), "lsp.builtinCompletions" => Some(Self::LspBuiltinCompletions), @@ -331,4 +373,26 @@ mod tests { fn parses_document_lifecycle_command() { assert!(CoreCommand::parse("document.lifecycle").is_some()); } + + #[test] + fn parses_debug_runtime_commands() { + for command in [ + "debug.createSession", + "debug.launch", + "debug.setBreakpoints", + "debug.setExceptionBreakpoints", + "debug.setFunctionBreakpoints", + "debug.dataBreakpointInfo", + "debug.setDataBreakpoints", + "debug.setVariable", + "debug.cancelOperation", + "debug.execute", + "debug.inspect", + "debug.receive", + "debug.disconnect", + "debug.destroySession", + ] { + assert!(CoreCommand::parse(command).is_some(), "missing {command}"); + } + } } diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index 6b4ad8c6..750eb790 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -482,6 +482,250 @@ fn execute(request: &str) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::DebugCreateSession => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid debug create-session request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::debug::create_session) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Debug session update should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::DebugLaunch => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid debug launch request") + .with_details(error.to_string()) + }) + .and_then(crate::debug::launch) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Debug launch update should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::DebugSetBreakpoints => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid debug set-breakpoints request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::debug::set_breakpoints) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Debug breakpoint update should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::DebugSetExceptionBreakpoints => { + match serde_json::from_value::( + parsed.payload, + ) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid debug set-exception-breakpoints request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::debug::set_exception_breakpoints) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data) + .expect("Debug exception breakpoint update should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::DebugSetFunctionBreakpoints => { + match serde_json::from_value::( + parsed.payload, + ) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid debug set-function-breakpoints request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::debug::set_function_breakpoints) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data) + .expect("Debug function breakpoint update should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::DebugDataBreakpointInfo => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid debug data-breakpoint-info request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::debug::data_breakpoint_info) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data) + .expect("Debug data breakpoint info update should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::DebugSetDataBreakpoints => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid debug set-data-breakpoints request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::debug::set_data_breakpoints) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Debug data breakpoint update should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::DebugSetVariable => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid debug set-variable request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::debug::set_variable) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Debug variable update should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::DebugCancelOperation => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid debug cancel-operation request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::debug::cancel_operation) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Debug cancellation update should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::DebugExecute => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid debug execute request") + .with_details(error.to_string()) + }) + .and_then(crate::debug::execute) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Debug execution update should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::DebugInspect => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid debug inspect request") + .with_details(error.to_string()) + }) + .and_then(crate::debug::inspect) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Debug inspection update should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::DebugReceive => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid debug receive request") + .with_details(error.to_string()) + }) + .and_then(crate::debug::receive) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Debug receive update should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::DebugDisconnect => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid debug disconnect request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::debug::disconnect) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Debug disconnect update should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::DebugDestroySession => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid debug destroy-session request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::debug::destroy_session) + { + Ok(()) => CoreResponse::success(id, json!({"destroyed": true})), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::LspApplyTextEdits => { match serde_json::from_value::(parsed.payload) .map_err(|error| { diff --git a/rust/lithe-core/src/tests/protocol.rs b/rust/lithe-core/src/tests/protocol.rs index fcd09f39..0870fb64 100644 --- a/rust/lithe-core/src/tests/protocol.rs +++ b/rust/lithe-core/src/tests/protocol.rs @@ -1,4 +1,6 @@ use crate::execute_json; +use base64::engine::general_purpose::STANDARD as BASE64; +use base64::Engine as _; use serde_json::Value; #[test] @@ -12,3 +14,48 @@ fn ping_exposes_protocol_version() { assert_eq!(response["data"]["protocolVersion"], 1); assert_eq!(response["data"]["coreVersion"], "0.1.0"); } + +#[test] +fn debug_create_and_destroy_commands_cross_the_json_boundary() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/debug/dap-session-v1.json" + ))) + .expect("debug fixture should be valid JSON"); + let session = &fixture["session"]; + let create_request = serde_json::json!({ + "id": "debug-create", + "command": "debug.createSession", + "payload": session + }); + + let created: Value = serde_json::from_str(&execute_json(&create_request.to_string())) + .expect("debug create response should be JSON"); + + assert_eq!(created["ok"], true); + assert_eq!(created["data"]["state"], fixture["expected"]["createState"]); + let frame = created["data"]["outboundFrames"][0] + .as_str() + .expect("initialize frame should be base64"); + let bytes = BASE64 + .decode(frame) + .expect("initialize frame should decode"); + let body_start = bytes + .windows(4) + .position(|window| window == b"\r\n\r\n") + .expect("initialize frame should have a header") + + 4; + let message: Value = + serde_json::from_slice(&bytes[body_start..]).expect("initialize body should be JSON"); + assert_eq!(message["command"], fixture["expected"]["createCommand"]); + + let destroy_request = serde_json::json!({ + "id": "debug-destroy", + "command": "debug.destroySession", + "payload": {"sessionId": session["sessionId"]} + }); + let destroyed: Value = serde_json::from_str(&execute_json(&destroy_request.to_string())) + .expect("debug destroy response should be JSON"); + assert_eq!(destroyed["ok"], true); + assert_eq!(destroyed["data"]["destroyed"], true); +} diff --git a/scripts/prepare-jdtls.ps1 b/scripts/prepare-jdtls.ps1 index a1d62aaf..d0e460f7 100644 --- a/scripts/prepare-jdtls.ps1 +++ b/scripts/prepare-jdtls.ps1 @@ -27,8 +27,13 @@ $archiveHash = $manifest.archiveSHA256.ToLowerInvariant() $licenseHash = $manifest.licenseSHA256.ToLowerInvariant() $lombokHash = $manifest.lombokSHA256.ToLowerInvariant() $lombokLicenseHash = $manifest.lombokLicenseSHA256.ToLowerInvariant() +$javaDebugArchiveHash = $manifest.javaDebugArchiveSHA256.ToLowerInvariant() +$javaDebugPluginHash = $manifest.javaDebugPluginSHA256.ToLowerInvariant() +$javaDebugLicenseHash = $manifest.javaDebugLicenseSHA256.ToLowerInvariant() $safeVersion = ([string]$manifest.version) -replace '[^A-Za-z0-9._-]', '_' $safeLombokVersion = ([string]$manifest.lombokVersion) -replace '[^A-Za-z0-9._-]', '_' +$safeJavaDebugExtensionVersion = ([string]$manifest.javaDebugExtensionVersion) -replace '[^A-Za-z0-9._-]', '_' +$safeJavaDebugServerVersion = ([string]$manifest.javaDebugServerVersion) -replace '[^A-Za-z0-9._-]', '_' $archive = if ($archiveUsesOverride) { $env:LITHE_JDTLS_ARCHIVE } else { @@ -37,6 +42,11 @@ $archive = if ($archiveUsesOverride) { $license = Join-Path $cache "EPL-2.0-$licenseHash.txt" $lombok = Join-Path $cache "lombok-$safeLombokVersion-$lombokHash.jar" $lombokLicense = Join-Path $cache "lombok-MIT-$safeLombokVersion-$lombokLicenseHash.txt" +# Expand-Archive validates the file extension even though VSIX files are ZIP +# archives, so keep the verified payload under a compatible cache name. +$javaDebugArchive = Join-Path $cache "vscode-java-debug-$safeJavaDebugExtensionVersion-$javaDebugArchiveHash.zip" +$javaDebugLicense = Join-Path $cache "java-debug-EPL-1.0-$safeJavaDebugServerVersion-$javaDebugLicenseHash.txt" +$javaDebugPluginName = "com.microsoft.java.debug.plugin-$safeJavaDebugServerVersion.jar" function Get-FileSHA256 { param([Parameter(Mandatory)][string]$Path) @@ -94,6 +104,8 @@ function Assert-JdtlsOutput { if (-not (Test-Path -LiteralPath (Join-Path $output "bin/jdtls.bat") -PathType Leaf)) { throw "JDTLS batch launcher is missing: $output" } if (-not (Test-Path -LiteralPath (Join-Path $output "lombok/lombok.jar") -PathType Leaf)) { throw "JDTLS Lombok agent is missing: $output" } if (-not (Test-Path -LiteralPath (Join-Path $output "lombok/LICENSE-MIT.txt") -PathType Leaf)) { throw "JDTLS Lombok license is missing: $output" } + if (-not (Test-Path -LiteralPath (Join-Path $output "java-debug/$javaDebugPluginName") -PathType Leaf)) { throw "Java Debug Server plugin is missing: $output" } + if (-not (Test-Path -LiteralPath (Join-Path $output "java-debug/LICENSE-EPL-1.0.txt") -PathType Leaf)) { throw "Java Debug Server license is missing: $output" } # Wrapper scripts remain for external/legacy launch plans. Packaged JDTLS # uses the direct-launch resources validated above. $launcher = Get-Content -Raw -LiteralPath (Join-Path $output "bin/jdtls.ps1") @@ -117,6 +129,8 @@ if ($archiveUsesOverride) { Get-VerifiedDownload -Uri $manifest.licenseURL -ExpectedSHA256 $licenseHash -Destination $license -Description "EPL-2.0 license" Get-VerifiedDownload -Uri $manifest.lombokURL -ExpectedSHA256 $lombokHash -Destination $lombok -Description "Lombok agent" Get-VerifiedDownload -Uri $manifest.lombokLicenseURL -ExpectedSHA256 $lombokLicenseHash -Destination $lombokLicense -Description "Lombok MIT license" +Get-VerifiedDownload -Uri $manifest.javaDebugArchiveURL -ExpectedSHA256 $javaDebugArchiveHash -Destination $javaDebugArchive -Description "Java Debug extension" +Get-VerifiedDownload -Uri $manifest.javaDebugLicenseURL -ExpectedSHA256 $javaDebugLicenseHash -Destination $javaDebugLicense -Description "Java Debug EPL-1.0 license" if (Test-Path -LiteralPath $output) { Remove-Item -Recurse -Force -LiteralPath $output } New-Item -ItemType Directory -Force -Path $output | Out-Null @@ -127,6 +141,23 @@ $lombokOutput = Join-Path $output "lombok" New-Item -ItemType Directory -Force -Path $lombokOutput | Out-Null Copy-Item -LiteralPath $lombok -Destination (Join-Path $lombokOutput "lombok.jar") -Force Copy-Item -LiteralPath $lombokLicense -Destination (Join-Path $lombokOutput "LICENSE-MIT.txt") -Force +$javaDebugOutput = Join-Path $output "java-debug" +New-Item -ItemType Directory -Force -Path $javaDebugOutput | Out-Null +$javaDebugExtraction = Join-Path $cache "java-debug-extract-$PID" +try { + if (Test-Path -LiteralPath $javaDebugExtraction) { Remove-Item -Recurse -Force -LiteralPath $javaDebugExtraction } + Expand-Archive -LiteralPath $javaDebugArchive -DestinationPath $javaDebugExtraction -Force + $javaDebugPlugin = Join-Path $javaDebugExtraction "extension/server/$javaDebugPluginName" + if (-not (Test-Path -LiteralPath $javaDebugPlugin -PathType Leaf)) { throw "Java Debug Server plugin was not found in the verified extension archive" } + $actualJavaDebugPluginHash = Get-FileSHA256 -Path $javaDebugPlugin + if ($actualJavaDebugPluginHash -ne $javaDebugPluginHash) { + throw "Java Debug Server plugin checksum mismatch: expected $javaDebugPluginHash, got $actualJavaDebugPluginHash" + } + Copy-Item -LiteralPath $javaDebugPlugin -Destination (Join-Path $javaDebugOutput $javaDebugPluginName) -Force +} finally { + if (Test-Path -LiteralPath $javaDebugExtraction) { Remove-Item -Recurse -Force -LiteralPath $javaDebugExtraction } +} +Copy-Item -LiteralPath $javaDebugLicense -Destination (Join-Path $javaDebugOutput "LICENSE-EPL-1.0.txt") -Force $windowsLauncher = @' $ErrorActionPreference = "Stop" diff --git a/scripts/prepare-jdtls.sh b/scripts/prepare-jdtls.sh index 91023fc7..e4c6c36c 100755 --- a/scripts/prepare-jdtls.sh +++ b/scripts/prepare-jdtls.sh @@ -19,12 +19,22 @@ lombok_url="$(manifest_value lombokURL)" lombok_sha256="$(manifest_value lombokSHA256)" lombok_license_url="$(manifest_value lombokLicenseURL)" lombok_license_sha256="$(manifest_value lombokLicenseSHA256)" +java_debug_archive_url="$(manifest_value javaDebugArchiveURL)" +java_debug_archive_sha256="$(manifest_value javaDebugArchiveSHA256)" +java_debug_plugin_sha256="$(manifest_value javaDebugPluginSHA256)" +java_debug_license_url="$(manifest_value javaDebugLicenseURL)" +java_debug_license_sha256="$(manifest_value javaDebugLicenseSHA256)" jdtls_version="$(manifest_value version)" lombok_version="$(manifest_value lombokVersion)" +java_debug_extension_version="$(manifest_value javaDebugExtensionVersion)" +java_debug_server_version="$(manifest_value javaDebugServerVersion)" archive_path="${LITHE_JDTLS_ARCHIVE:-$CACHE_DIR/jdtls-$jdtls_version-$archive_sha256.tar.gz}" license_path="$CACHE_DIR/EPL-2.0-$license_sha256.txt" lombok_path="$CACHE_DIR/lombok-$lombok_version-$lombok_sha256.jar" lombok_license_path="$CACHE_DIR/lombok-MIT-$lombok_version-$lombok_license_sha256.txt" +java_debug_archive_path="$CACHE_DIR/vscode-java-debug-$java_debug_extension_version-$java_debug_archive_sha256.vsix" +java_debug_license_path="$CACHE_DIR/java-debug-EPL-1.0-$java_debug_server_version-$java_debug_license_sha256.txt" +java_debug_plugin_name="com.microsoft.java.debug.plugin-$java_debug_server_version.jar" file_sha256() { shasum -a 256 "$1" | awk '{print tolower($1)}' @@ -96,6 +106,8 @@ validate_output() { [[ -f "$OUTPUT_DIR/bin/jdtls.ps1" ]] || { print -u2 -- "JDTLS Windows launcher is missing: $OUTPUT_DIR"; exit 1; } [[ -f "$OUTPUT_DIR/lombok/lombok.jar" ]] || { print -u2 -- "JDTLS Lombok agent is missing: $OUTPUT_DIR"; exit 1; } [[ -f "$OUTPUT_DIR/lombok/LICENSE-MIT.txt" ]] || { print -u2 -- "JDTLS Lombok license is missing: $OUTPUT_DIR"; exit 1; } + [[ -f "$OUTPUT_DIR/java-debug/$java_debug_plugin_name" ]] || { print -u2 -- "Java Debug Server plugin is missing: $OUTPUT_DIR"; exit 1; } + [[ -f "$OUTPUT_DIR/java-debug/LICENSE-EPL-1.0.txt" ]] || { print -u2 -- "Java Debug Server license is missing: $OUTPUT_DIR"; exit 1; } # Wrapper scripts remain available for external/legacy launch plans. The # packaged product launches bundled Java directly with the resources above. grep -Fq -- '-javaagent:' "$OUTPUT_DIR/bin/jdtls" || { print -u2 -- "JDTLS launcher does not load the Lombok agent: $OUTPUT_DIR"; exit 1; } @@ -122,6 +134,8 @@ fi download_verified_file "$license_url" "$license_sha256" "$license_path" "EPL-2.0 license" download_verified_file "$lombok_url" "$lombok_sha256" "$lombok_path" "Lombok agent" download_verified_file "$lombok_license_url" "$lombok_license_sha256" "$lombok_license_path" "Lombok MIT license" +download_verified_file "$java_debug_archive_url" "$java_debug_archive_sha256" "$java_debug_archive_path" "Java Debug extension" +download_verified_file "$java_debug_license_url" "$java_debug_license_sha256" "$java_debug_license_path" "Java Debug EPL-1.0 license" rm -rf "$OUTPUT_DIR" mkdir -p "$OUTPUT_DIR" @@ -131,6 +145,17 @@ cp "$MANIFEST" "$OUTPUT_DIR/manifest.json" mkdir -p "$OUTPUT_DIR/lombok" cp "$lombok_path" "$OUTPUT_DIR/lombok/lombok.jar" cp "$lombok_license_path" "$OUTPUT_DIR/lombok/LICENSE-MIT.txt" +mkdir -p "$OUTPUT_DIR/java-debug" +unzip -p \ + "$java_debug_archive_path" \ + "extension/server/$java_debug_plugin_name" \ + > "$OUTPUT_DIR/java-debug/$java_debug_plugin_name" +actual_java_debug_plugin_sha256="$(file_sha256 "$OUTPUT_DIR/java-debug/$java_debug_plugin_name")" +if [[ "$actual_java_debug_plugin_sha256" != "$java_debug_plugin_sha256" ]]; then + print -u2 -- "Java Debug Server plugin checksum mismatch: expected $java_debug_plugin_sha256, got $actual_java_debug_plugin_sha256" + exit 1 +fi +cp "$java_debug_license_path" "$OUTPUT_DIR/java-debug/LICENSE-EPL-1.0.txt" cat > "$OUTPUT_DIR/bin/jdtls" <<'EOF' #!/bin/zsh diff --git a/scripts/verify-macos-package.sh b/scripts/verify-macos-package.sh index 4c4ba614..f8978b9a 100755 --- a/scripts/verify-macos-package.sh +++ b/scripts/verify-macos-package.sh @@ -4,6 +4,11 @@ set -euo pipefail ROOT_DIR="${0:A:h:h}" cd "$ROOT_DIR" +java_debug_server_version="$( + /usr/bin/plutil -extract javaDebugServerVersion raw -o - third_party/jdtls/manifest.json +)" +java_debug_plugin_name="com.microsoft.java.debug.plugin-$java_debug_server_version.jar" + temporary_directory=$(mktemp -d "${TMPDIR:-/tmp}/lithe-package-verification.XXXXXX") trap 'rm -rf -- "$temporary_directory"' EXIT jdtls_root="$temporary_directory/jdtls" @@ -22,7 +27,8 @@ mkdir -p \ "$jdtls_root/config_mac" \ "$jdtls_root/config_win" \ "$jdtls_root/bin" \ - "$jdtls_root/lombok" + "$jdtls_root/lombok" \ + "$jdtls_root/java-debug" cat > "$jdtls_root/bin/jdtls" <<'LAUNCHER' #!/bin/zsh java_agent_argument="-javaagent:../lombok/lombok.jar" @@ -36,6 +42,8 @@ LAUNCHER : > "$jdtls_root/lombok/lombok.jar" : > "$jdtls_root/lombok/LICENSE-MIT.txt" : > "$jdtls_root/plugins/org.eclipse.equinox.launcher_1.0.0.jar" +: > "$jdtls_root/java-debug/$java_debug_plugin_name" +: > "$jdtls_root/java-debug/LICENSE-EPL-1.0.txt" for missing_configuration in config_mac_arm config_mac; do broken_jdtls_root="$temporary_directory/jdtls-missing-$missing_configuration" @@ -104,6 +112,8 @@ required_resources=( "$app_path/Contents/Resources/LanguageServers/jdtls/config_mac_arm" "$app_path/Contents/Resources/LanguageServers/jdtls/config_mac" "$app_path/Contents/Resources/LanguageServers/jdtls/lombok/lombok.jar" + "$app_path/Contents/Resources/LanguageServers/jdtls/java-debug/$java_debug_plugin_name" + "$app_path/Contents/Resources/LanguageServers/jdtls/java-debug/LICENSE-EPL-1.0.txt" "$app_path/Contents/Resources/LanguageServers/jdk-arm64/bin/java" "$app_path/Contents/Resources/LanguageServers/jdk-arm64/lib" "$app_path/Contents/Resources/LanguageServers/jdk-x86_64/bin/java" diff --git a/shared/contracts/application-boundary.md b/shared/contracts/application-boundary.md index 25b89186..a051bcfa 100644 --- a/shared/contracts/application-boundary.md +++ b/shared/contracts/application-boundary.md @@ -29,8 +29,8 @@ verification scripts are the executable source of boundary checks. | GitHub | remote parsing, trusted request plans, normalized branch comparisons and pull requests/reviews/comments, deterministic ordering, and stable errors | OAuth configuration, HTTPS, browser opening, and operating-system credential storage | | Runtime | Java/Maven requirements, normalized candidates, and effective toolchain references | JDK/Maven probing and executable paths | | Language tooling | provider catalog, local fallback results, complete LSP process/session runtime, capabilities, diagnostics, UTF-16 edits, and normalized feature results | executable/environment discovery and UI provider routing | -| Java/Maven/Spring | deterministic Maven-root selection, project structure, modules and profiles; compiler diagnostic parsing; Java source structure, symbols, code vision, run-configuration detection, Spring configuration/bean/endpoint indexing, and JDTLS adapter policy | JDK/Maven discovery, local dependency-repository selection, Java/Maven child processes, sockets, and JDB transport | -| Run/Debug | versioned configuration documents, three-layer resolution, diagnostics, and platform-neutral launch plans | project file persistence, child processes, sockets, and JDB transport | +| Java/Maven/Spring | deterministic Maven-root selection, project structure, modules and profiles; compiler diagnostic parsing; Java source structure, symbols, code vision, run-configuration detection, Spring configuration/bean/endpoint indexing, and JDTLS/Java Debug adapter policy | JDK/Maven discovery, local dependency-repository selection, Java/Maven child processes, and sockets | +| Run/Debug | versioned configuration documents, three-layer resolution, diagnostics, platform-neutral launch plans, DAP framing/state, breakpoints, threads, stacks, variables, and events | project file persistence, adapter discovery, child processes, sockets, native termination, and UI | | Terminal | input bytes, output bytes, lifecycle | PTY/ConPTY, shell and environment | | Workbench background | versioned source (`none`, bundled slot `01`–`10`, or `custom`) and opacity | UI, image rendering, bundled-resource packaging, local-image access permission and persistence | | Local History | revision metadata, text content, restore result | persistence location and file operations | diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 33096729..26728e9d 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -81,6 +81,20 @@ stable error code and a user-facing message: | `history.delete` | Delete one history entry and its snapshot | | `maven.scan` | Parse a Maven project descriptor and recursively return modules/profiles | | `maven.diagnostics` | Parse stable Maven compiler diagnostics from build output | +| `debug.createSession` | Create a transport-neutral DAP session and return its initialize frame | +| `debug.launch` | Queue a launch or attach request, including during initialization | +| `debug.setBreakpoints` | Replace and deterministically order one source's DAP breakpoints | +| `debug.setExceptionBreakpoints` | Replace and deterministically order one session's exception filters | +| `debug.setFunctionBreakpoints` | Replace and deterministically order one session's named function breakpoints | +| `debug.dataBreakpointInfo` | Resolve an adapter-owned data breakpoint identity for a paused variable or field | +| `debug.setDataBreakpoints` | Replace and deterministically order one session's resolved data breakpoints | +| `debug.setVariable` | Replace one visible variable value in its adapter-owned parent container | +| `debug.cancelOperation` | Cancel or time out one pending operation and ignore its late response | +| `debug.execute` | Submit continue, pause, next, step-in, or step-out control | +| `debug.inspect` | Request normalized threads, frames, scopes, variables, or evaluation | +| `debug.receive` | Reduce base64-encoded bytes received from a platform-owned DAP transport | +| `debug.disconnect` | Begin the DAP disconnect handshake without closing the native transport | +| `debug.destroySession` | Remove a session after the platform closes its native transport | | `lsp.applyTextEdits` | Apply LSP UTF-16 text edits with range validation | | `lsp.plainSnippet` | Convert LSP snippet insert text into plain editor text | | `lsp.builtinCompletions` | Return lightweight current-file identifier completions | @@ -344,6 +358,108 @@ details `invalidRange`. Successful responses return `{ "text": string }`. after removing LSP tab stops and replacing simple placeholder defaults such as `${1:name}` with `name`. +The `debug.*` commands are the shared Debug Adapter Protocol boundary. Rust +owns DAP framing, request sequences, response correlation, initialization and +execution state, deterministic breakpoint sets, and normalized thread, stack, +scope, variable, evaluation, output, stop, continue, and termination events. +Platforms own adapter discovery, JDT LS activation, sockets or process pipes, +native process termination, persistence, and UI rendering. + +`debug.createSession` accepts `{ sessionId, adapterId, rootPath }`. It does not +open a socket or launch a process. It returns a session update in +`initializing` state with an ordered `outboundFrames` array. Each frame is a +complete Content-Length-framed byte sequence encoded as base64. Every Debug +command returns the same update shape: `{ sessionId, state, outboundFrames, +events }`. The platform writes frames in array order and feeds received chunks +back through `debug.receive` as `{ sessionId, dataBase64 }`; partial and +consecutive messages are buffered and reduced in Rust. + +`debug.launch` accepts an `operationId` and a language-neutral configuration +containing `name`, request kind (`launch` or `attach`), and provider arguments. +Launch submitted during initialization is retained until the initialize +response. `debug.setBreakpoints` accepts one-based line and optional column, +enabled state, condition, hit condition, and log message values. Rust sorts and +de-duplicates the complete source set, retains disabled entries without sending +them to the adapter, waits for the DAP `initialized` event, then sends all +sources in deterministic path order followed by `configurationDone` when the +adapter supports it. This allows native products to mute or restore breakpoints +without maintaining a second protocol representation. + +`debug.setExceptionBreakpoints` accepts adapter-defined filter identifiers, +enabled state, and an optional condition. Rust trims, sorts, and de-duplicates +the complete selection, retains disabled filters without sending them, and uses +DAP `filterOptions` only when the adapter negotiated that capability. Before a +native client has configured a selection, Rust adopts the adapter's declared +defaults so the first `initialized` flow sends exception filters before source +breakpoints and `configurationDone`. + +`debug.setFunctionBreakpoints` accepts a method or function name, enabled +state, condition, and hit condition. Rust retains the complete sorted set, +omits disabled entries, and sends DAP `setFunctionBreakpoints` before source +breakpoints only when the adapter negotiated function-breakpoint support. + +Data breakpoints use DAP's required two-step flow. The native client first calls +`debug.dataBreakpointInfo` with the selected variable name plus its parent +`variablesReference` and current frame. Rust Core correlates the response by +`operationId` and returns the adapter-owned `dataId`, display description, +allowed access modes, and `canPersist`. The client then calls +`debug.setDataBreakpoints`; Core keeps the complete deterministic set, omits +disabled entries, and sends access type, condition, and hit count only when the +adapter negotiated data-breakpoint support. Native clients must discard IDs +whose `canPersist` is false when the debug session ends. + +`debug.setVariable` accepts the selected variable's parent `variablesReference`, +name, and replacement text. Core permits mutation only while paused and after +the adapter advertises `supportsSetVariable`, then returns the adapter's +normalized replacement value and optional type through the caller's +`operationId`. + +`debug.execute` covers continue, pause, step over, step in, step out, step back, +restart, terminate, and capability-gated single-thread execution. Rust Core rejects stepping unless the session is paused +and a thread is selected, and gates step back, restart, and terminate against +the adapter capabilities negotiated during initialization. Restart and +terminate are session-level requests and never receive a stale `threadId`. +Single-thread pause, continue, and stepping preserve the paused session when +the adapter reports that other threads remain stopped. + +`debug.cancelOperation` removes the matching pending request before emitting a +terminal failure, so a late adapter response cannot mutate current UI state. If +the adapter advertises `supportsCancelRequest`, Core also sends DAP `cancel` +with the original request sequence. Native hosts own monotonic deadlines and +invoke this command with `cancelled` or `timedOut`; the macOS reference product +uses a bounded 10-second deadline for interactive inspections and mutations. + +Smart step into and run to cursor keep DAP's target lookup explicit. Clients +use `debug.inspect` with `stepInTargets` and a frame, or `gotoTargets` with a +source path and one-based cursor coordinates. Core normalizes the returned +targets and correlates them to the caller's operation. The selected target is +then passed as `targetId` to `debug.execute` using `stepIn` or `goto`; both +flows are rejected unless the adapter advertised the matching capability. + +The successful DAP initialize response emits a normalized `capabilities` event. +It includes conditional, hit-count, log, function, data, and exception +breakpoint support; variable mutation; restart and terminate requests; step +back; request cancellation; single-thread execution; step-in targets; goto +targets; and ordered exception filters. Native UIs +must treat capability state as unknown until this event arrives and hide or +disable unsupported actions after negotiation. + +`debug.execute` correlates continue, pause, next, step-in, and step-out to the +caller's `operationId`. `debug.inspect` supports `threads`, `stackTrace`, +`scopes`, `variables`, and `evaluate`; required thread, frame, variable +reference, and expression fields are validated before a request is emitted. +Terminal operation events are exactly one of `operationCompleted` with a typed +result or `operationFailed` with the adapter command and safe message. Other +ordered events are `stateChanged`, `initialized`, `output`, `stopped`, +`continued`, `terminated`, and `breakpoint`. Source coordinates are one-based. +The compatibility flow is captured in +`shared/fixtures/debug/dap-session-v1.json`. + +`debug.disconnect` emits the protocol handshake and enters `terminating`; the +platform keeps the socket or process alive long enough to flush the frame, +then closes it and calls `debug.destroySession`. A session allocates no process, +socket, timer, or background task, and no session exists until Debug is used. + `lsp.builtinCompletions`, `lsp.builtinHover`, and `lsp.builtinNavigation` are the no-process lightweight language path. They accept current-file text, an absolute `filePath`, and a zero-based LSP position. Completion returns @@ -376,7 +492,8 @@ provider such as JDT LS that has a later readiness signal, progress and `serviceReadyAbsoluteTimeoutMilliseconds` is the final safety cap. The defaults are 45 seconds idle and 10 minutes absolute; duplicate progress does not refresh the idle deadline. `jdtlsLaunchResources`, when present, -contains `launcherJarPath`, `configurationDirectory`, and `lombokAgentPath`; it +contains `launcherJarPath`, `configurationDirectory`, `lombokAgentPath`, and the +optional `javaDebugBundlePath`; it is valid only for the Java provider and requires `runtimeExecutablePath`. Rust then uses `runtimeExecutablePath` as the process executable and constructs the complete deterministic JDT LS JVM argument list. When the structured object is diff --git a/shared/fixtures/debug/dap-session-v1.json b/shared/fixtures/debug/dap-session-v1.json new file mode 100644 index 00000000..1cf6a5bf --- /dev/null +++ b/shared/fixtures/debug/dap-session-v1.json @@ -0,0 +1,139 @@ +{ + "version": 1, + "session": { + "sessionId": "contract-debug-session", + "adapterId": "java", + "rootPath": "/workspace" + }, + "launch": { + "operationId": "launch-main", + "configuration": { + "name": "Main", + "request": "launch", + "arguments": { + "mainClass": "example.Main" + } + } + }, + "breakpoints": { + "sourcePath": "/workspace/src/main/java/example/Main.java", + "values": [ + { + "line": 12, + "enabled": true, + "condition": "value > 1", + "hitCondition": "3", + "logMessage": "value = {value}" + }, + { + "line": 14, + "enabled": false + } + ] + }, + "exceptionBreakpoints": { + "values": [ + { + "filter": "caught", + "enabled": true, + "condition": "example.CustomException" + }, + { + "filter": "uncaught", + "enabled": false + } + ] + }, + "functionBreakpoints": { + "values": [ + { + "name": "example.Main.run", + "enabled": true, + "condition": "ready", + "hitCondition": "2" + }, + { + "name": "example.Main.skip", + "enabled": false + } + ] + }, + "dataBreakpointInfo": { + "operationId": "field-count", + "name": "count", + "variablesReference": 42, + "frameId": 7 + }, + "dataBreakpoints": { + "values": [ + { + "dataId": "field:count", + "label": "Main.count", + "enabled": true, + "accessType": "write", + "condition": "count > 1", + "hitCondition": "2" + } + ] + }, + "setVariable": { + "operationId": "set-count", + "variablesReference": 42, + "name": "count", + "value": "7" + }, + "adapterMessages": { + "initializeResponse": { + "seq": 101, + "type": "response", + "request_seq": 1, + "success": true, + "command": "initialize", + "body": { + "supportsConfigurationDoneRequest": true, + "supportsConditionalBreakpoints": true, + "supportsHitConditionalBreakpoints": true, + "supportsLogPoints": true, + "supportsFunctionBreakpoints": true, + "supportsDataBreakpoints": true, + "supportsSetVariable": true, + "supportsCancelRequest": true, + "supportsSingleThreadExecutionRequests": true, + "supportsRestartRequest": true, + "supportsExceptionFilterOptions": true, + "exceptionBreakpointFilters": [ + { + "filter": "caught", + "label": "Caught Exceptions", + "default": false, + "supportsCondition": true + }, + { + "filter": "uncaught", + "label": "Uncaught Exceptions", + "default": true, + "supportsCondition": false + } + ] + } + }, + "initializedEvent": { + "seq": 102, + "type": "event", + "event": "initialized" + } + }, + "expected": { + "createState": "initializing", + "createCommand": "initialize", + "postInitializeState": "launching", + "postInitializeCommand": "launch", + "configurationCommands": [ + "setExceptionBreakpoints", + "setFunctionBreakpoints", + "setDataBreakpoints", + "setBreakpoints", + "configurationDone" + ] + } +} diff --git a/shared/fixtures/lsp/jdt-direct-launch-v1.json b/shared/fixtures/lsp/jdt-direct-launch-v1.json index 921ea642..4f8581a2 100644 --- a/shared/fixtures/lsp/jdt-direct-launch-v1.json +++ b/shared/fixtures/lsp/jdt-direct-launch-v1.json @@ -16,7 +16,8 @@ "jdtlsLaunchResources": { "launcherJarPath": "/opt/lithe/jdtls/plugins/org.eclipse.equinox.launcher_1.7.0.jar", "configurationDirectory": "/opt/lithe/jdtls/config_mac", - "lombokAgentPath": "/opt/lithe/jdtls/lombok/lombok.jar" + "lombokAgentPath": "/opt/lithe/jdtls/lombok/lombok.jar", + "javaDebugBundlePath": "/opt/lithe/jdtls/java-debug/com.microsoft.java.debug.plugin-0.53.1.jar" }, "cacheDirectory": "/var/cache/lithe/language-servers", "workspaceFingerprint": "build=|modules=|jdtls=1.55.0", diff --git a/third_party/jdtls/manifest.json b/third_party/jdtls/manifest.json index ddb00188..b469ab60 100644 --- a/third_party/jdtls/manifest.json +++ b/third_party/jdtls/manifest.json @@ -9,5 +9,12 @@ "lombokSHA256": "01f7b1a015e33e2b62d5f5f37053306357ab1415fd181fcba7794f5d198c1126", "lombokLicenseURL": "https://raw.githubusercontent.com/projectlombok/lombok/v1.18.46/LICENSE", "lombokLicenseSHA256": "76479448741d7a7a3a97b6afd9ab9699d95621faafc65a1b8e9149342ea00feb", + "javaDebugExtensionVersion": "0.58.1", + "javaDebugServerVersion": "0.53.1", + "javaDebugArchiveURL": "https://open-vsx.org/api/vscjava/vscode-java-debug/0.58.1/file/vscjava.vscode-java-debug-0.58.1.vsix", + "javaDebugArchiveSHA256": "d1edf57a28321afcb1d88ab8c525f1ec4edef837ccd9ce8fe2c7179f2c6a74f7", + "javaDebugPluginSHA256": "daaaa5f63f527dc1e9bfa7bae1aca006b69fb29fa0525d7e284d3420ec7b9c44", + "javaDebugLicenseURL": "https://raw.githubusercontent.com/microsoft/java-debug/0.53.1/LICENSE.txt", + "javaDebugLicenseSHA256": "f494326c16bc95ebb14874ea5fa2c16a963eb36d1f2ab6fe99490073709771c1", "minimumJavaVersion": 17 } From 542880cb5afb7f32282883774a63612662e9ac05 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sat, 29 Aug 2026 20:30:40 +0800 Subject: [PATCH 11/66] feat(debug): reuse selected run configuration --- .../AppModel/AppModel+Development.swift | 6 +++ .../DebugLaunchConfigurationResolver.swift | 26 ++++++++--- macos/Sources/Lithe/Views/Run/RunView.swift | 3 ++ .../Lithe/Views/Workbench/WorkbenchView.swift | 30 +++++++++++++ .../Application/ExecutionFeatureModels.swift | 7 ++- .../RunConfigurationIntegrationTests.swift | 44 +++++++++++++++++++ 6 files changed, 110 insertions(+), 6 deletions(-) diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift index 6381fe86..72055fe4 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift @@ -510,6 +510,12 @@ extension AppModel { showNotification("No language provider is available for this file") return } + // Debug is the second execution mode for the Run selection. Re-apply + // the selection here so its project-scoped Java runtime override is + // active even when the Run panel was never opened in this session. + if let selectedConfiguration = runFeature.selectedConfiguration { + runFeature.select(selectedConfiguration) + } if document.isDirty { do { let previousText = document.savedText diff --git a/macos/Sources/Lithe/Services/Debug/DebugLaunchConfigurationResolver.swift b/macos/Sources/Lithe/Services/Debug/DebugLaunchConfigurationResolver.swift index ed95ce5b..6ddee5f8 100644 --- a/macos/Sources/Lithe/Services/Debug/DebugLaunchConfigurationResolver.swift +++ b/macos/Sources/Lithe/Services/Debug/DebugLaunchConfigurationResolver.swift @@ -112,12 +112,29 @@ struct DebugLaunchConfigurationResolver { target: JavaDebugLaunchTarget, options: (RunConfiguration) -> RunOptions ) -> DebugLaunchConfiguration { + // Debug is a second execution mode for the selected Run configuration. + // Keep every Java configuration eligible here so its JDK, Maven, + // working-directory, VM/program arguments, profiles, and environment + // overrides are carried over unchanged. let configuration = selectedConfiguration.flatMap { selected in - selected.kind.isMavenBacked ? selected : nil + selected.kind.providerID == "java" || selected.kind.isMavenBacked ? selected : nil + } + let runOptions = configuration.map(options) + let mainClass = configuration?.mainClass ?? target.mainClass + let configuredWorkingDirectory = runOptions?.workingDirectoryPath + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let workingDirectory: String + if configuredWorkingDirectory.isEmpty { + workingDirectory = workspaceURL.standardizedFileURL.path + } else { + workingDirectory = URL( + fileURLWithPath: configuredWorkingDirectory, + relativeTo: workspaceURL + ).standardizedFileURL.path } var arguments: [String: ToolingJSONValue] = [ - "mainClass": .string(target.mainClass), - "cwd": .string(workspaceURL.standardizedFileURL.path), + "mainClass": .string(mainClass), + "cwd": .string(workingDirectory), "console": .string("internalConsole") ] if let projectName = target.projectName { @@ -129,8 +146,7 @@ struct DebugLaunchConfigurationResolver { if !target.classPaths.isEmpty { arguments["classPaths"] = .array(target.classPaths.map(ToolingJSONValue.string)) } - if let configuration { - let runOptions = options(configuration) + if let runOptions { let programArguments = RunArgumentParser.parse(runOptions.arguments) if !programArguments.isEmpty { arguments["args"] = .array(programArguments.map(ToolingJSONValue.string)) diff --git a/macos/Sources/Lithe/Views/Run/RunView.swift b/macos/Sources/Lithe/Views/Run/RunView.swift index e5aedac7..87e1085c 100644 --- a/macos/Sources/Lithe/Views/Run/RunView.swift +++ b/macos/Sources/Lithe/Views/Run/RunView.swift @@ -526,6 +526,7 @@ struct RunView: View { onToggle: nil ) { selectedSessionID = nil + model.selectRunConfiguration(.currentFile) } ForEach(RunConfigurationExecution.displayOrder, id: \.self) { execution in @@ -595,12 +596,14 @@ struct RunView: View { if let session, session.isRunning { feature.stopModule(session) } else { + model.selectRunConfiguration(configuration) model.startRunConfiguration(configuration) selectedSessionID = configuration.id } } ) { selectedSessionID = configuration.id + model.selectRunConfiguration(configuration) } } diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift index b0ad619b..63452bac 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift @@ -503,6 +503,8 @@ struct WorkbenchView: View { Spacer(minLength: 22) + debugLaunchButton + backgroundPickerButton } @@ -519,6 +521,34 @@ struct WorkbenchView: View { } } + private var debugLaunchButton: some View { + Button { + model.startDebugging() + } label: { + HStack(spacing: 5) { + LitheIDEAIcon( + resourcePath: "toolwindows/toolWindowDebugger.svg", + size: 16, + fallbackSystemImage: "ladybug.fill" + ) + if let configuration = model.runFeatureIfActive?.selectedConfiguration { + Text(configuration.name) + .font(.system(size: 11.5, weight: .medium)) + .lineLimit(1) + } + } + .padding(.horizontal, 8) + .frame(height: 30) + .litheRowHover(isActive: false, cornerRadius: 6, activeBackground: LitheTheme.subtleSelection) + } + .buttonStyle(.plain) + .lithePointer() + .foregroundStyle(LitheTheme.success) + .help("Debug selected run configuration") + .accessibilityLabel("Debug selected run configuration") + .accessibilityIdentifier("debug-selected-run-configuration") + } + private var backgroundPickerButton: some View { Button { isBackgroundPickerPresented.toggle() diff --git a/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift b/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift index a37f6821..9007a748 100644 --- a/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift +++ b/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift @@ -103,6 +103,12 @@ package final class RunFeatureModel: ObservableObject { service.source(for: configuration) } + /// Applies the same selected configuration side effects used by Run, + /// including project-scoped Java runtime selection, before Debug starts. + package func select(_ configuration: RunConfiguration) { + service.select(configuration) + } + package func serviceURL(for configuration: RunConfiguration) -> URL? { service.serviceURL(for: configuration) } @@ -178,7 +184,6 @@ package final class RunFeatureModel: ObservableObject { isGenerationConfirmationPresented = true } - package func select(_ configuration: RunConfiguration) { service.select(configuration) } @discardableResult package func registerLanguageRunExtension( _ provider: any LanguageRunExtensionProviding, diff --git a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift index cf4655df..57ff9b48 100644 --- a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -307,6 +307,50 @@ struct RunConfigurationIntegrationTests { #expect(configuration.arguments["cwd"] == .string(root.path)) } + @Test + func javaDebugReusesTheSelectedRunConfigurationOptions() throws { + let provider = try #require(LanguageProviderCatalog.standard.provider( + for: URL(fileURLWithPath: "/tmp/java-project/src/main/java/com/acme/Main.java") + )) + let root = URL(fileURLWithPath: "/tmp/java-project", isDirectory: true) + let selected = RunConfiguration( + id: "java-main:service", + name: "Service", + kind: .javaMain, + execution: .application, + modulePath: "service", + mainClass: "com.acme.ConfiguredMain" + ) + let options = RunOptions( + workingDirectoryPath: "service", + vmArguments: "-Xmx1g -Dprofile=dev", + programArguments: "--port 8080", + environment: ["APP_ENV": "dev"] + ) + let resolver = DebugLaunchConfigurationResolver(fileExists: { _ in true }) + let configuration = try resolver.resolve( + provider: provider, + documentURL: root.appendingPathComponent("src/main/java/com/acme/Main.java"), + workspaceURL: root, + configurations: [selected], + selectedConfiguration: selected, + javaTarget: JavaDebugLaunchTarget( + mainClass: "com.acme.ResolvedByJdtls", + projectName: nil, + modulePaths: [], + classPaths: [] + ), + options: { _ in options } + ) + + #expect(configuration.name == "Service") + #expect(configuration.arguments["mainClass"] == .string("com.acme.ConfiguredMain")) + #expect(configuration.arguments["cwd"] == .string(root.appendingPathComponent("service").path)) + #expect(configuration.arguments["vmArgs"] == .array([.string("-Xmx1g"), .string("-Dprofile=dev")])) + #expect(configuration.arguments["args"] == .array([.string("--port"), .string("8080")])) + #expect(configuration.arguments["env"] == .object(["APP_ENV": .string("dev")])) + } + @Test func macToolDiscoveryReportsProjectHomebrewAndXcodeSources() { let root = URL(fileURLWithPath: "/tmp/mac-tool-project", isDirectory: true) From d973cd417ee6369d12dd8ce1de188b7ee974a8a8 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sat, 29 Aug 2026 20:37:32 +0800 Subject: [PATCH 12/66] feat(debug): expose shared run configuration in toolbar --- .../Lithe/Views/Workbench/WorkbenchView.swift | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift index 63452bac..a0eccedd 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift @@ -503,6 +503,7 @@ struct WorkbenchView: View { Spacer(minLength: 22) + debugConfigurationPicker debugLaunchButton backgroundPickerButton @@ -549,6 +550,49 @@ struct WorkbenchView: View { .accessibilityIdentifier("debug-selected-run-configuration") } + private var debugConfigurationPicker: some View { + Menu { + if let runFeature = model.runFeatureIfActive, + !runFeature.configurations.isEmpty { + ForEach(runFeature.configurations) { configuration in + Button { + model.selectRunConfiguration(configuration) + } label: { + HStack { + RunConfigurationIcon(kind: configuration.kind, size: 14) + Text(configuration.name) + if configuration.id == runFeature.selectedConfiguration?.id { + Spacer() + Image(systemName: "checkmark") + } + } + } + } + } else { + Button("Current File") { + model.selectRunConfiguration(.currentFile) + } + } + } label: { + HStack(spacing: 5) { + Image(systemName: "chevron.down") + .font(.system(size: 8, weight: .bold)) + Text(model.runFeatureIfActive?.selectedConfiguration?.name ?? "Current File") + .font(.system(size: 11.5, weight: .medium)) + .lineLimit(1) + } + .foregroundStyle(LitheTheme.primaryText) + .padding(.horizontal, 8) + .frame(maxWidth: 190, minHeight: 30) + .litheRowHover(isActive: false, cornerRadius: 6, activeBackground: LitheTheme.subtleSelection) + } + .menuStyle(.borderlessButton) + .fixedSize(horizontal: true, vertical: false) + .help("Select run configuration for Run or Debug") + .accessibilityLabel("Select run configuration for Run or Debug") + .accessibilityIdentifier("run-configuration-picker") + } + private var backgroundPickerButton: some View { Button { isBackgroundPickerPresented.toggle() From 611c7817866c2a76b892ddbc880340243a81a831 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sat, 29 Aug 2026 20:46:41 +0800 Subject: [PATCH 13/66] feat(debug): navigate editor to stopped frame --- .../AppModel/AppModel+ExecutionModules.swift | 3 ++ .../Lithe/Views/Editor/CodeEditorView.swift | 33 ++++++++++++++++++- .../GenericDebugFeatureModel.swift | 24 ++++++++++++-- 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift index 00a6ff3b..02666e03 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift @@ -58,6 +58,9 @@ extension AppModel { guard let capability = value as? LitheDebugModule.DebugModuleCapability, let genericFeature = capability.genericFeature as? GenericDebugFeatureModel else { return nil } cacheModuleCapability(capability, id: .debugWorkspace, moduleID: .debug) + genericFeature.onStoppedLocation = { [weak self] url, line, column in + self?.openSourceLocation(url: url, line: line, column: column) + } observeModuleFeature(.debug, observation: genericFeature.objectWillChange.sink { [weak self] _ in self?.scheduleObjectWillChangeRelay() }) diff --git a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift index 3622d8e6..eba678ec 100644 --- a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift +++ b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift @@ -601,6 +601,7 @@ struct CodeEditorView: NSViewRepresentable { private var appliedBlameVisible = false private var appliedBlameLines: [GitBlameLine] = [] private var appliedDebugBreakpointLines = Set() + private var appliedCurrentExecutionLine: Int? private var appliedGitMarkers: [GitLineChangeMarker]? private var appliedDiagnostics: [EditorDiagnostic] = [] private var markdownImagePasteMonitor: Any? @@ -1222,12 +1223,19 @@ struct CodeEditorView: NSViewRepresentable { $0.fileURL.standardizedFileURL == url }.map(\.line) let debugBreakpointLines = Set(genericBreakpointLines) + let currentExecutionLine: Int? = { + guard let frame = model.genericDebugFeatureIfActive?.stoppedFrame, + frame.sourceURL?.standardizedFileURL == url else { return nil } + return frame.line + }() if appliedBlameVisible != isBlameVisible || appliedBlameLines != blameLines - || appliedDebugBreakpointLines != debugBreakpointLines { + || appliedDebugBreakpointLines != debugBreakpointLines + || appliedCurrentExecutionLine != currentExecutionLine { appliedBlameVisible = isBlameVisible appliedBlameLines = blameLines appliedDebugBreakpointLines = debugBreakpointLines + appliedCurrentExecutionLine = currentExecutionLine container?.gutterWidthConstraint?.constant = isBlameVisible ? EditorLayoutMetrics.blameMetadataWidth + standardGutterWidth : standardGutterWidth @@ -1237,6 +1245,7 @@ struct CodeEditorView: NSViewRepresentable { gutter?.updateDebugBreakpointLines(debugBreakpointLines) { [weak model] line in model?.toggleDebugBreakpoint(fileURL: url, line: line) } + gutter?.updateCurrentExecutionLine(currentExecutionLine) } } @@ -3104,6 +3113,7 @@ final class LineNumberGutterView: NSView { private var implementationMarkers: [JavaImplementationMarker] = [] private var onSelectImplementation: ((JavaImplementationMarker) -> Void)? private var debugBreakpointLines: Set = [] + private var currentExecutionLine: Int? private var onToggleDebugBreakpoint: ((Int) -> Void)? private var scrollRefreshScheduled = false private var hoveredFoldID: String? @@ -3268,6 +3278,11 @@ final class LineNumberGutterView: NSView { needsDisplay = true } + func updateCurrentExecutionLine(_ line: Int?) { + currentExecutionLine = line.map { max(0, $0 - 1) } + needsDisplay = true + } + func updateGitLineChanges( _ markers: [GitLineChangeMarker], onShow: @escaping (GitLineChangeMarker) -> Void, @@ -3475,6 +3490,9 @@ final class LineNumberGutterView: NSView { palette.currentLine.setFill() NSRect(x: 0, y: y, width: bounds.width, height: lineRect.height).fill() } + if currentExecutionLine == lineNumber - 1 { + drawCurrentExecutionLine(y: y, height: lineRect.height) + } if isBlameVisible, let blame = blameByLine[lineNumber - 1], showsBlameMetadata(line: lineNumber - 1, firstVisibleLine: firstLine) { @@ -3665,6 +3683,19 @@ final class LineNumberGutterView: NSView { ).fill() } + private func drawCurrentExecutionLine(y: CGFloat, height: CGFloat) { + let markerSize: CGFloat = 10 + let centerY = y + height / 2 + let left = editorGutterOriginX + gutterLayout.breakpointRange.lowerBound + 2 + let path = NSBezierPath() + path.move(to: NSPoint(x: left, y: centerY)) + path.line(to: NSPoint(x: left + markerSize, y: centerY - markerSize / 2)) + path.line(to: NSPoint(x: left + markerSize, y: centerY + markerSize / 2)) + path.close() + NSColor(calibratedRed: 0.98, green: 0.72, blue: 0.18, alpha: 1).setFill() + path.fill() + } + private func drawGitLineChange( _ marker: GitLineChangeMarker, y: CGFloat, diff --git a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift index 285230ba..bf1cb01d 100644 --- a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift +++ b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift @@ -96,6 +96,11 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu @Published public private(set) var selectedFrameID: Int? @Published public private(set) var areBreakpointsMuted = false @Published public private(set) var capabilities: DebugAdapterCapabilities = .unknown + @Published public private(set) var stoppedFrame: DebugStackFrame? + + /// Delivers the selected stopped frame to the host editor for source + /// navigation. The Debug module does not own editor presentation. + public var onStoppedLocation: ((URL, Int, Int) -> Void)? private let sessions: DebugAdapterSessionManager private var requestedBreakpointsByFile: [URL: [Int: DebugSourceBreakpoint]] = [:] @@ -155,6 +160,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu capabilities = .unknown selectedThreadID = nil selectedFrameID = nil + stoppedFrame = nil do { if requestedBreakpointsByFile[fileURL.standardizedFileURL] != nil { try sessions.setBreakpoints( @@ -192,6 +198,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu stoppedReason = nil selectedThreadID = nil selectedFrameID = nil + stoppedFrame = nil threads = [] stackFrames = [] scopes = [] @@ -549,7 +556,10 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu case .success(let frames): self?.stackFrames = frames self?.selectedFrameID = frames.first?.id - if let frame = frames.first { self?.selectFrame(frame) } + if let frame = frames.first { + self?.selectFrame(frame) + self?.publishStoppedLocation(frame) + } case .failure(let error): self?.record(error) } } @@ -764,12 +774,16 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu session.requestStackTrace(threadID: threadID) { [weak self] result in if case .success(let frames) = result { self?.stackFrames = frames - if let frame = frames.first { self?.selectFrame(frame) } + if let frame = frames.first { + self?.selectFrame(frame) + self?.publishStoppedLocation(frame) + } } } } case .continued: stoppedReason = nil + stoppedFrame = nil activeSession?.cancelPendingOperations() resetVariableTree() invalidateWatchResults() @@ -799,6 +813,12 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } } + private func publishStoppedLocation(_ frame: DebugStackFrame) { + stoppedFrame = frame + guard let sourceURL = frame.sourceURL else { return } + onStoppedLocation?(sourceURL, frame.line, frame.column) + } + private func reconcileBreakpoints() { let previous = Dictionary(uniqueKeysWithValues: breakpoints.map { ($0.id, $0) }) breakpoints = requestedBreakpointsByFile From 42973845949c8479a886b53f5b3b8819557536e9 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sat, 29 Aug 2026 20:54:20 +0800 Subject: [PATCH 14/66] feat(debug): preflight invalid Java breakpoint lines --- .../AppModel/AppModel+Development.swift | 12 +++ .../DebugBreakpointLocationValidator.swift | 79 +++++++++++++++++++ .../RunConfigurationIntegrationTests.swift | 21 +++++ 3 files changed, 112 insertions(+) create mode 100644 macos/Sources/LitheDebugModule/Application/DebugBreakpointLocationValidator.swift diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift index 72055fe4..55e8a397 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift @@ -1,5 +1,6 @@ import Foundation import LitheCoreContracts +import LitheDebugModule import LitheExecutionModule import LitheModuleAPI @@ -460,6 +461,17 @@ extension AppModel { } func toggleDebugBreakpoint(fileURL: URL, line: Int) { + if languageProviderCatalog.provider(for: fileURL)?.id == "java", + let document = openDocuments.first(where: { + $0.url.standardizedFileURL == fileURL.standardizedFileURL + }), + !DebugBreakpointLocationValidator.isExecutableJavaLine( + source: document.text, + line: line + ) { + showNotification("This line cannot hold a Java breakpoint") + return + } if languageProviderCatalog.provider(for: fileURL)? .capabilities.contains(.debugAdapter) == true { Task { [weak self] in diff --git a/macos/Sources/LitheDebugModule/Application/DebugBreakpointLocationValidator.swift b/macos/Sources/LitheDebugModule/Application/DebugBreakpointLocationValidator.swift new file mode 100644 index 00000000..de53e523 --- /dev/null +++ b/macos/Sources/LitheDebugModule/Application/DebugBreakpointLocationValidator.swift @@ -0,0 +1,79 @@ +import Foundation + +/// Performs a conservative, language-aware preflight before a source +/// breakpoint is sent to a debug adapter. The adapter remains authoritative; +/// this only prevents obviously non-executable Java gutter locations. +public enum DebugBreakpointLocationValidator { + public static func isExecutableJavaLine(source: String, line: Int) -> Bool { + guard line > 0 else { return false } + let lines = source.components(separatedBy: .newlines) + guard line <= lines.count else { return false } + + var inBlockComment = false + for (index, rawLine) in lines.enumerated() { + let code = codeWithoutJavaCommentsAndStrings( + rawLine, + inBlockComment: &inBlockComment + ).trimmingCharacters(in: .whitespacesAndNewlines) + guard index + 1 == line else { continue } + guard !code.isEmpty, + !code.hasPrefix("@"), + !Self.nonExecutableOnlyLines.contains(code) else { return false } + return code.contains(where: { $0.isLetter || $0.isNumber || $0 == "_" }) + } + return false + } + + private static let nonExecutableOnlyLines: Set = [ + "{", "}", "(", ")", "[", "]", ";", ",", ":" + ] + + private static func codeWithoutJavaCommentsAndStrings( + _ line: String, + inBlockComment: inout Bool + ) -> String { + var result = "" + var index = line.startIndex + var inString: Character? + while index < line.endIndex { + let next = line.index(after: index) + let character = line[index] + let following = next < line.endIndex ? line[next] : nil + if inBlockComment { + if character == "*", following == "/" { + inBlockComment = false + index = line.index(after: next) + } else { + index = next + } + continue + } + if let quote = inString { + if character == "\\" { + index = next < line.endIndex ? line.index(after: next) : next + } else if character == quote { + inString = nil + result.append(" ") + index = next + } else { + index = next + } + continue + } + if character == "/", following == "*" { + inBlockComment = true + index = line.index(after: next) + } else if character == "/", following == "/" { + break + } else if character == "\"" || character == "'" { + inString = character + result.append(" ") + index = next + } else { + result.append(character) + index = next + } + } + return result + } +} diff --git a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift index 57ff9b48..2c423fa1 100644 --- a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -9,6 +9,27 @@ import Testing @Suite("Run configuration integration") @MainActor struct RunConfigurationIntegrationTests { + @Test + func javaBreakpointLocationPreflightRejectsNonExecutableLines() { + let source = """ + package demo; + // comment + + public class Main { + /* block comment */ + void run() { + System.out.println("ok"); + } + } + """ + + #expect(!DebugBreakpointLocationValidator.isExecutableJavaLine(source: source, line: 2)) + #expect(!DebugBreakpointLocationValidator.isExecutableJavaLine(source: source, line: 3)) + #expect(!DebugBreakpointLocationValidator.isExecutableJavaLine(source: source, line: 5)) + #expect(DebugBreakpointLocationValidator.isExecutableJavaLine(source: source, line: 7)) + #expect(!DebugBreakpointLocationValidator.isExecutableJavaLine(source: source, line: 8)) + } + @Test func providerCapabilitiesKeepProcessEditorsLanguageNeutral() { let process = RunConfigurationKind.process(provider: "python.script").capabilities From ff5f10efe9c82abfdae37d3cb309bdaf60e42f84 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sat, 29 Aug 2026 20:57:07 +0800 Subject: [PATCH 15/66] feat(debug): show breakpoint verification in gutter --- .../Lithe/Views/Editor/CodeEditorView.swift | 38 ++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift index eba678ec..9d2e8175 100644 --- a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift +++ b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift @@ -601,6 +601,7 @@ struct CodeEditorView: NSViewRepresentable { private var appliedBlameVisible = false private var appliedBlameLines: [GitBlameLine] = [] private var appliedDebugBreakpointLines = Set() + private var appliedDebugBreakpointStates: [Int: Bool] = [:] private var appliedCurrentExecutionLine: Int? private var appliedGitMarkers: [GitLineChangeMarker]? private var appliedDiagnostics: [EditorDiagnostic] = [] @@ -1223,6 +1224,13 @@ struct CodeEditorView: NSViewRepresentable { $0.fileURL.standardizedFileURL == url }.map(\.line) let debugBreakpointLines = Set(genericBreakpointLines) + let debugBreakpointStates = (model.genericDebugFeatureIfActive?.breakpoints ?? []) + .filter { $0.fileURL.standardizedFileURL == url } + .reduce(into: [Int: Bool]()) { states, breakpoint in + // A source line can carry multiple column breakpoints; + // show it as confirmed when any adapter location is confirmed. + states[breakpoint.line] = states[breakpoint.line] == true || breakpoint.verified + } let currentExecutionLine: Int? = { guard let frame = model.genericDebugFeatureIfActive?.stoppedFrame, frame.sourceURL?.standardizedFileURL == url else { return nil } @@ -1231,10 +1239,12 @@ struct CodeEditorView: NSViewRepresentable { if appliedBlameVisible != isBlameVisible || appliedBlameLines != blameLines || appliedDebugBreakpointLines != debugBreakpointLines + || appliedDebugBreakpointStates != debugBreakpointStates || appliedCurrentExecutionLine != currentExecutionLine { appliedBlameVisible = isBlameVisible appliedBlameLines = blameLines appliedDebugBreakpointLines = debugBreakpointLines + appliedDebugBreakpointStates = debugBreakpointStates appliedCurrentExecutionLine = currentExecutionLine container?.gutterWidthConstraint?.constant = isBlameVisible ? EditorLayoutMetrics.blameMetadataWidth + standardGutterWidth @@ -1242,7 +1252,7 @@ struct CodeEditorView: NSViewRepresentable { gutter?.update(blameLines: blameLines, isVisible: isBlameVisible) { [weak model] blame in Task { await model?.showGitCommit(blame.commitHash) } } - gutter?.updateDebugBreakpointLines(debugBreakpointLines) { [weak model] line in + gutter?.updateDebugBreakpointLines(debugBreakpointStates) { [weak model] line in model?.toggleDebugBreakpoint(fileURL: url, line: line) } gutter?.updateCurrentExecutionLine(currentExecutionLine) @@ -3113,6 +3123,7 @@ final class LineNumberGutterView: NSView { private var implementationMarkers: [JavaImplementationMarker] = [] private var onSelectImplementation: ((JavaImplementationMarker) -> Void)? private var debugBreakpointLines: Set = [] + private var debugBreakpointVerifiedByLine: [Int: Bool] = [:] private var currentExecutionLine: Int? private var onToggleDebugBreakpoint: ((Int) -> Void)? private var scrollRefreshScheduled = false @@ -3270,10 +3281,13 @@ final class LineNumberGutterView: NSView { } func updateDebugBreakpointLines( - _ lines: Set, + _ states: [Int: Bool], onToggle: @escaping (Int) -> Void ) { - debugBreakpointLines = Set(lines.map { max(0, $0 - 1) }) + debugBreakpointLines = Set(states.keys.map { max(0, $0 - 1) }) + debugBreakpointVerifiedByLine = Dictionary( + uniqueKeysWithValues: states.map { (max(0, $0.key - 1), $0.value) } + ) onToggleDebugBreakpoint = onToggle needsDisplay = true } @@ -3498,8 +3512,8 @@ final class LineNumberGutterView: NSView { showsBlameMetadata(line: lineNumber - 1, firstVisibleLine: firstLine) { drawBlame(blame, y: y, height: lineRect.height) } - if !isBlameVisible, debugBreakpointLines.contains(lineNumber - 1) { - drawDebugBreakpoint(y: y, height: lineRect.height) + if !isBlameVisible, let verified = debugBreakpointVerifiedByLine[lineNumber - 1] { + drawDebugBreakpoint(y: y, height: lineRect.height, verified: verified) } else { let markers = implementationMarkers.filter { $0.line == lineNumber - 1 } for marker in markers { @@ -3669,10 +3683,9 @@ final class LineNumberGutterView: NSView { path.stroke() } - private func drawDebugBreakpoint(y: CGFloat, height: CGFloat) { + private func drawDebugBreakpoint(y: CGFloat, height: CGFloat, verified: Bool) { let markerSize: CGFloat = 9 - NSColor(red: 0.92, green: 0.28, blue: 0.30, alpha: 0.96).setFill() - NSBezierPath( + let path = NSBezierPath( ovalIn: NSRect( x: editorGutterOriginX + gutterLayout.breakpointRange.lowerBound + (EditorGutterLayout.width(of: gutterLayout.breakpointRange) - markerSize) / 2, @@ -3680,7 +3693,14 @@ final class LineNumberGutterView: NSView { width: markerSize, height: markerSize ) - ).fill() + ) + NSColor(red: 0.92, green: 0.28, blue: 0.30, alpha: 0.96).setStroke() + path.lineWidth = 1.5 + if verified { + path.fill() + } else { + path.stroke() + } } private func drawCurrentExecutionLine(y: CGFloat, height: CGFloat) { From 10adf8fca5db87947890ec257667c4df91b7cfde Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sat, 29 Aug 2026 21:01:56 +0800 Subject: [PATCH 16/66] feat(debug): add breakpoint hover status --- .../Lithe/Views/Editor/CodeEditorView.swift | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift index 9d2e8175..f6309af6 100644 --- a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift +++ b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift @@ -602,6 +602,7 @@ struct CodeEditorView: NSViewRepresentable { private var appliedBlameLines: [GitBlameLine] = [] private var appliedDebugBreakpointLines = Set() private var appliedDebugBreakpointStates: [Int: Bool] = [:] + private var appliedDebugBreakpointMessages: [Int: String] = [:] private var appliedCurrentExecutionLine: Int? private var appliedGitMarkers: [GitLineChangeMarker]? private var appliedDiagnostics: [EditorDiagnostic] = [] @@ -1231,6 +1232,14 @@ struct CodeEditorView: NSViewRepresentable { // show it as confirmed when any adapter location is confirmed. states[breakpoint.line] = states[breakpoint.line] == true || breakpoint.verified } + let debugBreakpointMessages = Dictionary( + model.genericDebugFeatureIfActive?.breakpoints + .filter { $0.fileURL.standardizedFileURL == url } + .compactMap { breakpoint in + breakpoint.message.map { (breakpoint.line, $0) } + } ?? [], + uniquingKeysWith: { first, _ in first } + ) let currentExecutionLine: Int? = { guard let frame = model.genericDebugFeatureIfActive?.stoppedFrame, frame.sourceURL?.standardizedFileURL == url else { return nil } @@ -1240,11 +1249,13 @@ struct CodeEditorView: NSViewRepresentable { || appliedBlameLines != blameLines || appliedDebugBreakpointLines != debugBreakpointLines || appliedDebugBreakpointStates != debugBreakpointStates + || appliedDebugBreakpointMessages != debugBreakpointMessages || appliedCurrentExecutionLine != currentExecutionLine { appliedBlameVisible = isBlameVisible appliedBlameLines = blameLines appliedDebugBreakpointLines = debugBreakpointLines appliedDebugBreakpointStates = debugBreakpointStates + appliedDebugBreakpointMessages = debugBreakpointMessages appliedCurrentExecutionLine = currentExecutionLine container?.gutterWidthConstraint?.constant = isBlameVisible ? EditorLayoutMetrics.blameMetadataWidth + standardGutterWidth @@ -1255,6 +1266,7 @@ struct CodeEditorView: NSViewRepresentable { gutter?.updateDebugBreakpointLines(debugBreakpointStates) { [weak model] line in model?.toggleDebugBreakpoint(fileURL: url, line: line) } + gutter?.updateDebugBreakpointMessages(debugBreakpointMessages) gutter?.updateCurrentExecutionLine(currentExecutionLine) } } @@ -3124,6 +3136,7 @@ final class LineNumberGutterView: NSView { private var onSelectImplementation: ((JavaImplementationMarker) -> Void)? private var debugBreakpointLines: Set = [] private var debugBreakpointVerifiedByLine: [Int: Bool] = [:] + private var debugBreakpointMessagesByLine: [Int: String] = [:] private var currentExecutionLine: Int? private var onToggleDebugBreakpoint: ((Int) -> Void)? private var scrollRefreshScheduled = false @@ -3297,6 +3310,12 @@ final class LineNumberGutterView: NSView { needsDisplay = true } + func updateDebugBreakpointMessages(_ messages: [Int: String]) { + debugBreakpointMessagesByLine = messages.reduce(into: [:]) { + $0[max(0, $1.key - 1)] = $1.value + } + } + func updateGitLineChanges( _ markers: [GitLineChangeMarker], onShow: @escaping (GitLineChangeMarker) -> Void, @@ -3778,6 +3797,7 @@ final class LineNumberGutterView: NSView { super.mouseMoved(with: event) let point = convert(event.locationInWindow, from: nil) updateFoldHover(at: point) + updateBreakpointToolTip(at: point) if foldRegion(at: point) != nil { NSCursor.pointingHand.set() } @@ -3795,6 +3815,20 @@ final class LineNumberGutterView: NSView { override func mouseExited(with event: NSEvent) { super.mouseExited(with: event) updateFoldHover(at: nil) + toolTip = nil + } + + private func updateBreakpointToolTip(at point: NSPoint) { + let localX = point.x - editorGutterOriginX + guard gutterLayout.breakpointRange.contains(localX), + let line = editorLine(at: point), + let verified = debugBreakpointVerifiedByLine[line] else { + toolTip = nil + return + } + let state = verified ? "Breakpoint verified" : "Breakpoint not verified" + let detail = debugBreakpointMessagesByLine[line].map { " — \($0)" } ?? "" + toolTip = "Line \(line + 1): \(state)\(detail)" } private func updateFoldHover(at point: NSPoint?) { From 0bee5b708bd91a0d2d951868acbe858789afaf5f Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sat, 29 Aug 2026 21:04:30 +0800 Subject: [PATCH 17/66] feat(debug): follow selected stack frame in editor --- macos/Sources/Lithe/Views/Editor/CodeEditorView.swift | 2 +- .../Application/GenericDebugFeatureModel.swift | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift index f6309af6..60e416ea 100644 --- a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift +++ b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift @@ -1241,7 +1241,7 @@ struct CodeEditorView: NSViewRepresentable { uniquingKeysWith: { first, _ in first } ) let currentExecutionLine: Int? = { - guard let frame = model.genericDebugFeatureIfActive?.stoppedFrame, + guard let frame = model.genericDebugFeatureIfActive?.selectedFrame, frame.sourceURL?.standardizedFileURL == url else { return nil } return frame.line }() diff --git a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift index bf1cb01d..b441f870 100644 --- a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift +++ b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift @@ -97,6 +97,9 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu @Published public private(set) var areBreakpointsMuted = false @Published public private(set) var capabilities: DebugAdapterCapabilities = .unknown @Published public private(set) var stoppedFrame: DebugStackFrame? + /// The frame currently selected in the call stack, which may differ from + /// the frame that initially caused the stop. + @Published public private(set) var selectedFrame: DebugStackFrame? /// Delivers the selected stopped frame to the host editor for source /// navigation. The Debug module does not own editor presentation. @@ -161,6 +164,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu selectedThreadID = nil selectedFrameID = nil stoppedFrame = nil + selectedFrame = nil do { if requestedBreakpointsByFile[fileURL.standardizedFileURL] != nil { try sessions.setBreakpoints( @@ -199,6 +203,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu selectedThreadID = nil selectedFrameID = nil stoppedFrame = nil + selectedFrame = nil threads = [] stackFrames = [] scopes = [] @@ -558,7 +563,6 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu self?.selectedFrameID = frames.first?.id if let frame = frames.first { self?.selectFrame(frame) - self?.publishStoppedLocation(frame) } case .failure(let error): self?.record(error) } @@ -568,6 +572,8 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu public func selectFrame(_ frame: DebugStackFrame) { activeSession?.cancelPendingOperations() selectedFrameID = frame.id + selectedFrame = frame + publishStoppedLocation(frame) refreshWatches() guard let session = activeSession else { return } session.requestScopes(frameID: frame.id) { [weak self] result in @@ -776,7 +782,6 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu self?.stackFrames = frames if let frame = frames.first { self?.selectFrame(frame) - self?.publishStoppedLocation(frame) } } } @@ -784,6 +789,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu case .continued: stoppedReason = nil stoppedFrame = nil + selectedFrame = nil activeSession?.cancelPendingOperations() resetVariableTree() invalidateWatchResults() From f1b0acb766e82fae985425b907124c0afc745a4b Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sat, 29 Aug 2026 21:06:20 +0800 Subject: [PATCH 18/66] feat(debug): manage breakpoints from editor gutter --- .../Lithe/Views/Editor/CodeEditorView.swift | 54 +++++++++++++++++-- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift index 60e416ea..76a3710e 100644 --- a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift +++ b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift @@ -1263,9 +1263,26 @@ struct CodeEditorView: NSViewRepresentable { gutter?.update(blameLines: blameLines, isVisible: isBlameVisible) { [weak model] blame in Task { await model?.showGitCommit(blame.commitHash) } } - gutter?.updateDebugBreakpointLines(debugBreakpointStates) { [weak model] line in - model?.toggleDebugBreakpoint(fileURL: url, line: line) - } + gutter?.updateDebugBreakpointLines( + debugBreakpointStates, + onToggle: { [weak model] line in + model?.toggleDebugBreakpoint(fileURL: url, line: line) + }, + onRemove: { [weak model] line in + guard let feature = model?.genericDebugFeatureIfActive, + let breakpoint = feature.breakpoints.first(where: { + $0.fileURL.standardizedFileURL == url && $0.line == line + }) else { return } + feature.removeBreakpoint(breakpoint) + }, + onSetEnabled: { [weak model] line, enabled in + guard let feature = model?.genericDebugFeatureIfActive, + let breakpoint = feature.breakpoints.first(where: { + $0.fileURL.standardizedFileURL == url && $0.line == line + }) else { return } + feature.setBreakpointEnabled(breakpoint, enabled: enabled) + } + ) gutter?.updateDebugBreakpointMessages(debugBreakpointMessages) gutter?.updateCurrentExecutionLine(currentExecutionLine) } @@ -3139,6 +3156,9 @@ final class LineNumberGutterView: NSView { private var debugBreakpointMessagesByLine: [Int: String] = [:] private var currentExecutionLine: Int? private var onToggleDebugBreakpoint: ((Int) -> Void)? + private var onRemoveDebugBreakpoint: ((Int) -> Void)? + private var onSetDebugBreakpointEnabled: ((Int, Bool) -> Void)? + private var contextDebugBreakpointLine: Int? private var scrollRefreshScheduled = false private var hoveredFoldID: String? private var foldIndicatorOpacities: [String: CGFloat] = [:] @@ -3295,13 +3315,17 @@ final class LineNumberGutterView: NSView { func updateDebugBreakpointLines( _ states: [Int: Bool], - onToggle: @escaping (Int) -> Void + onToggle: @escaping (Int) -> Void, + onRemove: ((Int) -> Void)? = nil, + onSetEnabled: ((Int, Bool) -> Void)? = nil ) { debugBreakpointLines = Set(states.keys.map { max(0, $0 - 1) }) debugBreakpointVerifiedByLine = Dictionary( uniqueKeysWithValues: states.map { (max(0, $0.key - 1), $0.value) } ) onToggleDebugBreakpoint = onToggle + onRemoveDebugBreakpoint = onRemove + onSetDebugBreakpointEnabled = onSetEnabled needsDisplay = true } @@ -3955,6 +3979,18 @@ final class LineNumberGutterView: NSView { override func menu(for event: NSEvent) -> NSMenu? { let point = convert(event.locationInWindow, from: nil) let localX = point.x - editorGutterOriginX + if gutterLayout.breakpointRange.contains(localX), + let line = editorLine(at: point), + let verified = debugBreakpointVerifiedByLine[line] { + contextDebugBreakpointLine = line + let menu = NSMenu(title: "Breakpoint") + let toggleTitle = verified ? "Disable Breakpoint" : "Enable Breakpoint" + menu.addItem(withTitle: toggleTitle, action: #selector(toggleDebugBreakpointFromMenu), keyEquivalent: "") + menu.items.last?.target = self + menu.addItem(withTitle: "Remove Breakpoint", action: #selector(removeDebugBreakpointFromMenu), keyEquivalent: "") + menu.items.last?.target = self + return menu + } guard gutterLayout.gitChangeRange.contains(localX), let line = editorLine(at: point), let marker = gitLineChangeMarkersByLine[line] else { @@ -3980,6 +4016,16 @@ final class LineNumberGutterView: NSView { return menu } + @objc private func toggleDebugBreakpointFromMenu() { + guard let line = contextDebugBreakpointLine, + let verified = debugBreakpointVerifiedByLine[line] else { return } + onSetDebugBreakpointEnabled?(line, !verified) + } + + @objc private func removeDebugBreakpointFromMenu() { + if let line = contextDebugBreakpointLine { onRemoveDebugBreakpoint?(line) } + } + private func editorLine(at point: NSPoint) -> Int? { guard let textView, let scrollView, From cc6480c9c8adda4937ef75023f934286b6ff4b70 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sat, 29 Aug 2026 21:10:04 +0800 Subject: [PATCH 19/66] feat(debug): add run to cursor gutter action --- .../Lithe/Views/Editor/CodeEditorView.swift | 38 ++++++++++++++++++- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift index 76a3710e..0dd5510e 100644 --- a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift +++ b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift @@ -603,6 +603,7 @@ struct CodeEditorView: NSViewRepresentable { private var appliedDebugBreakpointLines = Set() private var appliedDebugBreakpointStates: [Int: Bool] = [:] private var appliedDebugBreakpointMessages: [Int: String] = [:] + private var appliedRunToCursorEnabled = false private var appliedCurrentExecutionLine: Int? private var appliedGitMarkers: [GitLineChangeMarker]? private var appliedDiagnostics: [EditorDiagnostic] = [] @@ -1245,17 +1246,20 @@ struct CodeEditorView: NSViewRepresentable { frame.sourceURL?.standardizedFileURL == url else { return nil } return frame.line }() + let isRunToCursorEnabled = model.genericDebugFeatureIfActive?.state == .paused if appliedBlameVisible != isBlameVisible || appliedBlameLines != blameLines || appliedDebugBreakpointLines != debugBreakpointLines || appliedDebugBreakpointStates != debugBreakpointStates || appliedDebugBreakpointMessages != debugBreakpointMessages + || appliedRunToCursorEnabled != isRunToCursorEnabled || appliedCurrentExecutionLine != currentExecutionLine { appliedBlameVisible = isBlameVisible appliedBlameLines = blameLines appliedDebugBreakpointLines = debugBreakpointLines appliedDebugBreakpointStates = debugBreakpointStates appliedDebugBreakpointMessages = debugBreakpointMessages + appliedRunToCursorEnabled = isRunToCursorEnabled appliedCurrentExecutionLine = currentExecutionLine container?.gutterWidthConstraint?.constant = isBlameVisible ? EditorLayoutMetrics.blameMetadataWidth + standardGutterWidth @@ -1281,7 +1285,11 @@ struct CodeEditorView: NSViewRepresentable { $0.fileURL.standardizedFileURL == url && $0.line == line }) else { return } feature.setBreakpointEnabled(breakpoint, enabled: enabled) - } + }, + onRunToCursor: { [weak model] line in + model?.runToCursor(fileURL: url, line: line + 1, column: 1) + }, + isRunToCursorEnabled: isRunToCursorEnabled ) gutter?.updateDebugBreakpointMessages(debugBreakpointMessages) gutter?.updateCurrentExecutionLine(currentExecutionLine) @@ -3158,6 +3166,9 @@ final class LineNumberGutterView: NSView { private var onToggleDebugBreakpoint: ((Int) -> Void)? private var onRemoveDebugBreakpoint: ((Int) -> Void)? private var onSetDebugBreakpointEnabled: ((Int, Bool) -> Void)? + private var onRunToCursor: ((Int) -> Void)? + private var isRunToCursorEnabled = false + private var contextGutterLine: Int? private var contextDebugBreakpointLine: Int? private var scrollRefreshScheduled = false private var hoveredFoldID: String? @@ -3317,7 +3328,9 @@ final class LineNumberGutterView: NSView { _ states: [Int: Bool], onToggle: @escaping (Int) -> Void, onRemove: ((Int) -> Void)? = nil, - onSetEnabled: ((Int, Bool) -> Void)? = nil + onSetEnabled: ((Int, Bool) -> Void)? = nil, + onRunToCursor: ((Int) -> Void)? = nil, + isRunToCursorEnabled: Bool = false ) { debugBreakpointLines = Set(states.keys.map { max(0, $0 - 1) }) debugBreakpointVerifiedByLine = Dictionary( @@ -3326,6 +3339,8 @@ final class LineNumberGutterView: NSView { onToggleDebugBreakpoint = onToggle onRemoveDebugBreakpoint = onRemove onSetDebugBreakpointEnabled = onSetEnabled + self.onRunToCursor = onRunToCursor + self.isRunToCursorEnabled = isRunToCursorEnabled needsDisplay = true } @@ -3991,6 +4006,21 @@ final class LineNumberGutterView: NSView { menu.items.last?.target = self return menu } + if gutterLayout.lineNumberRange.contains(localX), + let line = editorLine(at: point), + onRunToCursor != nil { + contextGutterLine = line + let menu = NSMenu(title: "Editor Line") + let item = NSMenuItem( + title: "Run to Cursor", + action: #selector(runToCursorFromGutterMenu), + keyEquivalent: "" + ) + item.target = self + item.isEnabled = isRunToCursorEnabled + menu.addItem(item) + return menu + } guard gutterLayout.gitChangeRange.contains(localX), let line = editorLine(at: point), let marker = gitLineChangeMarkersByLine[line] else { @@ -4026,6 +4056,10 @@ final class LineNumberGutterView: NSView { if let line = contextDebugBreakpointLine { onRemoveDebugBreakpoint?(line) } } + @objc private func runToCursorFromGutterMenu() { + if let contextGutterLine { onRunToCursor?(contextGutterLine) } + } + private func editorLine(at point: NSPoint) -> Int? { guard let textView, let scrollView, From eb788c7c21fbd3fc636fb55fe1584cfff78a96d4 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sat, 29 Aug 2026 21:47:59 +0800 Subject: [PATCH 20/66] feat(debug): add breakpoint mute gutter action --- .../Lithe/Views/Editor/CodeEditorView.swift | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift index 0dd5510e..d4954772 100644 --- a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift +++ b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift @@ -1286,6 +1286,9 @@ struct CodeEditorView: NSViewRepresentable { }) else { return } feature.setBreakpointEnabled(breakpoint, enabled: enabled) }, + onToggleAll: { [weak model] in + model?.genericDebugFeatureIfActive?.toggleBreakpointMute() + }, onRunToCursor: { [weak model] line in model?.runToCursor(fileURL: url, line: line + 1, column: 1) }, @@ -3166,6 +3169,7 @@ final class LineNumberGutterView: NSView { private var onToggleDebugBreakpoint: ((Int) -> Void)? private var onRemoveDebugBreakpoint: ((Int) -> Void)? private var onSetDebugBreakpointEnabled: ((Int, Bool) -> Void)? + private var onToggleAllDebugBreakpoints: (() -> Void)? private var onRunToCursor: ((Int) -> Void)? private var isRunToCursorEnabled = false private var contextGutterLine: Int? @@ -3329,6 +3333,7 @@ final class LineNumberGutterView: NSView { onToggle: @escaping (Int) -> Void, onRemove: ((Int) -> Void)? = nil, onSetEnabled: ((Int, Bool) -> Void)? = nil, + onToggleAll: (() -> Void)? = nil, onRunToCursor: ((Int) -> Void)? = nil, isRunToCursorEnabled: Bool = false ) { @@ -3339,6 +3344,7 @@ final class LineNumberGutterView: NSView { onToggleDebugBreakpoint = onToggle onRemoveDebugBreakpoint = onRemove onSetDebugBreakpointEnabled = onSetEnabled + onToggleAllDebugBreakpoints = onToggleAll self.onRunToCursor = onRunToCursor self.isRunToCursorEnabled = isRunToCursorEnabled needsDisplay = true @@ -4004,6 +4010,11 @@ final class LineNumberGutterView: NSView { menu.items.last?.target = self menu.addItem(withTitle: "Remove Breakpoint", action: #selector(removeDebugBreakpointFromMenu), keyEquivalent: "") menu.items.last?.target = self + if onToggleAllDebugBreakpoints != nil { + menu.addItem(.separator()) + menu.addItem(withTitle: "Mute All Breakpoints", action: #selector(toggleAllDebugBreakpointsFromMenu), keyEquivalent: "") + menu.items.last?.target = self + } return menu } if gutterLayout.lineNumberRange.contains(localX), @@ -4056,6 +4067,10 @@ final class LineNumberGutterView: NSView { if let line = contextDebugBreakpointLine { onRemoveDebugBreakpoint?(line) } } + @objc private func toggleAllDebugBreakpointsFromMenu() { + onToggleAllDebugBreakpoints?() + } + @objc private func runToCursorFromGutterMenu() { if let contextGutterLine { onRunToCursor?(contextGutterLine) } } From b900b0c500679812c700b09e3418d0204e39d7d1 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sat, 29 Aug 2026 22:06:03 +0800 Subject: [PATCH 21/66] feat(debug): reflect muted breakpoints in gutter --- .../Lithe/Views/Editor/CodeEditorView.swift | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift index d4954772..910f258c 100644 --- a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift +++ b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift @@ -604,6 +604,7 @@ struct CodeEditorView: NSViewRepresentable { private var appliedDebugBreakpointStates: [Int: Bool] = [:] private var appliedDebugBreakpointMessages: [Int: String] = [:] private var appliedRunToCursorEnabled = false + private var appliedBreakpointsMuted = false private var appliedCurrentExecutionLine: Int? private var appliedGitMarkers: [GitLineChangeMarker]? private var appliedDiagnostics: [EditorDiagnostic] = [] @@ -1247,12 +1248,14 @@ struct CodeEditorView: NSViewRepresentable { return frame.line }() let isRunToCursorEnabled = model.genericDebugFeatureIfActive?.state == .paused + let areBreakpointsMuted = model.genericDebugFeatureIfActive?.areBreakpointsMuted ?? false if appliedBlameVisible != isBlameVisible || appliedBlameLines != blameLines || appliedDebugBreakpointLines != debugBreakpointLines || appliedDebugBreakpointStates != debugBreakpointStates || appliedDebugBreakpointMessages != debugBreakpointMessages || appliedRunToCursorEnabled != isRunToCursorEnabled + || appliedBreakpointsMuted != areBreakpointsMuted || appliedCurrentExecutionLine != currentExecutionLine { appliedBlameVisible = isBlameVisible appliedBlameLines = blameLines @@ -1260,6 +1263,7 @@ struct CodeEditorView: NSViewRepresentable { appliedDebugBreakpointStates = debugBreakpointStates appliedDebugBreakpointMessages = debugBreakpointMessages appliedRunToCursorEnabled = isRunToCursorEnabled + appliedBreakpointsMuted = areBreakpointsMuted appliedCurrentExecutionLine = currentExecutionLine container?.gutterWidthConstraint?.constant = isBlameVisible ? EditorLayoutMetrics.blameMetadataWidth + standardGutterWidth @@ -1292,7 +1296,8 @@ struct CodeEditorView: NSViewRepresentable { onRunToCursor: { [weak model] line in model?.runToCursor(fileURL: url, line: line + 1, column: 1) }, - isRunToCursorEnabled: isRunToCursorEnabled + isRunToCursorEnabled: isRunToCursorEnabled, + areBreakpointsMuted: areBreakpointsMuted ) gutter?.updateDebugBreakpointMessages(debugBreakpointMessages) gutter?.updateCurrentExecutionLine(currentExecutionLine) @@ -3172,6 +3177,7 @@ final class LineNumberGutterView: NSView { private var onToggleAllDebugBreakpoints: (() -> Void)? private var onRunToCursor: ((Int) -> Void)? private var isRunToCursorEnabled = false + private var areBreakpointsMuted = false private var contextGutterLine: Int? private var contextDebugBreakpointLine: Int? private var scrollRefreshScheduled = false @@ -3335,7 +3341,8 @@ final class LineNumberGutterView: NSView { onSetEnabled: ((Int, Bool) -> Void)? = nil, onToggleAll: (() -> Void)? = nil, onRunToCursor: ((Int) -> Void)? = nil, - isRunToCursorEnabled: Bool = false + isRunToCursorEnabled: Bool = false, + areBreakpointsMuted: Bool = false ) { debugBreakpointLines = Set(states.keys.map { max(0, $0 - 1) }) debugBreakpointVerifiedByLine = Dictionary( @@ -3347,6 +3354,7 @@ final class LineNumberGutterView: NSView { onToggleAllDebugBreakpoints = onToggleAll self.onRunToCursor = onRunToCursor self.isRunToCursorEnabled = isRunToCursorEnabled + self.areBreakpointsMuted = areBreakpointsMuted needsDisplay = true } @@ -3758,7 +3766,7 @@ final class LineNumberGutterView: NSView { height: markerSize ) ) - NSColor(red: 0.92, green: 0.28, blue: 0.30, alpha: 0.96).setStroke() + NSColor(red: 0.92, green: 0.28, blue: 0.30, alpha: areBreakpointsMuted ? 0.42 : 0.96).setStroke() path.lineWidth = 1.5 if verified { path.fill() @@ -4012,7 +4020,7 @@ final class LineNumberGutterView: NSView { menu.items.last?.target = self if onToggleAllDebugBreakpoints != nil { menu.addItem(.separator()) - menu.addItem(withTitle: "Mute All Breakpoints", action: #selector(toggleAllDebugBreakpointsFromMenu), keyEquivalent: "") + menu.addItem(withTitle: areBreakpointsMuted ? "Unmute All Breakpoints" : "Mute All Breakpoints", action: #selector(toggleAllDebugBreakpointsFromMenu), keyEquivalent: "") menu.items.last?.target = self } return menu From 4a658b966417545c497a857e43448cc7fc9aa536 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sat, 29 Aug 2026 22:31:00 +0800 Subject: [PATCH 22/66] feat(debug): add stepping keyboard shortcuts --- .../zh-Hans.lproj/Localizable.strings | 8 ++++++++ .../AppModel/AppModel+Development.swift | 20 +++++++++++++++++++ .../AppModel/AppModel+FeatureState.swift | 2 ++ .../Models/Keymap/LitheCommandCatalog.swift | 4 ++++ macos/Sources/Lithe/Models/LitheAction.swift | 4 ++++ .../LitheTests/KeyboardShortcutTests.swift | 2 +- 6 files changed, 39 insertions(+), 1 deletion(-) diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 96665fd4..2d4496c1 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -871,6 +871,14 @@ "Stop the current run" = "停止当前运行"; "Stop Debug" = "停止调试"; "Stop the current debug session" = "停止当前调试会话"; +"Debug: Resume" = "调试:继续"; +"Resume the paused debug session" = "继续已暂停的调试会话"; +"Debug: Step Over" = "调试:步过"; +"Execute the next source line" = "执行下一行源代码"; +"Debug: Step Into" = "调试:步入"; +"Enter the next function call" = "进入下一个函数调用"; +"Debug: Step Out" = "调试:步出"; +"Return from the current function" = "从当前函数返回"; "Open Project" = "打开项目"; "Where would you like to open the project ‘%@’?" = "你想在哪里打开项目“%@”?"; "Don't ask again" = "不再询问"; diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift index 55e8a397..428bb2df 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift @@ -450,6 +450,26 @@ extension AppModel { genericDebugFeatureIfActive?.stop() } + func resumeDebugging() { + guard let feature = genericDebugFeatureIfActive, feature.state == .paused else { return } + feature.execute(.continueExecution) + } + + func stepOverDebugging() { + guard let feature = genericDebugFeatureIfActive, feature.state == .paused else { return } + feature.execute(.next) + } + + func stepIntoDebugging() { + guard let feature = genericDebugFeatureIfActive, feature.state == .paused else { return } + feature.execute(.stepIn) + } + + func stepOutDebugging() { + guard let feature = genericDebugFeatureIfActive, feature.state == .paused else { return } + feature.execute(.stepOut) + } + func toggleDebugBreakpointAtCaret() { guard let document = activeDocument, let caret = editorCaret, diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift index 2b0a9c53..38b5f7ab 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift @@ -345,6 +345,8 @@ extension AppModel { supportsLanguageServerFeature(.references) case "go-to-implementation": supportsLanguageServerFeature(.implementation) + case "debug-resume", "debug-step-over", "debug-step-into", "debug-step-out": + genericDebugFeatureIfActive?.state == .paused case "close-project", "search-everywhere", "search-in-project", "replace-in-project", "project-local-history", "run", "debug", "stop-run", "stop-debug", "toggle-terminal", "toggle-problems", diff --git a/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift b/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift index 66ab4fc2..7e0ec0eb 100644 --- a/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift +++ b/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift @@ -21,6 +21,10 @@ enum LitheCommandCatalog { command("debug", "Debug", "Start debugging", .run, "d", [.control]), command("stop-run", "Stop Run", "Stop the current run", .run), command("stop-debug", "Stop Debug", "Stop the current debug session", .run), + command("debug-resume", "Debug: Resume", "Resume the paused debug session", .run, "f9"), + command("debug-step-over", "Debug: Step Over", "Execute the next source line", .run, "f8"), + command("debug-step-into", "Debug: Step Into", "Enter the next function call", .run, "f7"), + command("debug-step-out", "Debug: Step Out", "Return from the current function", .run, "f8", [.shift]), LitheCommandDefinition( id: "search-everywhere", diff --git a/macos/Sources/Lithe/Models/LitheAction.swift b/macos/Sources/Lithe/Models/LitheAction.swift index f9b19f6b..b8d2011a 100644 --- a/macos/Sources/Lithe/Models/LitheAction.swift +++ b/macos/Sources/Lithe/Models/LitheAction.swift @@ -59,6 +59,10 @@ enum LitheActionRegistry { action("debug", model: model) { model.startDebugging() }, action("stop-run", model: model) { model.stopSelectedRun() }, action("stop-debug", model: model) { model.stopDebugging() }, + action("debug-resume", model: model) { model.resumeDebugging() }, + action("debug-step-over", model: model) { model.stepOverDebugging() }, + action("debug-step-into", model: model) { model.stepIntoDebugging() }, + action("debug-step-out", model: model) { model.stepOutDebugging() }, action("open-project", model: model) { model.chooseProject() }, action("close-project", model: model) { model.closeProject() }, action("settings", model: model) { model.showSettings() }, diff --git a/macos/Tests/LitheTests/KeyboardShortcutTests.swift b/macos/Tests/LitheTests/KeyboardShortcutTests.swift index 1ccb9a4d..af8b3f6b 100644 --- a/macos/Tests/LitheTests/KeyboardShortcutTests.swift +++ b/macos/Tests/LitheTests/KeyboardShortcutTests.swift @@ -8,7 +8,7 @@ struct KeyboardShortcutTests { @Test func catalogHasStableUniqueCommandsAndConflictFreeDefaults() { let commands = LitheCommandCatalog.commands - #expect(commands.count == 31) + #expect(commands.count == 35) #expect(Set(commands.map(\.id)).count == commands.count) let owners = commands.flatMap { command in From 6dd30d818786d23ed95bd09096289f66074f9b5e Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sat, 29 Aug 2026 22:34:30 +0800 Subject: [PATCH 23/66] fix(debug): separate enabled and verified breakpoint states --- .../Lithe/Views/Editor/CodeEditorView.swift | 55 +++++++++++++------ 1 file changed, 37 insertions(+), 18 deletions(-) diff --git a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift index 910f258c..f8ff625a 100644 --- a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift +++ b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift @@ -105,6 +105,11 @@ enum EditorGutterHitTarget: Equatable { case gitChange } +struct EditorDebugBreakpointState: Equatable { + let enabled: Bool + let verified: Bool +} + struct EditorLanguageFeatureTransition: Equatable { let refreshImplementationMarkers: Bool let clearImplementationMarkers: Bool @@ -601,7 +606,7 @@ struct CodeEditorView: NSViewRepresentable { private var appliedBlameVisible = false private var appliedBlameLines: [GitBlameLine] = [] private var appliedDebugBreakpointLines = Set() - private var appliedDebugBreakpointStates: [Int: Bool] = [:] + private var appliedDebugBreakpointStates: [Int: EditorDebugBreakpointState] = [:] private var appliedDebugBreakpointMessages: [Int: String] = [:] private var appliedRunToCursorEnabled = false private var appliedBreakpointsMuted = false @@ -1229,10 +1234,14 @@ struct CodeEditorView: NSViewRepresentable { let debugBreakpointLines = Set(genericBreakpointLines) let debugBreakpointStates = (model.genericDebugFeatureIfActive?.breakpoints ?? []) .filter { $0.fileURL.standardizedFileURL == url } - .reduce(into: [Int: Bool]()) { states, breakpoint in + .reduce(into: [Int: EditorDebugBreakpointState]()) { states, breakpoint in // A source line can carry multiple column breakpoints; // show it as confirmed when any adapter location is confirmed. - states[breakpoint.line] = states[breakpoint.line] == true || breakpoint.verified + let previous = states[breakpoint.line] + states[breakpoint.line] = EditorDebugBreakpointState( + enabled: previous?.enabled == true || breakpoint.enabled, + verified: previous?.verified == true || breakpoint.verified + ) } let debugBreakpointMessages = Dictionary( model.genericDebugFeatureIfActive?.breakpoints @@ -3168,7 +3177,7 @@ final class LineNumberGutterView: NSView { private var implementationMarkers: [JavaImplementationMarker] = [] private var onSelectImplementation: ((JavaImplementationMarker) -> Void)? private var debugBreakpointLines: Set = [] - private var debugBreakpointVerifiedByLine: [Int: Bool] = [:] + private var debugBreakpointStatesByLine: [Int: EditorDebugBreakpointState] = [:] private var debugBreakpointMessagesByLine: [Int: String] = [:] private var currentExecutionLine: Int? private var onToggleDebugBreakpoint: ((Int) -> Void)? @@ -3335,7 +3344,7 @@ final class LineNumberGutterView: NSView { } func updateDebugBreakpointLines( - _ states: [Int: Bool], + _ states: [Int: EditorDebugBreakpointState], onToggle: @escaping (Int) -> Void, onRemove: ((Int) -> Void)? = nil, onSetEnabled: ((Int, Bool) -> Void)? = nil, @@ -3345,7 +3354,7 @@ final class LineNumberGutterView: NSView { areBreakpointsMuted: Bool = false ) { debugBreakpointLines = Set(states.keys.map { max(0, $0 - 1) }) - debugBreakpointVerifiedByLine = Dictionary( + debugBreakpointStatesByLine = Dictionary( uniqueKeysWithValues: states.map { (max(0, $0.key - 1), $0.value) } ) onToggleDebugBreakpoint = onToggle @@ -3584,8 +3593,8 @@ final class LineNumberGutterView: NSView { showsBlameMetadata(line: lineNumber - 1, firstVisibleLine: firstLine) { drawBlame(blame, y: y, height: lineRect.height) } - if !isBlameVisible, let verified = debugBreakpointVerifiedByLine[lineNumber - 1] { - drawDebugBreakpoint(y: y, height: lineRect.height, verified: verified) + if !isBlameVisible, let state = debugBreakpointStatesByLine[lineNumber - 1] { + drawDebugBreakpoint(y: y, height: lineRect.height, state: state) } else { let markers = implementationMarkers.filter { $0.line == lineNumber - 1 } for marker in markers { @@ -3755,7 +3764,11 @@ final class LineNumberGutterView: NSView { path.stroke() } - private func drawDebugBreakpoint(y: CGFloat, height: CGFloat, verified: Bool) { + private func drawDebugBreakpoint( + y: CGFloat, + height: CGFloat, + state: EditorDebugBreakpointState + ) { let markerSize: CGFloat = 9 let path = NSBezierPath( ovalIn: NSRect( @@ -3766,9 +3779,10 @@ final class LineNumberGutterView: NSView { height: markerSize ) ) - NSColor(red: 0.92, green: 0.28, blue: 0.30, alpha: areBreakpointsMuted ? 0.42 : 0.96).setStroke() + let isInactive = areBreakpointsMuted || !state.enabled + NSColor(red: 0.92, green: 0.28, blue: 0.30, alpha: isInactive ? 0.42 : 0.96).setStroke() path.lineWidth = 1.5 - if verified { + if state.verified { path.fill() } else { path.stroke() @@ -3875,13 +3889,18 @@ final class LineNumberGutterView: NSView { let localX = point.x - editorGutterOriginX guard gutterLayout.breakpointRange.contains(localX), let line = editorLine(at: point), - let verified = debugBreakpointVerifiedByLine[line] else { + let state = debugBreakpointStatesByLine[line] else { toolTip = nil return } - let state = verified ? "Breakpoint verified" : "Breakpoint not verified" + let stateLabel: String + if !state.enabled { + stateLabel = "Breakpoint disabled" + } else { + stateLabel = state.verified ? "Breakpoint verified" : "Breakpoint not verified" + } let detail = debugBreakpointMessagesByLine[line].map { " — \($0)" } ?? "" - toolTip = "Line \(line + 1): \(state)\(detail)" + toolTip = "Line \(line + 1): \(stateLabel)\(detail)" } private func updateFoldHover(at point: NSPoint?) { @@ -4010,10 +4029,10 @@ final class LineNumberGutterView: NSView { let localX = point.x - editorGutterOriginX if gutterLayout.breakpointRange.contains(localX), let line = editorLine(at: point), - let verified = debugBreakpointVerifiedByLine[line] { + let state = debugBreakpointStatesByLine[line] { contextDebugBreakpointLine = line let menu = NSMenu(title: "Breakpoint") - let toggleTitle = verified ? "Disable Breakpoint" : "Enable Breakpoint" + let toggleTitle = state.enabled ? "Disable Breakpoint" : "Enable Breakpoint" menu.addItem(withTitle: toggleTitle, action: #selector(toggleDebugBreakpointFromMenu), keyEquivalent: "") menu.items.last?.target = self menu.addItem(withTitle: "Remove Breakpoint", action: #selector(removeDebugBreakpointFromMenu), keyEquivalent: "") @@ -4067,8 +4086,8 @@ final class LineNumberGutterView: NSView { @objc private func toggleDebugBreakpointFromMenu() { guard let line = contextDebugBreakpointLine, - let verified = debugBreakpointVerifiedByLine[line] else { return } - onSetDebugBreakpointEnabled?(line, !verified) + let state = debugBreakpointStatesByLine[line] else { return } + onSetDebugBreakpointEnabled?(line, !state.enabled) } @objc private func removeDebugBreakpointFromMenu() { From cc25efb10fd19088fc214a70de280678d3d29bab Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sat, 29 Aug 2026 22:51:24 +0800 Subject: [PATCH 24/66] feat(debug): persist project breakpoints --- .../Application/Composition/AppServices.swift | 4 + .../Composition/DebugFeatureGraph.swift | 10 +- .../AppModel/AppModel+ExecutionModules.swift | 16 +++ .../Lithe/Models/AppModel/AppModel.swift | 2 + .../MacOS/Debug/MacDebugBreakpointStore.swift | 45 ++++++++ .../Platform/MacOS/MacServiceContainer.swift | 7 +- .../DebugBreakpointPersistence.swift | 52 +++++++++ .../GenericDebugFeatureModel.swift | 103 +++++++++++++++++- .../DebugModuleTests.swift | 94 ++++++++++++++++ .../DebugBreakpointPersistenceTests.swift | 63 +++++++++++ 10 files changed, 392 insertions(+), 4 deletions(-) create mode 100644 macos/Sources/Lithe/Platform/MacOS/Debug/MacDebugBreakpointStore.swift create mode 100644 macos/Sources/LitheDebugModule/Application/DebugBreakpointPersistence.swift create mode 100644 macos/Tests/LitheTests/DebugBreakpointPersistenceTests.swift diff --git a/macos/Sources/Lithe/Application/Composition/AppServices.swift b/macos/Sources/Lithe/Application/Composition/AppServices.swift index 3ee615dd..d537ddfd 100644 --- a/macos/Sources/Lithe/Application/Composition/AppServices.swift +++ b/macos/Sources/Lithe/Application/Composition/AppServices.swift @@ -1,6 +1,7 @@ import Foundation import LitheApplicationKernel import LitheCoreContracts +import LitheDebugModule /// Platform-neutral service graph consumed by application orchestration. /// Platform composition roots construct this graph with their own adapters. @@ -19,6 +20,7 @@ final class AppServices { /// Metadata-only provider catalog; providers are activated on demand. let languageProviderCatalog: LanguageProviderCatalog let debugLaunchConfigurationResolver: DebugLaunchConfigurationResolver + let debugBreakpointPersistence: (any DebugBreakpointPersisting)? let workspaceOperations: any WorkspaceOperations let documentLifecycleDecider: any DocumentLifecycleDeciding let javaMavenOperations: any JavaMavenOperations @@ -52,6 +54,7 @@ final class AppServices { languageProviderCatalogSource: any LanguageProviderCatalogSource, languageProviderCatalogSnapshot: LanguageProviderCatalogSnapshot? = nil, debugLaunchConfigurationResolver: DebugLaunchConfigurationResolver? = nil, + debugBreakpointPersistence: (any DebugBreakpointPersisting)? = nil, workspaceOperations: any WorkspaceOperations, documentLifecycleDecider: any DocumentLifecycleDeciding, javaMavenOperations: any JavaMavenOperations, @@ -88,6 +91,7 @@ final class AppServices { self.languageProviderCatalog = resolvedCatalog self.debugLaunchConfigurationResolver = debugLaunchConfigurationResolver ?? DebugLaunchConfigurationResolver(fileStorage: fileStorage) + self.debugBreakpointPersistence = debugBreakpointPersistence self.workspaceOperations = workspaceOperations self.documentLifecycleDecider = documentLifecycleDecider self.javaMavenOperations = javaMavenOperations diff --git a/macos/Sources/Lithe/Application/Composition/DebugFeatureGraph.swift b/macos/Sources/Lithe/Application/Composition/DebugFeatureGraph.swift index 61e76e8e..4a7788ab 100644 --- a/macos/Sources/Lithe/Application/Composition/DebugFeatureGraph.swift +++ b/macos/Sources/Lithe/Application/Composition/DebugFeatureGraph.swift @@ -10,9 +10,15 @@ final class DebugFeatureGraph: NSObject, DebugServiceGraph { private var activityObservers: Set = [] private var adapterLease: ModuleLease? - init(adapterSessions: DebugAdapterSessionManager) { + init( + adapterSessions: DebugAdapterSessionManager, + breakpointPersistence: (any DebugBreakpointPersisting)? = nil + ) { self.adapterSessions = adapterSessions - genericFeature = GenericDebugFeatureModel(sessions: adapterSessions) + genericFeature = GenericDebugFeatureModel( + sessions: adapterSessions, + breakpointPersistence: breakpointPersistence + ) } var isActive: Bool { !adapterSessions.activeAdapterIDs.isEmpty } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift index 02666e03..9cbf72eb 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift @@ -51,6 +51,7 @@ extension AppModel { func activateDebugModule() async -> DebugFeatureAccess? { if let genericFeature = genericDebugFeatureIfActive { + if let workspaceURL { genericFeature.openWorkspace(at: workspaceURL) } return DebugFeatureAccess(genericFeature: genericFeature) } do { @@ -61,6 +62,7 @@ extension AppModel { genericFeature.onStoppedLocation = { [weak self] url, line, column in self?.openSourceLocation(url: url, line: line, column: column) } + if let workspaceURL { genericFeature.openWorkspace(at: workspaceURL) } observeModuleFeature(.debug, observation: genericFeature.objectWillChange.sink { [weak self] _ in self?.scheduleObjectWillChangeRelay() }) @@ -70,4 +72,18 @@ extension AppModel { return nil } } + + func restoreDebugBreakpoints(for workspaceURL: URL) async { + guard self.workspaceURL == workspaceURL, + let persistence = services.debugBreakpointPersistence else { return } + do { + guard let snapshot = try persistence.loadBreakpoints(for: workspaceURL), + snapshot.version == DebugBreakpointSnapshot.currentVersion, + !snapshot.breakpoints.isEmpty, + self.workspaceURL == workspaceURL else { return } + _ = await activateDebugModule() + } catch { + showNotification(error.localizedDescription) + } + } } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index fb791560..6feaff39 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -965,6 +965,8 @@ final class AppModel: ObservableObject, Identifiable { recentProjects = recentProjectsStore.record(normalizedURL, in: recentProjects) Task { + await restoreDebugBreakpoints(for: normalizedURL) + guard workspaceURL == normalizedURL else { return } _ = await workspaceFeature.rebuild( at: normalizedURL, rules: visibilityRules, diff --git a/macos/Sources/Lithe/Platform/MacOS/Debug/MacDebugBreakpointStore.swift b/macos/Sources/Lithe/Platform/MacOS/Debug/MacDebugBreakpointStore.swift new file mode 100644 index 00000000..1dfc6635 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Debug/MacDebugBreakpointStore.swift @@ -0,0 +1,45 @@ +import Foundation +import LitheDebugModule + +enum MacDebugBreakpointStoreError: LocalizedError { + case invalidData + + var errorDescription: String? { + switch self { + case .invalidData: + "Saved breakpoints could not be read." + } + } +} + +final class MacDebugBreakpointStore: DebugBreakpointPersisting, @unchecked Sendable { + private static let keyPrefix = "lithe.debug.breakpoints." + private let store: any KeyValueStore + private let lock = NSLock() + + init(store: any KeyValueStore) { + self.store = store + } + + func loadBreakpoints(for workspaceURL: URL) throws -> DebugBreakpointSnapshot? { + lock.lock(); defer { lock.unlock() } + guard let data = store.data(forKey: key(for: workspaceURL)) else { return nil } + do { + return try JSONDecoder().decode(DebugBreakpointSnapshot.self, from: data) + } catch { + throw MacDebugBreakpointStoreError.invalidData + } + } + + func saveBreakpoints(_ snapshot: DebugBreakpointSnapshot, for workspaceURL: URL) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(snapshot) + lock.lock(); defer { lock.unlock() } + store.set(data, forKey: key(for: workspaceURL)) + } + + private func key(for workspaceURL: URL) -> String { + Self.keyPrefix + workspaceURL.standardizedFileURL.path + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index e525041a..15d9cf9f 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -73,6 +73,7 @@ final class MacServiceContainer { preferences: store ) self.runConfigurationStore = runConfigurationStore + let debugBreakpointStore = MacDebugBreakpointStore(store: store) let fileOperations = MacWorkspaceFileOperations() let processRunner = MacProcessRunner() let secureStore = MacLocalSecretStore() @@ -408,7 +409,10 @@ final class MacServiceContainer { ) } ) - let graph = DebugFeatureGraph(adapterSessions: adapterSessions) + let graph = DebugFeatureGraph( + adapterSessions: adapterSessions, + breakpointPersistence: debugBreakpointStore + ) return graph }) }) @@ -493,6 +497,7 @@ final class MacServiceContainer { pluginCatalog: pluginCatalog, languageProviderCatalogSource: languageProviderCatalogSource, languageProviderCatalogSnapshot: languageProviderCatalogSnapshot, + debugBreakpointPersistence: debugBreakpointStore, workspaceOperations: workspaceOperations, documentLifecycleDecider: RustDocumentLifecycleDecider(core: rustCore), javaMavenOperations: javaMavenOperations, diff --git a/macos/Sources/LitheDebugModule/Application/DebugBreakpointPersistence.swift b/macos/Sources/LitheDebugModule/Application/DebugBreakpointPersistence.swift new file mode 100644 index 00000000..9067aa91 --- /dev/null +++ b/macos/Sources/LitheDebugModule/Application/DebugBreakpointPersistence.swift @@ -0,0 +1,52 @@ +import Foundation + +public struct PersistedDebugBreakpoint: Codable, Equatable, Sendable { + public let relativePath: String + public let line: Int + public let column: Int? + public let enabled: Bool + public let condition: String? + public let hitCondition: String? + public let logMessage: String? + + public init( + relativePath: String, + line: Int, + column: Int? = nil, + enabled: Bool = true, + condition: String? = nil, + hitCondition: String? = nil, + logMessage: String? = nil + ) { + self.relativePath = relativePath + self.line = line + self.column = column + self.enabled = enabled + self.condition = condition + self.hitCondition = hitCondition + self.logMessage = logMessage + } +} + +public struct DebugBreakpointSnapshot: Codable, Equatable, Sendable { + public static let currentVersion = 1 + + public let version: Int + public let areBreakpointsMuted: Bool + public let breakpoints: [PersistedDebugBreakpoint] + + public init( + version: Int = Self.currentVersion, + areBreakpointsMuted: Bool = false, + breakpoints: [PersistedDebugBreakpoint] + ) { + self.version = version + self.areBreakpointsMuted = areBreakpointsMuted + self.breakpoints = breakpoints + } +} + +public protocol DebugBreakpointPersisting: Sendable { + func loadBreakpoints(for workspaceURL: URL) throws -> DebugBreakpointSnapshot? + func saveBreakpoints(_ snapshot: DebugBreakpointSnapshot, for workspaceURL: URL) throws +} diff --git a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift index b441f870..869f5b46 100644 --- a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift +++ b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift @@ -106,13 +106,19 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu public var onStoppedLocation: ((URL, Int, Int) -> Void)? private let sessions: DebugAdapterSessionManager + private let breakpointPersistence: (any DebugBreakpointPersisting)? private var requestedBreakpointsByFile: [URL: [Int: DebugSourceBreakpoint]] = [:] + private var workspaceURL: URL? private var activeFileURL: URL? private let maximumOutputCharacters = 400_000 private var watchGeneration = 0 - public init(sessions: DebugAdapterSessionManager) { + public init( + sessions: DebugAdapterSessionManager, + breakpointPersistence: (any DebugBreakpointPersisting)? = nil + ) { self.sessions = sessions + self.breakpointPersistence = breakpointPersistence sessions.onStateChange = { [weak self] providerID, state in guard self?.providerID == providerID else { return } self?.state = state @@ -226,6 +232,39 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu watches = [] requestedBreakpointsByFile = [:] areBreakpointsMuted = false + workspaceURL = nil + } + + public func openWorkspace(at workspaceURL: URL) { + let root = workspaceURL.standardizedFileURL + guard self.workspaceURL != root else { return } + self.workspaceURL = root + requestedBreakpointsByFile = [:] + breakpoints = [] + areBreakpointsMuted = false + guard let breakpointPersistence else { return } + do { + guard let snapshot = try breakpointPersistence.loadBreakpoints(for: root), + snapshot.version == DebugBreakpointSnapshot.currentVersion else { return } + areBreakpointsMuted = snapshot.areBreakpointsMuted + for persisted in snapshot.breakpoints { + guard let fileURL = restoredFileURL(for: persisted.relativePath, root: root), + persisted.line > 0 else { continue } + var values = requestedBreakpointsByFile[fileURL] ?? [:] + values[persisted.line] = DebugSourceBreakpoint( + line: persisted.line, + column: persisted.column, + enabled: persisted.enabled, + condition: normalizedOptionalText(persisted.condition), + hitCondition: normalizedOptionalText(persisted.hitCondition), + logMessage: normalizedOptionalText(persisted.logMessage) + ) + requestedBreakpointsByFile[fileURL] = values + } + reconcileBreakpoints() + } catch { + record(error) + } } public func toggleBreakpoint(fileURL: URL, line: Int) { @@ -239,6 +278,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } requestedBreakpointsByFile[normalizedURL] = values.isEmpty ? nil : values reconcileBreakpoints() + persistBreakpoints() synchronizeBreakpoints(for: normalizedURL) } @@ -262,6 +302,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu ) requestedBreakpointsByFile[normalizedURL] = values reconcileBreakpoints() + persistBreakpoints() synchronizeBreakpoints(for: normalizedURL) } @@ -282,6 +323,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu values[breakpoint.line] = nil requestedBreakpointsByFile[fileURL] = values.isEmpty ? nil : values reconcileBreakpoints() + persistBreakpoints() synchronizeBreakpoints(for: fileURL) } @@ -289,11 +331,13 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu let fileURLs = requestedBreakpointsByFile.keys.sorted { $0.path < $1.path } requestedBreakpointsByFile = [:] reconcileBreakpoints() + persistBreakpoints() for fileURL in fileURLs { synchronizeBreakpoints(for: fileURL) } } public func toggleBreakpointMute() { areBreakpointsMuted.toggle() + persistBreakpoints() for fileURL in requestedBreakpointsByFile.keys.sorted(by: { $0.path < $1.path }) { synchronizeBreakpoints(for: fileURL) } @@ -851,6 +895,63 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } } + private func persistBreakpoints() { + guard let breakpointPersistence, let workspaceURL else { return } + let values = breakpoints.compactMap { breakpoint -> PersistedDebugBreakpoint? in + guard let relativePath = workspaceRelativePath( + for: breakpoint.fileURL, + root: workspaceURL + ) else { return nil } + return PersistedDebugBreakpoint( + relativePath: relativePath, + line: breakpoint.line, + column: breakpoint.column, + enabled: breakpoint.enabled, + condition: breakpoint.condition, + hitCondition: breakpoint.hitCondition, + logMessage: breakpoint.logMessage + ) + }.sorted { + ($0.relativePath, $0.line, $0.column ?? 0) + < ($1.relativePath, $1.line, $1.column ?? 0) + } + do { + try breakpointPersistence.saveBreakpoints( + DebugBreakpointSnapshot( + areBreakpointsMuted: areBreakpointsMuted, + breakpoints: values + ), + for: workspaceURL + ) + } catch { + record(error) + } + } + + private func workspaceRelativePath(for fileURL: URL, root: URL) -> String? { + let rootPath = root.standardizedFileURL.path + let filePath = fileURL.standardizedFileURL.path + guard filePath.hasPrefix(rootPath + "/") else { return nil } + let value = String(filePath.dropFirst(rootPath.count + 1)) + guard !value.isEmpty else { return nil } + return value.replacingOccurrences(of: "\\", with: "/") + } + + private func restoredFileURL(for relativePath: String, root: URL) -> URL? { + guard !relativePath.isEmpty, + !relativePath.hasPrefix("/"), + !relativePath.contains("\\") else { return nil } + let components = relativePath.split(separator: "/", omittingEmptySubsequences: false) + guard components.allSatisfy({ !$0.isEmpty && $0 != "." && $0 != ".." }) else { + return nil + } + let value = components.reduce(root) { partial, component in + partial.appendingPathComponent(String(component), isDirectory: false) + }.standardizedFileURL + guard value.path.hasPrefix(root.path + "/") else { return nil } + return value + } + private func effectiveBreakpoints(for fileURL: URL) -> [DebugSourceBreakpoint] { (requestedBreakpointsByFile[fileURL]?.values ?? [:].values) .map { breakpoint in diff --git a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift index be2b6cf9..63bae9a5 100644 --- a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift +++ b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift @@ -443,6 +443,88 @@ struct DebugModuleTests { #expect(feature.watches.first?.value == nil) } + @Test + func projectBreakpointsPersistWithRelativePathsAndRestoreDeterministically() throws { + let descriptor = DebugProviderDescriptor( + id: "java", + displayName: "Java", + fileExtensions: ["java"] + ) + let persistence = RecordingBreakpointPersistence() + let root = URL(fileURLWithPath: "/tmp/persisted-java-breakpoints", isDirectory: true) + let main = root.appendingPathComponent("src/Main.java") + let service = root.appendingPathComponent("src/Service.java") + let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in nil } + let feature = GenericDebugFeatureModel( + sessions: manager, + breakpointPersistence: persistence + ) + + feature.openWorkspace(at: root) + feature.toggleBreakpoint(fileURL: service, line: 21) + feature.toggleBreakpoint(fileURL: main, line: 8) + feature.updateBreakpoint( + fileURL: main, + line: 8, + enabled: false, + condition: "ready", + hitCondition: "3", + logMessage: "ready = {ready}" + ) + feature.toggleBreakpointMute() + + let saved = try #require(persistence.snapshots[root.standardizedFileURL]) + #expect(saved.areBreakpointsMuted) + #expect(saved.breakpoints.map(\.relativePath) == ["src/Main.java", "src/Service.java"]) + #expect(saved.breakpoints.first == PersistedDebugBreakpoint( + relativePath: "src/Main.java", + line: 8, + enabled: false, + condition: "ready", + hitCondition: "3", + logMessage: "ready = {ready}" + )) + + let restored = GenericDebugFeatureModel( + sessions: DebugAdapterSessionManager(providers: [descriptor]) { _, _ in nil }, + breakpointPersistence: persistence + ) + restored.openWorkspace(at: root) + + #expect(restored.areBreakpointsMuted) + #expect(restored.breakpoints.map(\.fileURL) == [main, service]) + #expect(restored.breakpoints.map(\.line) == [8, 21]) + #expect(restored.breakpoints.first?.enabled == false) + #expect(restored.breakpoints.first?.verified == false) + } + + @Test + func projectBreakpointRestoreRejectsPathsOutsideTheWorkspace() { + let root = URL(fileURLWithPath: "/tmp/safe-java-breakpoints", isDirectory: true) + let persistence = RecordingBreakpointPersistence() + persistence.snapshots[root.standardizedFileURL] = DebugBreakpointSnapshot(breakpoints: [ + PersistedDebugBreakpoint(relativePath: "../Outside.java", line: 4), + PersistedDebugBreakpoint(relativePath: "/tmp/Absolute.java", line: 5), + PersistedDebugBreakpoint(relativePath: "src/Main.java", line: 6) + ]) + let manager = DebugAdapterSessionManager( + providers: [DebugProviderDescriptor( + id: "java", + displayName: "Java", + fileExtensions: ["java"] + )] + ) { _, _ in nil } + let feature = GenericDebugFeatureModel( + sessions: manager, + breakpointPersistence: persistence + ) + + feature.openWorkspace(at: root) + + #expect(feature.breakpoints.map(\.fileURL) == [root.appendingPathComponent("src/Main.java")]) + #expect(feature.breakpoints.map(\.line) == [6]) + } + @Test func protocolSessionInitializesAndStopsThroughInjectedTransport() throws { let transport = RecordingTransport() @@ -798,6 +880,18 @@ private final class RecordingTransport: DebugAdapterTransport, DebugAdapterChild } } +private final class RecordingBreakpointPersistence: DebugBreakpointPersisting, @unchecked Sendable { + var snapshots: [URL: DebugBreakpointSnapshot] = [:] + + func loadBreakpoints(for workspaceURL: URL) throws -> DebugBreakpointSnapshot? { + snapshots[workspaceURL.standardizedFileURL] + } + + func saveBreakpoints(_ snapshot: DebugBreakpointSnapshot, for workspaceURL: URL) throws { + snapshots[workspaceURL.standardizedFileURL] = snapshot + } +} + @MainActor private final class RecordingDebugProtocolCore: DebugProtocolCore { private var receiveUpdates: [DebugCoreUpdate] = [] diff --git a/macos/Tests/LitheTests/DebugBreakpointPersistenceTests.swift b/macos/Tests/LitheTests/DebugBreakpointPersistenceTests.swift new file mode 100644 index 00000000..be1080af --- /dev/null +++ b/macos/Tests/LitheTests/DebugBreakpointPersistenceTests.swift @@ -0,0 +1,63 @@ +import Foundation +import LitheDebugModule +@testable import Lithe +import Testing + +struct DebugBreakpointPersistenceTests { + @Test + func macStoreKeepsBreakpointSnapshotsSeparateByProject() throws { + let preferences = DebugBreakpointTestStore() + let store = MacDebugBreakpointStore(store: preferences) + let firstRoot = URL(fileURLWithPath: "/tmp/first-debug-project", isDirectory: true) + let secondRoot = URL(fileURLWithPath: "/tmp/second-debug-project", isDirectory: true) + let first = DebugBreakpointSnapshot( + areBreakpointsMuted: true, + breakpoints: [PersistedDebugBreakpoint( + relativePath: "src/Main.java", + line: 12, + enabled: false, + condition: "user != null", + hitCondition: "2", + logMessage: "user = {user}" + )] + ) + let second = DebugBreakpointSnapshot( + breakpoints: [PersistedDebugBreakpoint(relativePath: "App.java", line: 4)] + ) + + try store.saveBreakpoints(first, for: firstRoot) + try store.saveBreakpoints(second, for: secondRoot) + + #expect(try store.loadBreakpoints(for: firstRoot) == first) + #expect(try store.loadBreakpoints(for: secondRoot) == second) + let firstData = try #require(preferences.data( + forKey: "lithe.debug.breakpoints." + firstRoot.path + )) + #expect(!String(decoding: firstData, as: UTF8.self).contains(firstRoot.path)) + #expect(try store.loadBreakpoints( + for: URL(fileURLWithPath: "/tmp/unknown-debug-project", isDirectory: true) + ) == nil) + } + + @Test + func macStoreReportsCorruptBreakpointData() { + let preferences = DebugBreakpointTestStore() + let root = URL(fileURLWithPath: "/tmp/corrupt-debug-project", isDirectory: true) + preferences.set(Data("not-json".utf8), forKey: "lithe.debug.breakpoints." + root.path) + let store = MacDebugBreakpointStore(store: preferences) + + #expect(throws: MacDebugBreakpointStoreError.self) { + try store.loadBreakpoints(for: root) + } + } +} + +private final class DebugBreakpointTestStore: KeyValueStore, @unchecked Sendable { + private var values: [String: Any] = [:] + + func data(forKey key: String) -> Data? { values[key] as? Data } + func object(forKey key: String) -> Any? { values[key] } + func string(forKey key: String) -> String? { values[key] as? String } + func stringArray(forKey key: String) -> [String]? { values[key] as? [String] } + func set(_ value: Any?, forKey key: String) { values[key] = value } +} From 074133b4ef288efbe9a3e65d3ed50b707b5c281e Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sat, 29 Aug 2026 23:03:06 +0800 Subject: [PATCH 25/66] feat(debug): edit breakpoints from editor gutter --- .../AppModel/AppModel+Development.swift | 38 ++++++++ .../Lithe/Models/AppModel/AppModel.swift | 3 + .../Lithe/Views/Debug/GenericDebugView.swift | 4 +- .../Lithe/Views/Editor/CodeEditorView.swift | 92 +++++++++++++++---- .../Lithe/Views/Workbench/WorkbenchView.swift | 11 +++ .../LitheTests/EditorGutterLayoutTests.swift | 28 ++++++ 6 files changed, 156 insertions(+), 20 deletions(-) diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift index 428bb2df..29da04ff 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift @@ -503,6 +503,44 @@ extension AppModel { } } + func editDebugBreakpoint(fileURL: URL, line: Int) { + let normalizedURL = fileURL.standardizedFileURL + pendingDebugBreakpointEditor = genericDebugFeatureIfActive?.breakpoints + .filter { + $0.fileURL.standardizedFileURL == normalizedURL && $0.line == line + } + .min { ($0.column ?? 0) < ($1.column ?? 0) } + } + + func updateDebugBreakpoint( + _ breakpoint: GenericDebugBreakpoint, + enabled: Bool, + condition: String?, + hitCondition: String?, + logMessage: String? + ) { + pendingDebugBreakpointEditor = nil + guard let expectedWorkspaceURL = workspaceURL, + workspaceRelativePath( + for: breakpoint.fileURL, + root: expectedWorkspaceURL + ) != nil else { return } + Task { [weak self] in + guard let self, + self.workspaceURL == expectedWorkspaceURL, + let feature = await activateDebugModule()?.genericFeature, + self.workspaceURL == expectedWorkspaceURL else { return } + feature.updateBreakpoint( + fileURL: breakpoint.fileURL, + line: breakpoint.line, + enabled: enabled, + condition: condition, + hitCondition: hitCondition, + logMessage: logMessage + ) + } + } + func runToCursor(fileURL: URL, line: Int, column: Int) { guard let feature = genericDebugFeatureIfActive, feature.state == .paused, diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 6feaff39..7d15f297 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -122,6 +122,7 @@ final class AppModel: ObservableObject, Identifiable { @Published var isMavenVisible = false @Published var isSpringVisible = false @Published var isDebugVisible = false + @Published var pendingDebugBreakpointEditor: GenericDebugBreakpoint? @Published var isDiscourseCommunityVisible = false @Published var isImplementationChooserVisible = false var languageProviderCatalog: LanguageProviderCatalog { languageToolingFeature.catalog } @@ -932,6 +933,7 @@ final class AppModel: ObservableObject, Identifiable { mavenFeatureIfActive?.reset() runFeatureIfActive?.reset() genericDebugFeatureIfActive?.reset() + pendingDebugBreakpointEditor = nil clearLanguageNavigationProjection() javaFeature.stop() springFeature.reset() @@ -1043,6 +1045,7 @@ final class AppModel: ObservableObject, Identifiable { mavenFeatureIfActive?.reset() runFeatureIfActive?.reset() genericDebugFeatureIfActive?.reset() + pendingDebugBreakpointEditor = nil javaFeature.stop() springFeature.reset() editorChrome.reset() diff --git a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift index 49ad6488..0f9ae190 100644 --- a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift +++ b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift @@ -909,7 +909,7 @@ struct GenericDebugView: View { } } -private struct BreakpointEditorValue { +struct BreakpointEditorValue { let enabled: Bool let condition: String? let hitCondition: String? @@ -1070,7 +1070,7 @@ private struct ExceptionBreakpointEditorView: View { } } -private struct BreakpointEditorView: View { +struct BreakpointEditorView: View { @Environment(\.dismiss) private var dismiss let breakpoint: GenericDebugBreakpoint let onSave: (BreakpointEditorValue) -> Void diff --git a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift index f8ff625a..4e1ec4fe 100644 --- a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift +++ b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift @@ -110,6 +110,10 @@ struct EditorDebugBreakpointState: Equatable { let verified: Bool } +enum EditorDebugBreakpointLocation { + static func productLine(forEditorLine line: Int) -> Int { line + 1 } +} + struct EditorLanguageFeatureTransition: Equatable { let refreshImplementationMarkers: Bool let clearImplementationMarkers: Bool @@ -1283,19 +1287,34 @@ struct CodeEditorView: NSViewRepresentable { gutter?.updateDebugBreakpointLines( debugBreakpointStates, onToggle: { [weak model] line in - model?.toggleDebugBreakpoint(fileURL: url, line: line) + model?.toggleDebugBreakpoint( + fileURL: url, + line: EditorDebugBreakpointLocation.productLine(forEditorLine: line) + ) + }, + onEdit: { [weak model] line in + model?.editDebugBreakpoint( + fileURL: url, + line: EditorDebugBreakpointLocation.productLine(forEditorLine: line) + ) }, onRemove: { [weak model] line in + let productLine = EditorDebugBreakpointLocation.productLine( + forEditorLine: line + ) guard let feature = model?.genericDebugFeatureIfActive, let breakpoint = feature.breakpoints.first(where: { - $0.fileURL.standardizedFileURL == url && $0.line == line + $0.fileURL.standardizedFileURL == url && $0.line == productLine }) else { return } feature.removeBreakpoint(breakpoint) }, onSetEnabled: { [weak model] line, enabled in + let productLine = EditorDebugBreakpointLocation.productLine( + forEditorLine: line + ) guard let feature = model?.genericDebugFeatureIfActive, let breakpoint = feature.breakpoints.first(where: { - $0.fileURL.standardizedFileURL == url && $0.line == line + $0.fileURL.standardizedFileURL == url && $0.line == productLine }) else { return } feature.setBreakpointEnabled(breakpoint, enabled: enabled) }, @@ -1303,7 +1322,11 @@ struct CodeEditorView: NSViewRepresentable { model?.genericDebugFeatureIfActive?.toggleBreakpointMute() }, onRunToCursor: { [weak model] line in - model?.runToCursor(fileURL: url, line: line + 1, column: 1) + model?.runToCursor( + fileURL: url, + line: EditorDebugBreakpointLocation.productLine(forEditorLine: line), + column: 1 + ) }, isRunToCursorEnabled: isRunToCursorEnabled, areBreakpointsMuted: areBreakpointsMuted @@ -3181,6 +3204,7 @@ final class LineNumberGutterView: NSView { private var debugBreakpointMessagesByLine: [Int: String] = [:] private var currentExecutionLine: Int? private var onToggleDebugBreakpoint: ((Int) -> Void)? + private var onEditDebugBreakpoint: ((Int) -> Void)? private var onRemoveDebugBreakpoint: ((Int) -> Void)? private var onSetDebugBreakpointEnabled: ((Int, Bool) -> Void)? private var onToggleAllDebugBreakpoints: (() -> Void)? @@ -3346,6 +3370,7 @@ final class LineNumberGutterView: NSView { func updateDebugBreakpointLines( _ states: [Int: EditorDebugBreakpointState], onToggle: @escaping (Int) -> Void, + onEdit: ((Int) -> Void)? = nil, onRemove: ((Int) -> Void)? = nil, onSetEnabled: ((Int, Bool) -> Void)? = nil, onToggleAll: (() -> Void)? = nil, @@ -3358,6 +3383,7 @@ final class LineNumberGutterView: NSView { uniqueKeysWithValues: states.map { (max(0, $0.key - 1), $0.value) } ) onToggleDebugBreakpoint = onToggle + onEditDebugBreakpoint = onEdit onRemoveDebugBreakpoint = onRemove onSetDebugBreakpointEnabled = onSetEnabled onToggleAllDebugBreakpoints = onToggleAll @@ -4029,20 +4055,8 @@ final class LineNumberGutterView: NSView { let localX = point.x - editorGutterOriginX if gutterLayout.breakpointRange.contains(localX), let line = editorLine(at: point), - let state = debugBreakpointStatesByLine[line] { - contextDebugBreakpointLine = line - let menu = NSMenu(title: "Breakpoint") - let toggleTitle = state.enabled ? "Disable Breakpoint" : "Enable Breakpoint" - menu.addItem(withTitle: toggleTitle, action: #selector(toggleDebugBreakpointFromMenu), keyEquivalent: "") - menu.items.last?.target = self - menu.addItem(withTitle: "Remove Breakpoint", action: #selector(removeDebugBreakpointFromMenu), keyEquivalent: "") - menu.items.last?.target = self - if onToggleAllDebugBreakpoints != nil { - menu.addItem(.separator()) - menu.addItem(withTitle: areBreakpointsMuted ? "Unmute All Breakpoints" : "Mute All Breakpoints", action: #selector(toggleAllDebugBreakpointsFromMenu), keyEquivalent: "") - menu.items.last?.target = self - } - return menu + debugBreakpointStatesByLine[line] != nil { + return debugBreakpointContextMenu(forLine: line) } if gutterLayout.lineNumberRange.contains(localX), let line = editorLine(at: point), @@ -4084,6 +4098,48 @@ final class LineNumberGutterView: NSView { return menu } + func debugBreakpointContextMenu(forLine line: Int) -> NSMenu? { + guard let state = debugBreakpointStatesByLine[line] else { return nil } + contextDebugBreakpointLine = line + let menu = NSMenu(title: "Breakpoint") + if onEditDebugBreakpoint != nil { + menu.addItem( + withTitle: "Edit Breakpoint…", + action: #selector(editDebugBreakpointFromMenu), + keyEquivalent: "" + ) + menu.items.last?.target = self + } + let toggleTitle = state.enabled ? "Disable Breakpoint" : "Enable Breakpoint" + menu.addItem( + withTitle: toggleTitle, + action: #selector(toggleDebugBreakpointFromMenu), + keyEquivalent: "" + ) + menu.items.last?.target = self + menu.addItem( + withTitle: "Remove Breakpoint", + action: #selector(removeDebugBreakpointFromMenu), + keyEquivalent: "" + ) + menu.items.last?.target = self + if onToggleAllDebugBreakpoints != nil { + menu.addItem(.separator()) + menu.addItem( + withTitle: areBreakpointsMuted + ? "Unmute All Breakpoints" : "Mute All Breakpoints", + action: #selector(toggleAllDebugBreakpointsFromMenu), + keyEquivalent: "" + ) + menu.items.last?.target = self + } + return menu + } + + @objc func editDebugBreakpointFromMenu() { + if let line = contextDebugBreakpointLine { onEditDebugBreakpoint?(line) } + } + @objc private func toggleDebugBreakpointFromMenu() { guard let line = contextDebugBreakpointLine, let state = debugBreakpointStatesByLine[line] else { return } diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift index a0eccedd..e2d6c7ce 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift @@ -88,6 +88,17 @@ struct WorkbenchView: View { } } } + .sheet(item: $model.pendingDebugBreakpointEditor) { breakpoint in + BreakpointEditorView(breakpoint: breakpoint) { value in + model.updateDebugBreakpoint( + breakpoint, + enabled: value.enabled, + condition: value.condition, + hitCondition: value.hitCondition, + logMessage: value.logMessage + ) + } + } .onAppear { updateWorkbenchBackgroundImage(model.workbenchBackgroundFeature.imageData) } diff --git a/macos/Tests/LitheTests/EditorGutterLayoutTests.swift b/macos/Tests/LitheTests/EditorGutterLayoutTests.swift index c5ff3af7..0488ffe6 100644 --- a/macos/Tests/LitheTests/EditorGutterLayoutTests.swift +++ b/macos/Tests/LitheTests/EditorGutterLayoutTests.swift @@ -4,6 +4,34 @@ import Testing @Suite("Editor gutter layout") struct EditorGutterLayoutTests { + @Test + func editorBreakpointLinesConvertToOneBasedProductLines() { + #expect(EditorDebugBreakpointLocation.productLine(forEditorLine: 0) == 1) + #expect(EditorDebugBreakpointLocation.productLine(forEditorLine: 7) == 8) + } + + @MainActor + @Test + func breakpointContextMenuOffersEditingAndDispatchesTheEditorLine() throws { + let gutter = LineNumberGutterView(frame: NSRect(x: 0, y: 0, width: 80, height: 200)) + var editedLine: Int? + gutter.updateDebugBreakpointLines( + [7: EditorDebugBreakpointState(enabled: true, verified: false)], + onToggle: { _ in }, + onEdit: { editedLine = $0 } + ) + + let menu = try #require(gutter.debugBreakpointContextMenu(forLine: 6)) + #expect(menu.items.map(\.title) == [ + "Edit Breakpoint…", + "Disable Breakpoint", + "Remove Breakpoint" + ]) + + gutter.editDebugBreakpointFromMenu() + #expect(editedLine == 6) + } + @MainActor @Test func foldingLinesChangesTheOverlayTargetGeometry() throws { From 26e4913ebb71dffb68181f88b88c56c46da4b4cc Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sat, 29 Aug 2026 23:10:00 +0800 Subject: [PATCH 26/66] feat(debug): attach to running JVMs --- .../AppModel/AppModel+Development.swift | 47 ++++++++++++++ .../DebugLaunchConfigurationResolver.swift | 24 +++++++ .../Lithe/Views/Debug/GenericDebugView.swift | 63 +++++++++++++++++++ .../RunConfigurationIntegrationTests.swift | 20 ++++++ 4 files changed, 154 insertions(+) diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift index 29da04ff..dbc10d81 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift @@ -322,6 +322,49 @@ extension AppModel { Task { [weak self] in await self?.startDebuggingAfterActivation() } } + func attachJavaDebugger(host: String, port: Int) { + Task { [weak self] in + await self?.attachJavaDebuggerAfterActivation(host: host, port: port) + } + } + + private func attachJavaDebuggerAfterActivation(host: String, port: Int) async { + guard let workspaceURL, + await activateDebugModule() != nil else { return } + let sourceURL = ([activeDocument?.url].compactMap { $0 } + projectFiles) + .map(\.standardizedFileURL) + .first { + languageProviderCatalog.provider(for: $0)?.id == "java" + } + guard let sourceURL else { + showNotification("Open a Java project before connecting the debugger") + return + } + let configuration: DebugLaunchConfiguration + do { + configuration = try debugLaunchConfigurationResolver.resolveJavaAttach( + host: host, + port: port + ) + } catch { + showNotification(error.localizedDescription) + return + } + guard let genericDebugFeature = genericDebugFeatureIfActive, + genericDebugFeature.start( + fileURL: sourceURL, + rootURL: workspaceURL, + configuration: configuration + ) else { + showNotification( + genericDebugFeatureIfActive?.errorMessage ?? "Could not connect to the JVM" + ) + isDebugVisible = true + return + } + showDebugToolWindow() + } + private func startDebuggingAfterActivation() async { guard await activateExecutionModule() != nil, await activateDebugModule() != nil else { return } @@ -630,6 +673,10 @@ extension AppModel { isDebugVisible = true return } + showDebugToolWindow() + } + + private func showDebugToolWindow() { isDebugVisible = true isGitLogVisible = false isTerminalVisible = false diff --git a/macos/Sources/Lithe/Services/Debug/DebugLaunchConfigurationResolver.swift b/macos/Sources/Lithe/Services/Debug/DebugLaunchConfigurationResolver.swift index 6ddee5f8..d9b0aa80 100644 --- a/macos/Sources/Lithe/Services/Debug/DebugLaunchConfigurationResolver.swift +++ b/macos/Sources/Lithe/Services/Debug/DebugLaunchConfigurationResolver.swift @@ -4,6 +4,8 @@ import LitheCoreContracts enum DebugLaunchConfigurationResolutionError: LocalizedError, Equatable { case unsupportedProvider(String) case javaLaunchTargetUnavailable + case invalidJavaAttachHost + case invalidJavaAttachPort case noRustBinaryConfiguration case rustExecutableNotBuilt(URL, binary: String) @@ -13,6 +15,10 @@ enum DebugLaunchConfigurationResolutionError: LocalizedError, Equatable { return "The \(provider) Debug Adapter is not installed yet." case .javaLaunchTargetUnavailable: return "The Java language service could not resolve a main class for this file." + case .invalidJavaAttachHost: + return "Enter the host name of the running JVM." + case .invalidJavaAttachPort: + return "Enter a JVM debug port between 1 and 65535." case .noRustBinaryConfiguration: return "No Cargo binary run configuration matches this Rust file." case .rustExecutableNotBuilt(let url, let binary): @@ -104,6 +110,24 @@ struct DebugLaunchConfigurationResolver { } } + func resolveJavaAttach(host: String, port: Int) throws -> DebugLaunchConfiguration { + let normalizedHost = host.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedHost.isEmpty else { + throw DebugLaunchConfigurationResolutionError.invalidJavaAttachHost + } + guard (1...65_535).contains(port) else { + throw DebugLaunchConfigurationResolutionError.invalidJavaAttachPort + } + return DebugLaunchConfiguration( + name: "\(normalizedHost):\(port)", + request: .attach, + arguments: [ + "hostName": .string(normalizedHost), + "port": .integer(port) + ] + ) + } + private func javaConfiguration( documentURL: URL, workspaceURL: URL, diff --git a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift index 0f9ae190..62fdb497 100644 --- a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift +++ b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift @@ -14,6 +14,7 @@ struct GenericDebugView: View { @State private var watchEditor: WatchEditorContext? @State private var smartStepTargets: [DebugStepInTarget] = [] @State private var isSmartStepPickerPresented = false + @State private var isJavaAttachPresented = false var body: some View { VStack(spacing: 0) { @@ -96,6 +97,11 @@ struct GenericDebugView: View { } } } + .sheet(isPresented: $isJavaAttachPresented) { + JavaAttachView { host, port in + model.attachJavaDebugger(host: host, port: port) + } + } } private var header: some View { @@ -118,6 +124,12 @@ struct GenericDebugView: View { .lineLimit(1) } Spacer() + Button { isJavaAttachPresented = true } label: { + Image(systemName: "link") + } + .litheIconButton() + .disabled(feature.isSessionActive) + .help("Connect to running JVM") controlButton( feature.state == .running ? "pause.fill" : "play.fill", help: feature.state == .running ? "Pause" : "Continue", @@ -758,6 +770,9 @@ struct GenericDebugView: View { .buttonStyle(.borderedProminent) .controlSize(.small) .tint(LitheTheme.accent) + Button("Connect to Running JVM") { isJavaAttachPresented = true } + .buttonStyle(.bordered) + .controlSize(.small) } .frame(maxWidth: .infinity, maxHeight: .infinity) } @@ -909,6 +924,54 @@ struct GenericDebugView: View { } } +private struct JavaAttachView: View { + @Environment(\.dismiss) private var dismiss + @State private var host = "localhost" + @State private var port = "5005" + let onAttach: (String, Int) -> Void + + private var parsedPort: Int? { + guard let value = Int(port), (1...65_535).contains(value) else { return nil } + return value + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Connect to Running JVM") + .font(.system(size: 14, weight: .semibold)) + Grid(alignment: .leading, horizontalSpacing: 10, verticalSpacing: 10) { + GridRow { + Text("Host") + TextField("localhost", text: $host) + .textFieldStyle(.roundedBorder) + } + GridRow { + Text("Port") + TextField("5005", text: $port) + .textFieldStyle(.roundedBorder) + } + } + HStack { + Spacer() + Button("Cancel", role: .cancel) { dismiss() } + .keyboardShortcut(.cancelAction) + Button("Connect") { + guard let parsedPort else { return } + onAttach(host.trimmingCharacters(in: .whitespacesAndNewlines), parsedPort) + dismiss() + } + .keyboardShortcut(.defaultAction) + .disabled( + host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || parsedPort == nil + ) + } + } + .padding(18) + .frame(width: 360) + } +} + struct BreakpointEditorValue { let enabled: Bool let condition: String? diff --git a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift index 2c423fa1..57d91229 100644 --- a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -372,6 +372,26 @@ struct RunConfigurationIntegrationTests { #expect(configuration.arguments["env"] == .object(["APP_ENV": .string("dev")])) } + @Test + func javaAttachUsesTheSharedDAPSessionWithValidatedEndpointArguments() throws { + let resolver = DebugLaunchConfigurationResolver(fileExists: { _ in true }) + + let configuration = try resolver.resolveJavaAttach(host: " localhost ", port: 5005) + + #expect(configuration.name == "localhost:5005") + #expect(configuration.request == .attach) + #expect(configuration.arguments == [ + "hostName": .string("localhost"), + "port": .integer(5005) + ]) + #expect(throws: DebugLaunchConfigurationResolutionError.invalidJavaAttachHost) { + try resolver.resolveJavaAttach(host: " ", port: 5005) + } + #expect(throws: DebugLaunchConfigurationResolutionError.invalidJavaAttachPort) { + try resolver.resolveJavaAttach(host: "localhost", port: 65_536) + } + } + @Test func macToolDiscoveryReportsProjectHomebrewAndXcodeSources() { let root = URL(fileURLWithPath: "/tmp/mac-tool-project", isDirectory: true) From 7d43a908866f823d860da100853c97b8069a157e Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sat, 29 Aug 2026 23:21:59 +0800 Subject: [PATCH 27/66] feat(debug): evaluate variables on editor hover --- .../AppModel/AppModel+Development.swift | 19 +++ .../Lithe/Views/Editor/CodeEditorView.swift | 153 +++++++++++++++++- .../GenericDebugFeatureModel.swift | 23 +++ .../DebugModuleTests.swift | 50 ++++++ .../LitheTests/EditorGutterLayoutTests.swift | 13 ++ 5 files changed, 257 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift index dbc10d81..bcc46fd4 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift @@ -611,6 +611,25 @@ extension AppModel { } } + func requestDebugHover( + expression: String, + completion: @escaping (String?) -> Void + ) { + guard let feature = genericDebugFeatureIfActive, + feature.state == .paused else { + completion(nil) + return + } + feature.evaluateForHover(expression) { variable in + guard let variable else { + completion(nil) + return + } + let type = variable.type.map { " : \($0)" } ?? "" + completion("\(expression)\(type) = \(variable.value)") + } + } + private func startGenericDebugging(_ document: EditorDocument) { Task { [weak self] in await self?.startGenericDebuggingAfterActivation(document) } } diff --git a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift index 4e1ec4fe..2484b176 100644 --- a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift +++ b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift @@ -114,6 +114,30 @@ enum EditorDebugBreakpointLocation { static func productLine(forEditorLine line: Int) -> Int { line + 1 } } +enum DebugHoverExpressionResolver { + static func expression(at location: Int, in source: NSString) -> (String, NSRange)? { + guard source.length > 0 else { return nil } + let characters = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "_$")) + let position = min(max(0, location), source.length - 1) + guard let scalar = UnicodeScalar(source.character(at: position)), + characters.contains(scalar) else { return nil } + var start = position + var end = position + 1 + while start > 0, + let scalar = UnicodeScalar(source.character(at: start - 1)), + characters.contains(scalar) { start -= 1 } + while end < source.length, + let scalar = UnicodeScalar(source.character(at: end)), + characters.contains(scalar) { end += 1 } + let range = NSRange(location: start, length: end - start) + let value = source.substring(with: range) + guard value.first?.isLetter == true || value.first == "_" || value.first == "$" else { + return nil + } + return (value, range) + } +} + struct EditorLanguageFeatureTransition: Equatable { let refreshImplementationMarkers: Bool let clearImplementationMarkers: Bool @@ -411,6 +435,9 @@ struct CodeEditorView: NSViewRepresentable { column: column + 1 ) } + textView.onDebugHover = { [weak model] expression, completion in + model?.requestDebugHover(expression: expression, completion: completion) + } textView.onFindStateChange = { [weak coordinator = context.coordinator] index, count in coordinator?.scheduleFindStateUpdate(currentIndex: index, count: count) } @@ -485,6 +512,7 @@ struct CodeEditorView: NSViewRepresentable { let debugFeature = model.genericDebugFeatureIfActive textView.isRunToCursorEnabled = debugFeature?.state == .paused && debugFeature?.capabilities.supportsGotoTargetsRequest == true + textView.isDebugHoverEnabled = debugFeature?.state == .paused context.coordinator.restoreViewportWhenReady() return container } @@ -512,6 +540,13 @@ struct CodeEditorView: NSViewRepresentable { context.coordinator.colorTheme = settings.colorTheme context.coordinator.requestInitialFocusIfNeeded() + if let codeTextView = textView as? CodeTextView { + let debugFeature = model.genericDebugFeatureIfActive + codeTextView.isRunToCursorEnabled = debugFeature?.state == .paused + && debugFeature?.capabilities.supportsGotoTargetsRequest == true + codeTextView.isDebugHoverEnabled = debugFeature?.state == .paused + } + let languageFeatures = model.languageToolingSessionsIfActive?.features(for: document.url) ?? [] let fontSize = settings.editorFontSize let tabWidth = settings.tabWidth @@ -1592,6 +1627,12 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { var onCodeActionsRequested: ((Int, Int) -> Void)? var onRunToCursor: ((Int, Int) -> Void)? var isRunToCursorEnabled = false + var onDebugHover: ((String, @escaping (String?) -> Void) -> Void)? + var isDebugHoverEnabled = false { + didSet { + if !isDebugHoverEnabled { clearDebugHover() } + } + } var onPasteImage: (() -> Bool)? private var findMatchRanges: [NSRange] = [] @@ -1600,6 +1641,9 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { private var lastCaretBackgroundRanges: [NSRange] = [] private var completionItemsByID: [String: LanguageServerCompletionItem] = [:] private var languageHoverPopover: NSPopover? + private var debugHoverPopover: NSPopover? + private var debugHoverWorkItem: DispatchWorkItem? + private var pendingDebugHover: (expression: String, range: NSRange)? private var currentLineColor = CodeEditorPalette.dark.currentLine private var bracketColor = CodeEditorPalette.dark.bracket @@ -2586,18 +2630,24 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { let summaryRegion = foldSummaryRegion(at: point) updateFoldHover(to: summaryRegion?.id) if summaryRegion != nil { + clearDebugHover() NSCursor.pointingHand.set() return } if hitTest(point) is CodeVisionLinkButton { + clearDebugHover() NSCursor.pointingHand.set() return } if isLanguageNavigationEnabled, hasNavigationModifier(event.modifierFlags) { updateLinkHighlight(at: point) - if linkRange != nil { return } + if linkRange != nil { + clearDebugHover() + return + } } + updateDebugHover(at: point) NSCursor.iBeam.set() } @@ -2618,12 +2668,14 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { super.mouseExited(with: event) updateFoldHover(to: nil) clearLinkHighlight() + clearDebugHover() NSCursor.arrow.set() } override func resignFirstResponder() -> Bool { updateFoldHover(to: nil) clearLinkHighlight() + clearDebugHover() return super.resignFirstResponder() } @@ -2839,6 +2891,105 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { return (text, range) } + private func updateDebugHover(at point: NSPoint) { + guard isDebugHoverEnabled, + let characterIndex = characterIndex(at: point), + let resolved = DebugHoverExpressionResolver.expression( + at: characterIndex, + in: string as NSString + ), + let layoutManager, + let textContainer else { + clearDebugHover() + return + } + let glyphRange = layoutManager.glyphRange( + forCharacterRange: resolved.1, + actualCharacterRange: nil + ) + let glyphRect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) + let containerPoint = NSPoint( + x: point.x - textContainerOrigin.x, + y: point.y - textContainerOrigin.y + ) + guard glyphRect.insetBy(dx: -2, dy: -2).contains(containerPoint) else { + clearDebugHover() + return + } + if pendingDebugHover?.expression == resolved.0, + pendingDebugHover?.range == resolved.1 { return } + debugHoverWorkItem?.cancel() + debugHoverPopover?.close() + pendingDebugHover = (resolved.0, resolved.1) + let workItem = DispatchWorkItem { [weak self] in + guard let self, + self.pendingDebugHover?.expression == resolved.0, + self.pendingDebugHover?.range == resolved.1 else { return } + self.onDebugHover?(resolved.0) { [weak self] value in + guard let self, + let value, + self.pendingDebugHover?.expression == resolved.0, + self.pendingDebugHover?.range == resolved.1 else { return } + self.presentDebugHover(value, range: resolved.1) + } + } + debugHoverWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 0.45, execute: workItem) + } + + private func presentDebugHover(_ value: String, range: NSRange) { + guard let layoutManager, let textContainer else { return } + let glyphRange = layoutManager.glyphRange( + forCharacterRange: range, + actualCharacterRange: nil + ) + var anchor = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) + anchor.origin.x += textContainerOrigin.x + anchor.origin.y += textContainerOrigin.y + let label = NSTextField(wrappingLabelWithString: value) + label.font = .monospacedSystemFont(ofSize: 12, weight: .regular) + label.textColor = NSColor(white: 0.9, alpha: 1) + label.maximumNumberOfLines = 6 + label.preferredMaxLayoutWidth = 420 + let controller = NSViewController() + let container = NSView() + label.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(label) + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 10), + label.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -10), + label.topAnchor.constraint(equalTo: container.topAnchor, constant: 8), + label.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -8) + ]) + container.wantsLayer = true + container.layer?.backgroundColor = NSColor( + red: 0.105, + green: 0.11, + blue: 0.12, + alpha: 1 + ).cgColor + controller.view = container + let fittingSize = label.fittingSize + controller.preferredContentSize = NSSize( + width: min(440, fittingSize.width + 20), + height: min(140, fittingSize.height + 16) + ) + let popover = NSPopover() + popover.behavior = .transient + popover.animates = true + popover.contentViewController = controller + popover.show(relativeTo: anchor, of: self, preferredEdge: .maxY) + debugHoverPopover = popover + } + + private func clearDebugHover() { + debugHoverWorkItem?.cancel() + debugHoverWorkItem = nil + pendingDebugHover = nil + debugHoverPopover?.close() + debugHoverPopover = nil + } + private func enclosingCodeScope(at caret: Int, in source: NSString) -> NSRange? { var start: Int? var depth = 0 diff --git a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift index 869f5b46..638c53a2 100644 --- a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift +++ b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift @@ -763,6 +763,29 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } } + public func evaluateForHover( + _ expression: String, + completion: @escaping (DebugVariable?) -> Void + ) { + let value = expression.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty, + state == .paused, + let session = activeSession else { + completion(nil) + return + } + let frameID = selectedFrameID + session.evaluate(value, frameID: frameID) { [weak self] result in + guard let self, + self.state == .paused, + self.selectedFrameID == frameID else { + completion(nil) + return + } + completion(try? result.get()) + } + } + public func clearOutput() { output = "" } private var activeSession: (any DebugAdapterControllingSession)? { diff --git a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift index 63bae9a5..df088549 100644 --- a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift +++ b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift @@ -361,6 +361,56 @@ struct DebugModuleTests { transport.emitData(Data("watch-response".utf8)) #expect(feature.watches.first?.value == "8") + var hoverValue: DebugVariable? + feature.evaluateForHover("count") { hoverValue = $0 } + let hoverOperationID = try #require(core.lastInspectionOperationID) + core.enqueueReceive(sessionID: "java-breakpoints", state: "paused", events: [[ + "sequence": 7, + "type": "operationCompleted", + "operationId": hoverOperationID, + "result": [ + "kind": "evaluate", + "variable": [ + "name": "count", + "value": "7", + "type": "int", + "variablesReference": 0 + ] + ] + ]]) + transport.emitData(Data("hover-response".utf8)) + #expect(hoverValue?.value == "7") + + var staleHoverValue: DebugVariable? + feature.evaluateForHover("count") { staleHoverValue = $0 } + let staleHoverOperationID = try #require(core.lastInspectionOperationID) + core.enqueueReceive(sessionID: "java-breakpoints", state: "running", events: [[ + "sequence": 8, + "type": "stateChanged", + "state": "running" + ], [ + "sequence": 9, + "type": "operationCompleted", + "operationId": staleHoverOperationID, + "result": [ + "kind": "evaluate", + "variable": [ + "name": "count", + "value": "8", + "type": "int", + "variablesReference": 0 + ] + ] + ]]) + transport.emitData(Data("stale-hover-response".utf8)) + #expect(staleHoverValue == nil) + core.enqueueReceive(sessionID: "java-breakpoints", state: "paused", events: [[ + "sequence": 10, + "type": "stopped", + "reason": "breakpoint" + ]]) + transport.emitData(Data("next-stopped-event".utf8)) + feature.requestDataBreakpoint(for: DebugVariable( id: "count", name: "count", diff --git a/macos/Tests/LitheTests/EditorGutterLayoutTests.swift b/macos/Tests/LitheTests/EditorGutterLayoutTests.swift index 0488ffe6..803a52c9 100644 --- a/macos/Tests/LitheTests/EditorGutterLayoutTests.swift +++ b/macos/Tests/LitheTests/EditorGutterLayoutTests.swift @@ -4,6 +4,19 @@ import Testing @Suite("Editor gutter layout") struct EditorGutterLayoutTests { + @Test + func debugHoverResolvesOnlyJavaIdentifierTokens() throws { + let source = "userService.login(userName)" as NSString + + let service = try #require(DebugHoverExpressionResolver.expression(at: 5, in: source)) + let argument = try #require(DebugHoverExpressionResolver.expression(at: 22, in: source)) + + #expect(service.0 == "userService") + #expect(source.substring(with: service.1) == "userService") + #expect(argument.0 == "userName") + #expect(DebugHoverExpressionResolver.expression(at: 11, in: source) == nil) + } + @Test func editorBreakpointLinesConvertToOneBasedProductLines() { #expect(EditorDebugBreakpointLocation.productLine(forEditorLine: 0) == 1) From 2b38b662569a9f22c0da55f5041ff66c1acd1a6d Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sat, 29 Aug 2026 23:34:23 +0800 Subject: [PATCH 28/66] feat(debug): show inline variable values --- .../Lithe/Views/Editor/CodeEditorView.swift | 227 ++++++++++++++++++ .../LitheTests/EditorGutterLayoutTests.swift | 68 ++++++ 2 files changed, 295 insertions(+) diff --git a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift index 2484b176..8ee1fa9d 100644 --- a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift +++ b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift @@ -110,6 +110,102 @@ struct EditorDebugBreakpointState: Equatable { let verified: Bool } +struct EditorInlineDebugValue: Equatable { + let name: String + let value: String +} + +enum EditorInlineDebugValueProjection { + static let maximumVisibleValues = 4 + static let maximumValueCharacters = 80 + + static func values( + forLine line: Int, + in source: NSString, + variables: [EditorInlineDebugValue] + ) -> [EditorInlineDebugValue] { + guard let lineRange = lineRange(for: line, in: source) else { return [] } + let lineSource = source.substring(with: lineRange) as NSString + let candidates = Dictionary( + variables.map { ($0.name, $0) }, + uniquingKeysWith: { first, _ in first } + ) + var matched: [(location: Int, value: EditorInlineDebugValue)] = [] + for (name, variable) in candidates { + guard isIdentifier(name) else { continue } + var searchLocation = 0 + while searchLocation < lineSource.length { + let range = lineSource.range( + of: name, + options: [], + range: NSRange( + location: searchLocation, + length: lineSource.length - searchLocation + ) + ) + guard range.location != NSNotFound else { break } + if hasIdentifierBoundaries(range: range, in: lineSource) { + matched.append((range.location, normalized(variable))) + break + } + searchLocation = NSMaxRange(range) + } + } + return matched + .sorted { ($0.location, $0.value.name) < ($1.location, $1.value.name) } + .prefix(maximumVisibleValues) + .map(\.value) + } + + private static func normalized(_ value: EditorInlineDebugValue) -> EditorInlineDebugValue { + let singleLine = value.value + .replacingOccurrences(of: "\r", with: " ") + .replacingOccurrences(of: "\n", with: " ") + guard singleLine.count > maximumValueCharacters else { + return EditorInlineDebugValue(name: value.name, value: singleLine) + } + return EditorInlineDebugValue( + name: value.name, + value: String(singleLine.prefix(maximumValueCharacters - 1)) + "…" + ) + } + + private static func isIdentifier(_ value: String) -> Bool { + guard let first = value.unicodeScalars.first, + CharacterSet.letters.union(CharacterSet(charactersIn: "_$")).contains(first) + else { return false } + let characters = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "_$")) + return value.unicodeScalars.dropFirst().allSatisfy(characters.contains) + } + + private static func hasIdentifierBoundaries(range: NSRange, in source: NSString) -> Bool { + let characters = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "_$")) + func isIdentifierCharacter(at location: Int) -> Bool { + guard location >= 0, + location < source.length, + let scalar = UnicodeScalar(source.character(at: location)) else { return false } + return characters.contains(scalar) + } + return !isIdentifierCharacter(at: range.location - 1) + && !isIdentifierCharacter(at: NSMaxRange(range)) + } + + private static func lineRange(for line: Int, in source: NSString) -> NSRange? { + guard line >= 0, source.length > 0 else { return nil } + var location = 0 + var currentLine = 0 + while currentLine < line, location < source.length { + let range = source.lineRange(for: NSRange(location: location, length: 0)) + let next = NSMaxRange(range) + guard next > location else { return nil } + location = next + currentLine += 1 + } + guard currentLine == line, location < source.length else { return nil } + return source.lineRange(for: NSRange(location: location, length: 0)) + } +} + enum EditorDebugBreakpointLocation { static func productLine(forEditorLine line: Int) -> Int { line + 1 } } @@ -498,6 +594,9 @@ struct CodeEditorView: NSViewRepresentable { } context.coordinator.attachMarkdownImagePasteMonitor(to: scrollView) context.coordinator.codeVisionOverlay = CodeVisionOverlayController(textView: textView) + context.coordinator.debugInlineValueOverlay = DebugInlineValueOverlayController( + textView: textView + ) context.coordinator.isDarkAppearance = palette.isDark context.coordinator.colorTheme = settings.colorTheme context.coordinator.highlight() @@ -612,6 +711,7 @@ struct CodeEditorView: NSViewRepresentable { weak var gutter: LineNumberGutterView? weak var container: EditorContainerView? var codeVisionOverlay: CodeVisionOverlayController? + var debugInlineValueOverlay: DebugInlineValueOverlayController? var isApplyingEditorChange = false var isDarkAppearance = true var colorTheme: AppColorTheme = .lithe @@ -638,6 +738,8 @@ struct CodeEditorView: NSViewRepresentable { private var appliedLanguageFeatures: LanguageServerFeatureSet? private var appliedReadOnly: Bool? private var appliedCodeVisionHints: [JavaCodeVisionHint]? + private var appliedInlineDebugLine: Int? + private var appliedInlineDebugValues: [EditorInlineDebugValue] = [] private var editorOverlayLayoutRevision = 0 private var appliedEditorOverlayLayoutRevision = -1 private var editorOverlayRelayoutTask: Task? @@ -1263,6 +1365,34 @@ struct CodeEditorView: NSViewRepresentable { onAuthor: { [weak model] in model?.showBlame(for: url) } ) } + let inlineDebugLine: Int? + let inlineDebugValues: [EditorInlineDebugValue] + if let feature = model.genericDebugFeatureIfActive, + feature.state == .paused, + feature.selectedFrame?.sourceURL?.standardizedFileURL == url, + let frame = feature.selectedFrame { + inlineDebugLine = max(0, frame.line - 1) + inlineDebugValues = EditorInlineDebugValueProjection.values( + forLine: inlineDebugLine ?? 0, + in: (textView?.string ?? "") as NSString, + variables: feature.variables.map { + EditorInlineDebugValue(name: $0.name, value: $0.value) + } + ) + } else { + inlineDebugLine = nil + inlineDebugValues = [] + } + if appliedInlineDebugLine != inlineDebugLine + || appliedInlineDebugValues != inlineDebugValues + || overlayLayoutChanged { + appliedInlineDebugLine = inlineDebugLine + appliedInlineDebugValues = inlineDebugValues + debugInlineValueOverlay?.update( + line: inlineDebugLine, + values: inlineDebugValues + ) + } appliedEditorOverlayLayoutRevision = editorOverlayLayoutRevision let isBlameVisible = model.blameVisibleURL == url @@ -4534,6 +4664,103 @@ final class CodeVisionOverlayController { } } +@MainActor +final class DebugInlineValueOverlayController { + private weak var textView: NSTextView? + private var label: NSTextField? + private(set) var renderedText: String? + private(set) var renderedFrame: NSRect? + + init(textView: NSTextView) { + self.textView = textView + } + + func update(line: Int?, values: [EditorInlineDebugValue]) { + label?.removeFromSuperview() + label = nil + renderedText = nil + renderedFrame = nil + guard let line, + !values.isEmpty, + let textView, + let layoutManager = textView.layoutManager, + let textContainer = textView.textContainer else { return } + layoutManager.ensureLayout(for: textContainer) + let source = textView.string as NSString + let lineStart = characterOffset(forLine: line, in: source) + guard lineStart < source.length else { return } + let lineRange = source.lineRange(for: NSRange(location: lineStart, length: 0)) + var contentEnd = NSMaxRange(lineRange) + while contentEnd > lineRange.location { + let character = source.character(at: contentEnd - 1) + guard character == 10 || character == 13 else { break } + contentEnd -= 1 + } + guard contentEnd > lineRange.location else { return } + let lastCharacter = max(lineRange.location, contentEnd - 1) + let lastGlyph = layoutManager.glyphIndexForCharacter(at: lastCharacter) + var visualLineGlyphRange = NSRange() + let lineRect = layoutManager.lineFragmentRect( + forGlyphAt: lastGlyph, + effectiveRange: &visualLineGlyphRange + ) + let contentGlyphRange = layoutManager.glyphRange( + forCharacterRange: NSRange( + location: lineRange.location, + length: contentEnd - lineRange.location + ), + actualCharacterRange: nil + ) + let visibleContentRange = NSIntersectionRange(contentGlyphRange, visualLineGlyphRange) + guard visibleContentRange.length > 0 else { return } + let contentRect = layoutManager.boundingRect( + forGlyphRange: visibleContentRange, + in: textContainer + ) + let text = values.map { "\($0.name) = \($0.value)" }.joined(separator: " ") + let label = DebugInlineValueLabel(labelWithString: text) + label.font = .monospacedSystemFont(ofSize: 11, weight: .regular) + label.textColor = NSColor.secondaryLabelColor.withAlphaComponent(0.82) + label.lineBreakMode = .byTruncatingTail + label.maximumNumberOfLines = 1 + label.toolTip = text + label.sizeToFit() + let originX = textView.textContainerOrigin.x + contentRect.maxX + 12 + let availableWidth = max(0, textView.bounds.width - originX - 12) + guard availableWidth >= 24 else { return } + let height = max(16, label.fittingSize.height) + label.frame = NSRect( + x: originX, + y: textView.textContainerOrigin.y + lineRect.midY - height / 2, + width: min(label.fittingSize.width, availableWidth), + height: height + ) + label.isSelectable = false + label.isEditable = false + textView.addSubview(label) + self.label = label + renderedText = text + renderedFrame = label.frame + } + + private func characterOffset(forLine line: Int, in source: NSString) -> Int { + if let codeTextView = textView as? CodeTextView { + return codeTextView.characterOffset(forLine: line, in: source) + } + var currentLine = 0 + var location = 0 + while currentLine < line, location < source.length { + location = NSMaxRange(source.lineRange(for: NSRange(location: location, length: 0))) + currentLine += 1 + } + return location + } +} + +private final class DebugInlineValueLabel: NSTextField { + override func hitTest(_ point: NSPoint) -> NSView? { nil } +} + @MainActor private final class ClosureButton: NSButton { private let handler: () -> Void diff --git a/macos/Tests/LitheTests/EditorGutterLayoutTests.swift b/macos/Tests/LitheTests/EditorGutterLayoutTests.swift index 803a52c9..9f2eeba2 100644 --- a/macos/Tests/LitheTests/EditorGutterLayoutTests.swift +++ b/macos/Tests/LitheTests/EditorGutterLayoutTests.swift @@ -4,6 +4,74 @@ import Testing @Suite("Editor gutter layout") struct EditorGutterLayoutTests { + @Test + func inlineDebugValuesMatchWholeIdentifiersInSourceOrder() { + let values = EditorInlineDebugValueProjection.values( + forLine: 0, + in: "int total = count + counter;" as NSString, + variables: [ + EditorInlineDebugValue(name: "counter", value: "9"), + EditorInlineDebugValue(name: "count", value: "4"), + EditorInlineDebugValue(name: "total", value: "13") + ] + ) + + #expect(values.map(\.name) == ["total", "count", "counter"]) + #expect(values.map(\.value) == ["13", "4", "9"]) + #expect(EditorInlineDebugValueProjection.values( + forLine: 0, + in: "counter" as NSString, + variables: [EditorInlineDebugValue(name: "count", value: "4")] + ).isEmpty) + } + + @Test + func inlineDebugValuesAreBoundedAndSingleLine() { + let longValue = String(repeating: "x", count: 100) + "\nnext" + let values = EditorInlineDebugValueProjection.values( + forLine: 0, + in: "a + b + c + d + e" as NSString, + variables: ["e", "d", "c", "b", "a"].map { + EditorInlineDebugValue(name: $0, value: $0 == "a" ? longValue : $0) + } + ) + + #expect(values.map(\.name) == ["a", "b", "c", "d"]) + #expect(values[0].value.count == EditorInlineDebugValueProjection.maximumValueCharacters) + #expect(values[0].value.last == "…") + #expect(!values[0].value.contains("\n")) + } + + @MainActor + @Test + func inlineDebugValueOverlayUsesOnlyRemainingEditorWidth() throws { + let textView = CodeTextView(frame: NSRect(x: 0, y: 0, width: 480, height: 120)) + textView.font = .monospacedSystemFont(ofSize: 13, weight: .regular) + textView.string = "int count = 4;\nreturn count;" + let layoutManager = try #require(textView.layoutManager) + let textContainer = try #require(textView.textContainer) + layoutManager.delegate = textView + layoutManager.ensureLayout(for: textContainer) + let overlay = DebugInlineValueOverlayController(textView: textView) + + overlay.update( + line: 1, + values: [EditorInlineDebugValue(name: "count", value: "4")] + ) + + let frame = try #require(overlay.renderedFrame) + #expect(overlay.renderedText == "count = 4") + #expect(frame.minX > textView.textContainerOrigin.x) + #expect(frame.maxX <= textView.bounds.maxX - 12) + + textView.frame.size.width = 40 + overlay.update( + line: 1, + values: [EditorInlineDebugValue(name: "count", value: "4")] + ) + #expect(overlay.renderedText == nil) + } + @Test func debugHoverResolvesOnlyJavaIdentifierTokens() throws { let source = "userService.login(userName)" as NSString From f4948ee4af6fb50a1008ddb18f351cd4e859e7a8 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sun, 30 Aug 2026 00:37:53 +0800 Subject: [PATCH 29/66] fix(debug): complete Java breakpoint inspection flow --- .../DebugLaunchConfigurationResolver.swift | 8 +- .../GenericDebugFeatureModel.swift | 52 ++- .../DebugModuleTests.swift | 149 ++++++- .../RealJavaDebugIntegrationTests.swift | 397 ++++++++++++++++++ .../RunConfigurationIntegrationTests.swift | 4 +- rust/lithe-core/src/debug/engine.rs | 43 ++ rust/lithe-core/src/debug/types.rs | 12 +- 7 files changed, 639 insertions(+), 26 deletions(-) create mode 100644 macos/Tests/LitheTests/RealJavaDebugIntegrationTests.swift diff --git a/macos/Sources/Lithe/Services/Debug/DebugLaunchConfigurationResolver.swift b/macos/Sources/Lithe/Services/Debug/DebugLaunchConfigurationResolver.swift index d9b0aa80..a27348f5 100644 --- a/macos/Sources/Lithe/Services/Debug/DebugLaunchConfigurationResolver.swift +++ b/macos/Sources/Lithe/Services/Debug/DebugLaunchConfigurationResolver.swift @@ -171,16 +171,16 @@ struct DebugLaunchConfigurationResolver { arguments["classPaths"] = .array(target.classPaths.map(ToolingJSONValue.string)) } if let runOptions { - let programArguments = RunArgumentParser.parse(runOptions.arguments) + let programArguments = runOptions.arguments.trimmingCharacters(in: .whitespacesAndNewlines) if !programArguments.isEmpty { - arguments["args"] = .array(programArguments.map(ToolingJSONValue.string)) + arguments["args"] = .string(programArguments) } if !runOptions.environment.isEmpty { arguments["env"] = .object(runOptions.environment.mapValues(ToolingJSONValue.string)) } - let vmArguments = RunArgumentParser.parse(runOptions.vmArguments) + let vmArguments = runOptions.vmArguments.trimmingCharacters(in: .whitespacesAndNewlines) if !vmArguments.isEmpty { - arguments["vmArgs"] = .array(vmArguments.map(ToolingJSONValue.string)) + arguments["vmArgs"] = .string(vmArguments) } } return DebugLaunchConfiguration( diff --git a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift index 638c53a2..742105c9 100644 --- a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift +++ b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift @@ -839,20 +839,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu case .stopped(let reason, let threadID, let description): stoppedReason = description ?? reason selectedThreadID = threadID - inspectThreads() - if let threadID, - let thread = threads.first(where: { $0.id == threadID }) { - selectThread(thread) - } else if let threadID, let session = activeSession { - session.requestStackTrace(threadID: threadID) { [weak self] result in - if case .success(let frames) = result { - self?.stackFrames = frames - if let frame = frames.first { - self?.selectFrame(frame) - } - } - } - } + loadStoppedContext(threadID: threadID) case .continued: stoppedReason = nil stoppedFrame = nil @@ -886,6 +873,43 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } } + private func loadStoppedContext(threadID: Int?) { + guard let session = activeSession else { return } + session.requestThreads { [weak self] result in + guard let self else { return } + switch result { + case .success(let threads): + self.threads = threads + let selectedThreadID = threadID.flatMap { stoppedID in + threads.first(where: { $0.id == stoppedID })?.id + } ?? threads.first?.id ?? threadID + self.selectedThreadID = selectedThreadID + if let selectedThreadID { + self.loadStoppedStack(threadID: selectedThreadID) + } + case .failure(let error): + self.record(error) + if let threadID { + self.loadStoppedStack(threadID: threadID) + } + } + } + } + + private func loadStoppedStack(threadID: Int) { + activeSession?.requestStackTrace(threadID: threadID) { [weak self] result in + guard let self else { return } + switch result { + case .success(let frames): + self.stackFrames = frames + self.selectedFrameID = frames.first?.id + if let frame = frames.first { self.selectFrame(frame) } + case .failure(let error): + self.record(error) + } + } + } + private func publishStoppedLocation(_ frame: DebugStackFrame) { stoppedFrame = frame guard let sourceURL = frame.sourceURL else { return } diff --git a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift index df088549..0b14e72d 100644 --- a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift +++ b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift @@ -185,6 +185,133 @@ struct DebugModuleTests { #expect(transport.stopCalls == 1) } + @Test + func stoppedEventLoadsThreadStackScopeAndVariablesInOrder() throws { + let transport = RecordingTransport() + let core = RecordingDebugProtocolCore() + let descriptor = DebugProviderDescriptor( + id: "java", + displayName: "Java", + fileExtensions: ["java"] + ) + let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in + CoreDebugAdapterProtocolSession( + adapterID: "java", + transport: transport, + core: core, + sessionID: "java-stopped-context", + deadlineScheduler: RecordingDebugDeadlineScheduler() + ) + } + let feature = GenericDebugFeatureModel(sessions: manager) + let root = URL(fileURLWithPath: "/tmp/java-stopped-context", isDirectory: true) + let source = root.appendingPathComponent("src/Main.java") + var stoppedLocation: (URL, Int, Int)? + feature.onStoppedLocation = { stoppedLocation = ($0, $1, $2) } + #expect(feature.start( + fileURL: source, + rootURL: root, + configuration: DebugLaunchConfiguration( + name: "Main", + request: .launch, + arguments: ["mainClass": .string("example.Main")] + ) + )) + defer { feature.stop() } + + core.enqueueReceive(sessionID: "java-stopped-context", state: "paused", events: [[ + "sequence": 1, + "type": "stopped", + "reason": "breakpoint", + "threadId": 13 + ]]) + transport.emitData(Data("stopped-event".utf8)) + #expect(core.inspectionRequests.map(\.kind) == ["threads"]) + + let threadsOperationID = try #require(core.lastInspectionOperationID) + core.enqueueReceive(sessionID: "java-stopped-context", state: "paused", events: [[ + "sequence": 2, + "type": "operationCompleted", + "operationId": threadsOperationID, + "result": [ + "kind": "threads", + "threads": [ + ["id": 2, "name": "Reference Handler"], + ["id": 13, "name": "http-nio-exec-1"] + ] + ] + ]]) + transport.emitData(Data("threads-response".utf8)) + #expect(core.inspectionRequests.map(\.kind) == ["threads", "stackTrace"]) + #expect(core.inspectionRequests.last?.threadID == 13) + + let stackOperationID = try #require(core.lastInspectionOperationID) + core.enqueueReceive(sessionID: "java-stopped-context", state: "paused", events: [[ + "sequence": 3, + "type": "operationCompleted", + "operationId": stackOperationID, + "result": [ + "kind": "stackTrace", + "stackFrames": [[ + "id": 70, + "name": "example.Main.run", + "sourcePath": source.path, + "line": 12, + "column": 5 + ]] + ] + ]]) + transport.emitData(Data("stack-response".utf8)) + #expect(core.inspectionRequests.map(\.kind) == ["threads", "stackTrace", "scopes"]) + #expect(core.inspectionRequests.last?.frameID == 70) + + let scopesOperationID = try #require(core.lastInspectionOperationID) + core.enqueueReceive(sessionID: "java-stopped-context", state: "paused", events: [[ + "sequence": 4, + "type": "operationCompleted", + "operationId": scopesOperationID, + "result": [ + "kind": "scopes", + "scopes": [[ + "name": "Locals", + "variablesReference": 200, + "expensive": false + ]] + ] + ]]) + transport.emitData(Data("scopes-response".utf8)) + #expect(core.inspectionRequests.map(\.kind) == [ + "threads", "stackTrace", "scopes", "variables" + ]) + #expect(core.inspectionRequests.last?.variablesReference == 200) + + let variablesOperationID = try #require(core.lastInspectionOperationID) + core.enqueueReceive(sessionID: "java-stopped-context", state: "paused", events: [[ + "sequence": 5, + "type": "operationCompleted", + "operationId": variablesOperationID, + "result": [ + "kind": "variables", + "variables": [[ + "name": "count", + "value": "7", + "type": "int", + "variablesReference": 0 + ]] + ] + ]]) + transport.emitData(Data("variables-response".utf8)) + + #expect(feature.selectedThreadID == 13) + #expect(feature.threads.map(\.id) == [2, 13]) + #expect(feature.selectedFrame?.id == 70) + #expect(feature.scopes.first?.variablesReference == 200) + #expect(feature.variables.first?.value == "7") + #expect(stoppedLocation?.0 == source.standardizedFileURL) + #expect(stoppedLocation?.1 == 12) + #expect(stoppedLocation?.2 == 5) + } + @Test func genericBreakpointsPreserveAdvancedOptionsAcrossMuteAndClear() throws { let transport = RecordingTransport() @@ -942,6 +1069,13 @@ private final class RecordingBreakpointPersistence: DebugBreakpointPersisting, @ } } +private struct RecordingDebugInspectionRequest: Equatable { + let kind: String + let threadID: Int? + let frameID: Int? + let variablesReference: Int? +} + @MainActor private final class RecordingDebugProtocolCore: DebugProtocolCore { private var receiveUpdates: [DebugCoreUpdate] = [] @@ -956,6 +1090,7 @@ private final class RecordingDebugProtocolCore: DebugProtocolCore { private(set) var cancelledOperationReasons: [String] = [] private(set) var lastExecutionSingleThread: Bool? private(set) var lastExecutionThreadID: Int? + private(set) var inspectionRequests: [RecordingDebugInspectionRequest] = [] func createDebugSession( sessionID: String, @@ -1066,16 +1201,22 @@ private final class RecordingDebugProtocolCore: DebugProtocolCore { func inspectDebugSession( sessionID: String, operationID: String, - kind _: String, - threadID _: Int?, - frameID _: Int?, - variablesReference _: Int?, + kind: String, + threadID: Int?, + frameID: Int?, + variablesReference: Int?, expression _: String?, sourcePath _: String?, line _: Int?, column _: Int? ) throws -> DebugCoreUpdate { lastInspectionOperationID = operationID + inspectionRequests.append(RecordingDebugInspectionRequest( + kind: kind, + threadID: threadID, + frameID: frameID, + variablesReference: variablesReference + )) return update(sessionID: sessionID, state: "paused") } diff --git a/macos/Tests/LitheTests/RealJavaDebugIntegrationTests.swift b/macos/Tests/LitheTests/RealJavaDebugIntegrationTests.swift new file mode 100644 index 00000000..7464a887 --- /dev/null +++ b/macos/Tests/LitheTests/RealJavaDebugIntegrationTests.swift @@ -0,0 +1,397 @@ +import Foundation +import LitheCoreContracts +import LitheDebugModule +import LitheLanguageIntelligenceModule +import Testing +@testable import Lithe + +@Suite("Real Java Debug integration") +@MainActor +struct RealJavaDebugIntegrationTests { + @Test + func springRequestHitsBreakpointInspectsStepsAndResumes() async throws { + let environment = ProcessInfo.processInfo.environment + guard environment["LITHE_RUN_JAVA_DEBUG_INTEGRATION"] == "1" else { return } + + let repositoryRoot = Self.repositoryRoot + let jdtlsRoot = URL( + fileURLWithPath: environment["LITHE_JDTLS_ROOT"] + ?? repositoryRoot.appendingPathComponent(".artifacts/jdtls").path, + isDirectory: true + ) + let javaURL = URL( + fileURLWithPath: environment["LITHE_JAVA_PATH"] + ?? repositoryRoot.appendingPathComponent(".artifacts/jdk-arm64/bin/java").path + ) + let jdtlsURL = jdtlsRoot.appendingPathComponent("bin/jdtls") + let fileManager = FileManager.default + #expect(fileManager.isExecutableFile(atPath: javaURL.path)) + #expect(fileManager.isExecutableFile(atPath: jdtlsURL.path)) + guard fileManager.isExecutableFile(atPath: javaURL.path), + fileManager.isExecutableFile(atPath: jdtlsURL.path) else { return } + + let rootURL = fileManager.temporaryDirectory.appendingPathComponent( + "lithe-real-java-debug-\(UUID().uuidString)", + isDirectory: true + ) + let cacheURL = fileManager.temporaryDirectory.appendingPathComponent( + "\(rootURL.lastPathComponent)-jdtls-cache", + isDirectory: true + ) + let fixtureURL = repositoryRoot.appendingPathComponent( + "shared/fixtures/projects/lithe-spring-boot-git-graph", + isDirectory: true + ) + try fileManager.copyItem(at: fixtureURL, to: rootURL) + let mainURL = rootURL.appendingPathComponent( + "src/main/java/com/example/demo/DemoApplication.java" + ) + let serviceURL = rootURL.appendingPathComponent( + "src/main/java/com/example/demo/user/UserService.java" + ) + let serviceSource = try String(contentsOf: serviceURL, encoding: .utf8) + let breakpointLine = try #require(Self.line( + containing: "return repository.findAll();", + in: serviceSource + )) + + let core = RustCoreBridge() + #expect(core.isAvailable) + guard core.isAvailable else { return } + let resources: JDTLSLaunchResources + switch MacJDTLSLaunchResourceResolver( + bundledJdtlsRootURL: jdtlsRoot + ).resolve(for: jdtlsURL) { + case .direct(let value): + resources = value + case .wrapperFallback: + Issue.record("The real Java Debug test requires direct JDT LS resources.") + return + case .unavailable(let message): + Issue.record("JDT LS resources are unavailable: \(message)") + return + } + + let descriptor = try #require(LanguageProviderCatalog.standard.provider(for: mainURL)) + let launch = try #require(descriptor.languageServerLaunch) + let languageSession = LanguageServerRuntimeSession( + providerID: descriptor.id, + executableURL: jdtlsURL, + arguments: launch.arguments, + environment: environment, + initializationOptions: launch.initializationOptions, + runtimeExecutableURL: javaURL, + jdtlsLaunchResources: resources, + cacheDirectoryURL: cacheURL, + initializeTimeout: 120, + requestTimeout: 120, + shutdownTimeout: 5, + core: core + ) + let languageRuntime = RealJavaDebugLanguageRuntime( + descriptor: descriptor, + session: languageSession + ) + let languageManager = LanguageToolingSessionManager( + catalog: LanguageProviderCatalog(descriptors: [descriptor]), + runtimes: [languageRuntime], + builtinCore: core + ) + let protocolTrace = RealJavaDebugProtocolTrace() + let debugManager = DebugAdapterSessionManager( + providers: [DebugProviderDescriptor( + id: "java", + displayName: "Java", + fileExtensions: ["java"] + )] + ) { _, _ in + CoreDebugAdapterProtocolSession( + adapterID: "java", + transport: RealJavaDebugRecordingTransport( + wrapping: MacJavaDebugAdapterTransport( + portResolver: { rootURL in + try await languageManager.startJavaDebugServer(rootURL: rootURL) + } + ), + trace: protocolTrace + ), + core: core, + deadlineScheduler: MacDebugOperationDeadlineScheduler() + ) + } + let feature = GenericDebugFeatureModel(sessions: debugManager) + var requestTask: Task<(Data, URLResponse), Error>? + defer { + requestTask?.cancel() + feature.stop() + languageManager.stopAll() + try? fileManager.removeItem(at: rootURL) + try? fileManager.removeItem(at: cacheURL) + } + + let mainSource = try String(contentsOf: mainURL, encoding: .utf8) + try languageManager.synchronizeLanguageServer( + for: mainURL, + text: mainSource, + rootURL: rootURL + ) + let target: JavaDebugLaunchTarget + do { + target = try await languageManager.resolveJavaDebugLaunchTarget( + fileURL: mainURL, + rootURL: rootURL + ) + } catch { + throw RealJavaDebugIntegrationError.languageToolingFailed( + message: String(describing: error), + logs: Self.languageServerLogSummary(languageManager.languageServerLogs) + ) + } + feature.toggleBreakpoint(fileURL: serviceURL, line: breakpointLine) + var arguments: [String: ToolingJSONValue] = [ + "mainClass": .string(target.mainClass), + "cwd": .string(rootURL.path), + "console": .string("internalConsole"), + "args": .string("--server.port=0") + ] + if let projectName = target.projectName { + arguments["projectName"] = .string(projectName) + } + if !target.modulePaths.isEmpty { + arguments["modulePaths"] = .array(target.modulePaths.map(ToolingJSONValue.string)) + } + if !target.classPaths.isEmpty { + arguments["classPaths"] = .array(target.classPaths.map(ToolingJSONValue.string)) + } + #expect(feature.start( + fileURL: mainURL, + rootURL: rootURL, + configuration: DebugLaunchConfiguration( + name: "Spring Debug Integration", + request: .launch, + arguments: arguments + ) + )) + + #expect(await Self.waitUntil(timeout: .seconds(120)) { + feature.state == .running + }, "Java Debug Server did not reach the running state. Output:\n\(feature.output)") + #expect(await Self.waitUntil(timeout: .seconds(120)) { + feature.breakpoints.first?.verified == true + }, "The Java breakpoint was not verified. Output:\n\(feature.output)") + let port = await Self.waitForSpringPort(feature: feature, timeout: .seconds(120)) + let resolvedPort = try #require(port) + + var request = URLRequest( + url: URL(string: "http://127.0.0.1:\(resolvedPort)/api/users")! + ) + request.timeoutInterval = 60 + requestTask = Task { try await URLSession.shared.data(for: request) } + guard await Self.waitUntil(timeout: .seconds(60), condition: { + feature.state == .paused + }) else { + throw RealJavaDebugIntegrationError.debuggerDidNotPause( + Self.debugSnapshot(feature, protocolTrace: protocolTrace) + ) + } + guard await Self.waitUntil(timeout: .seconds(30), condition: { + feature.selectedFrame?.sourceURL?.standardizedFileURL + == serviceURL.standardizedFileURL + && feature.selectedFrame?.line == breakpointLine + }) else { + throw RealJavaDebugIntegrationError.stoppedFrameUnavailable( + Self.debugSnapshot(feature, protocolTrace: protocolTrace) + ) + } + #expect(await Self.waitUntil(timeout: .seconds(30)) { + !feature.variables.isEmpty + }, "No variables were loaded for the stopped Java frame.") + + let stoppedFrame = try #require(feature.selectedFrame) + feature.execute(.next) + #expect(await Self.waitUntil(timeout: .seconds(30)) { + feature.state == .paused && feature.selectedFrame != stoppedFrame + }, "Step over did not reach the next Java frame.") + feature.execute(.continueExecution) + + let response = try await Self.value(of: try #require(requestTask), timeout: .seconds(60)) + let httpResponse = try #require(response.1 as? HTTPURLResponse) + #expect(httpResponse.statusCode == 200) + let body = String(decoding: response.0, as: UTF8.self) + #expect(body.contains("Ada Lovelace")) + #expect(body.contains("Grace Hopper")) + } + + private static var repositoryRoot: URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + } + + private static func line(containing needle: String, in source: String) -> Int? { + source.split(separator: "\n", omittingEmptySubsequences: false) + .firstIndex { $0.contains(needle) } + .map { $0 + 1 } + } + + private static func languageServerLogSummary( + _ entries: [LanguageServerLogEntry] + ) -> String { + entries.reversed().map { entry in + [entry.level.rawValue, entry.message, entry.detail] + .compactMap { $0 } + .joined(separator: " | ") + }.joined(separator: "\n") + } + + private static func debugSnapshot( + _ feature: GenericDebugFeatureModel, + protocolTrace: RealJavaDebugProtocolTrace + ) -> String { + let breakpointSummary = feature.breakpoints.map { + "\($0.title) enabled=\($0.enabled) verified=\($0.verified) message=\($0.message ?? "nil")" + }.joined(separator: "\n") + let threadSummary = feature.threads.map { "\($0.id):\($0.name)" }.joined(separator: ", ") + return """ + state=\(feature.state) + stoppedReason=\(feature.stoppedReason ?? "nil") + threads=\(threadSummary) + breakpoints: + \(breakpointSummary) + output: + \(feature.output) + DAP trace: + \(protocolTrace.entries.joined(separator: "\n")) + """ + } + + private static func waitForSpringPort( + feature: GenericDebugFeatureModel, + timeout: Duration + ) async -> Int? { + var port: Int? + _ = await waitUntil(timeout: timeout) { + port = springPort(in: feature.output) + return port != nil + } + return port + } + + private static func springPort(in output: String) -> Int? { + let expression = try? NSRegularExpression( + pattern: "Tomcat started on port ([0-9]+)" + ) + let range = NSRange(output.startIndex.. Bool + ) async -> Bool { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while clock.now < deadline { + if condition() { return true } + // test-stability: allow(swift-real-sleep) reason: External JDT LS and JVM state arrives only through production process callbacks. + try? await Task.sleep(for: .milliseconds(25)) + } + return condition() + } + + private static func value( + of task: Task, + timeout: Duration + ) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { try await task.value } + group.addTask { + // test-stability: allow(swift-real-sleep) reason: The real HTTP request needs a local deadline independent of the test runner. + try await Task.sleep(for: timeout) + throw RealJavaDebugIntegrationError.timedOut + } + defer { group.cancelAll() } + return try await group.next()! + } + } +} + +@MainActor +private final class RealJavaDebugProtocolTrace { + private(set) var entries: [String] = [] + + func record(direction: String, data: Data) { + entries.append("\(direction) \(Self.payload(in: data))") + } + + private static func payload(in data: Data) -> String { + guard let text = String(data: data, encoding: .utf8), + let separator = text.range(of: "\r\n\r\n") else { + return String(decoding: data, as: UTF8.self) + } + return String(text[separator.upperBound...]) + } +} + +@MainActor +private final class RealJavaDebugRecordingTransport: DebugAdapterTransport { + private let wrapped: any DebugAdapterTransport + private let trace: RealJavaDebugProtocolTrace + + var isRunning: Bool { wrapped.isRunning } + var onData: ((Data) -> Void)? + var onErrorOutput: ((Data) -> Void)? + var onTermination: ((Int) -> Void)? + + init( + wrapping wrapped: any DebugAdapterTransport, + trace: RealJavaDebugProtocolTrace + ) { + self.wrapped = wrapped + self.trace = trace + wrapped.onData = { [weak self] data in + self?.trace.record(direction: "<-", data: data) + self?.onData?(data) + } + wrapped.onErrorOutput = { [weak self] data in self?.onErrorOutput?(data) } + wrapped.onTermination = { [weak self] code in self?.onTermination?(code) } + } + + func start(rootURL: URL) throws { + try wrapped.start(rootURL: rootURL) + } + + func send(_ data: Data) throws { + trace.record(direction: "->", data: data) + try wrapped.send(data) + } + + func stop() { + wrapped.stop() + } +} + +@MainActor +private final class RealJavaDebugLanguageRuntime: LanguageProviderRuntime { + let descriptor: LanguageProviderDescriptor + let supportsLanguageServerSession = true + private let session: any LanguageServerSession + + init(descriptor: LanguageProviderDescriptor, session: any LanguageServerSession) { + self.descriptor = descriptor + self.session = session + } + + func makeLanguageServerSession() -> (any LanguageServerSession)? { session } +} + +private enum RealJavaDebugIntegrationError: Error { + case timedOut + case languageToolingFailed(message: String, logs: String) + case debuggerDidNotPause(String) + case stoppedFrameUnavailable(String) +} diff --git a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift index 57d91229..f951ed72 100644 --- a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -367,8 +367,8 @@ struct RunConfigurationIntegrationTests { #expect(configuration.name == "Service") #expect(configuration.arguments["mainClass"] == .string("com.acme.ConfiguredMain")) #expect(configuration.arguments["cwd"] == .string(root.appendingPathComponent("service").path)) - #expect(configuration.arguments["vmArgs"] == .array([.string("-Xmx1g"), .string("-Dprofile=dev")])) - #expect(configuration.arguments["args"] == .array([.string("--port"), .string("8080")])) + #expect(configuration.arguments["vmArgs"] == .string("-Xmx1g -Dprofile=dev")) + #expect(configuration.arguments["args"] == .string("--port 8080")) #expect(configuration.arguments["env"] == .object(["APP_ENV": .string("dev")])) } diff --git a/rust/lithe-core/src/debug/engine.rs b/rust/lithe-core/src/debug/engine.rs index 0c7a89a4..80434ebd 100644 --- a/rust/lithe-core/src/debug/engine.rs +++ b/rust/lithe-core/src/debug/engine.rs @@ -1662,6 +1662,49 @@ mod tests { serde_json::from_slice(&bytes[body_start..]).unwrap() } + #[test] + fn debug_update_serializes_variant_fields_with_contract_casing() { + let update = DebugSessionUpdate { + session_id: "debug-contract-casing".to_string(), + state: DebugSessionState::Paused, + outbound_frames: Vec::new(), + events: vec![ + DebugEvent { + sequence: 1, + body: DebugEventBody::Stopped { + reason: "breakpoint".to_string(), + thread_id: Some(13), + description: None, + }, + }, + DebugEvent { + sequence: 2, + body: DebugEventBody::OperationCompleted { + operation_id: "stack-1".to_string(), + result: DebugOperationResult::StackTrace { + stack_frames: vec![DebugStackFrame { + id: 7, + name: "example.Main.run".to_string(), + source_path: Some("/workspace/Main.java".to_string()), + line: 12, + column: 1, + }], + }, + }, + }, + ], + }; + + let value = serde_json::to_value(update).unwrap(); + assert_eq!(value["events"][0]["threadId"], 13); + assert!(value["events"][0].get("thread_id").is_none()); + assert_eq!(value["events"][1]["operationId"], "stack-1"); + assert!(value["events"][1].get("operation_id").is_none()); + assert_eq!(value["events"][1]["result"]["kind"], "stackTrace"); + assert_eq!(value["events"][1]["result"]["stackFrames"][0]["id"], 7); + assert!(value["events"][1]["result"].get("stack_frames").is_none()); + } + #[test] fn initialize_launch_breakpoints_and_inspection_are_reduced_in_order() { let session_id = "debug-engine-flow"; diff --git a/rust/lithe-core/src/debug/types.rs b/rust/lithe-core/src/debug/types.rs index 9da4cef4..44662f60 100644 --- a/rust/lithe-core/src/debug/types.rs +++ b/rust/lithe-core/src/debug/types.rs @@ -370,7 +370,11 @@ pub struct DebugEvent { } #[derive(Debug, Clone, Serialize)] -#[serde(tag = "type", rename_all = "camelCase")] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] /// Provider-neutral lifecycle, output, breakpoint, and request result events. pub enum DebugEventBody { StateChanged { @@ -420,7 +424,11 @@ pub enum DebugOperationFailureCode { } #[derive(Debug, Clone, Serialize)] -#[serde(tag = "kind", rename_all = "camelCase")] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] /// Typed terminal data for one caller-owned debug operation. pub enum DebugOperationResult { Acknowledged { From 24c72e5ccb861e4080afb9e7cad549a67ac365f1 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sun, 30 Aug 2026 00:53:31 +0800 Subject: [PATCH 30/66] feat(debug): separate debugger and console views --- macos/Resources/en.lproj/Localizable.strings | 1 + .../zh-Hans.lproj/Localizable.strings | 1 + .../Lithe/Views/Debug/GenericDebugView.swift | 136 +++++++++++++++--- 3 files changed, 116 insertions(+), 22 deletions(-) diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index ae6948fa..6c9311ea 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -109,5 +109,6 @@ "No notifications" = "No notifications"; "Log: %@" = "Log: %@"; "Console" = "Console"; +"Debugger" = "Debugger"; "Pull Requests integration is under development" = "Pull Requests integration is under development"; "GitHub sign-in and pull request management are temporarily unavailable." = "GitHub sign-in and pull request management are temporarily unavailable."; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 2d4496c1..cfe013f8 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -1223,3 +1223,4 @@ "No notifications" = "暂无通知"; "Log: %@" = "日志:%@"; "Console" = "控制台"; +"Debugger" = "调试器"; diff --git a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift index 62fdb497..27427def 100644 --- a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift +++ b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift @@ -15,17 +15,17 @@ struct GenericDebugView: View { @State private var smartStepTargets: [DebugStepInTarget] = [] @State private var isSmartStepPickerPresented = false @State private var isJavaAttachPresented = false + @State private var selectedContent: DebugContent = .debugger var body: some View { VStack(spacing: 0) { header Rectangle().fill(LitheTheme.divider).frame(height: 1) if feature.isSessionActive || !feature.output.isEmpty || feature.errorMessage != nil { - HStack(spacing: 0) { - inspector - .frame(width: 300) - Rectangle().fill(LitheTheme.divider).frame(width: 1) - output + VStack(spacing: 0) { + contentTabs + Rectangle().fill(LitheTheme.divider).frame(height: 1) + activeContent } } else { emptyState @@ -102,6 +102,68 @@ struct GenericDebugView: View { model.attachJavaDebugger(host: host, port: port) } } + .onChange(of: feature.state) { state in + switch state { + case .paused: + selectedContent = .debugger + case .failed: + selectedContent = .console + default: + break + } + } + } + + @ViewBuilder + private var activeContent: some View { + switch selectedContent { + case .debugger: + inspector + case .console: + output + } + } + + private var contentTabs: some View { + HStack(spacing: 0) { + ForEach(DebugContent.allCases) { content in + Button { + selectedContent = content + } label: { + HStack(spacing: 5) { + Image(systemName: content.systemImage) + .font(.system(size: 10, weight: .medium)) + Text(content.title) + .font(.system(size: 11, weight: .medium)) + } + .foregroundStyle( + selectedContent == content + ? LitheTheme.primaryText + : LitheTheme.secondaryText + ) + .padding(.horizontal, 12) + .frame(height: 29) + .overlay(alignment: .bottom) { + if selectedContent == content { + Rectangle() + .fill(LitheTheme.accent) + .frame(height: 2) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + Spacer(minLength: 0) + if feature.state == .paused { + Label(feature.stoppedReason ?? "Paused", systemImage: "pause.circle.fill") + .font(.system(size: 10.5, weight: .medium)) + .foregroundStyle(LitheTheme.warning) + .lineLimit(1) + .padding(.trailing, 10) + } + } + .litheWorkbenchSurface(LitheTheme.toolHeader) } private var header: some View { @@ -677,6 +739,7 @@ struct GenericDebugView: View { evaluateRow } } + .frame(maxWidth: .infinity, maxHeight: .infinity) .litheWorkbenchSurface(LitheTheme.sidebar) } @@ -734,26 +797,34 @@ struct GenericDebugView: View { } private var output: some View { - ScrollView([.vertical, .horizontal]) { - VStack(alignment: .leading, spacing: 8) { - if let stoppedReason = feature.stoppedReason { - Label(stoppedReason, systemImage: "pause.circle.fill") - .font(.system(size: 11.5, weight: .medium)) - .foregroundStyle(LitheTheme.warning) - } - if let errorMessage = feature.errorMessage { - Label(errorMessage, systemImage: "exclamationmark.triangle.fill") - .font(.system(size: 11.5)) - .foregroundStyle(LitheTheme.error) + GeometryReader { geometry in + ScrollView([.vertical, .horizontal]) { + VStack(alignment: .leading, spacing: 8) { + if let stoppedReason = feature.stoppedReason { + Label(stoppedReason, systemImage: "pause.circle.fill") + .font(.system(size: 11.5, weight: .medium)) + .foregroundStyle(LitheTheme.warning) + } + if let errorMessage = feature.errorMessage { + Label(errorMessage, systemImage: "exclamationmark.triangle.fill") + .font(.system(size: 11.5)) + .foregroundStyle(LitheTheme.error) + } + Text(feature.output.isEmpty ? "Waiting for Debug Adapter output…" : feature.output) + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(LitheTheme.primaryText) + .textSelection(.enabled) } - Text(feature.output.isEmpty ? "Waiting for Debug Adapter output…" : feature.output) - .font(.system(size: 12, design: .monospaced)) - .foregroundStyle(LitheTheme.primaryText) - .textSelection(.enabled) + .frame( + minWidth: max(0, geometry.size.width - 24), + minHeight: max(0, geometry.size.height - 24), + alignment: .topLeading + ) + .padding(12) } - .frame(maxWidth: .infinity, alignment: .topLeading) - .padding(12) } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .litheWorkbenchSurface(LitheTheme.editor) } private var emptyState: some View { @@ -924,6 +995,27 @@ struct GenericDebugView: View { } } +private enum DebugContent: CaseIterable, Identifiable { + case debugger + case console + + var id: Self { self } + + var title: LocalizedStringKey { + switch self { + case .debugger: "Debugger" + case .console: "Console" + } + } + + var systemImage: String { + switch self { + case .debugger: "ladybug" + case .console: "terminal" + } + } +} + private struct JavaAttachView: View { @Environment(\.dismiss) private var dismiss @State private var host = "localhost" From 0ed28a847f59a245dacc58fc7ba0c8e5519b14bf Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sun, 30 Aug 2026 01:42:51 +0800 Subject: [PATCH 31/66] feat(debug): organize macOS debugger workspace --- macos/Resources/en.lproj/Localizable.strings | 1 + .../zh-Hans.lproj/Localizable.strings | 1 + .../Lithe/Views/Debug/GenericDebugView.swift | 354 ++++++++++-------- 3 files changed, 191 insertions(+), 165 deletions(-) diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 6c9311ea..889cf7d9 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -110,5 +110,6 @@ "Log: %@" = "Log: %@"; "Console" = "Console"; "Debugger" = "Debugger"; +"Breakpoints" = "Breakpoints"; "Pull Requests integration is under development" = "Pull Requests integration is under development"; "GitHub sign-in and pull request management are temporarily unavailable." = "GitHub sign-in and pull request management are temporarily unavailable."; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index cfe013f8..930a7f92 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -1224,3 +1224,4 @@ "Log: %@" = "日志:%@"; "Console" = "控制台"; "Debugger" = "调试器"; +"Breakpoints" = "断点"; diff --git a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift index 27427def..88493bd0 100644 --- a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift +++ b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift @@ -119,6 +119,8 @@ struct GenericDebugView: View { switch selectedContent { case .debugger: inspector + case .breakpoints: + breakpointInspector case .console: output } @@ -298,6 +300,190 @@ struct GenericDebugView: View { } private var inspector: some View { + HSplitView { + executionInspector + .frame(minWidth: 240, idealWidth: 320, maxWidth: .infinity) + dataInspector + .frame(minWidth: 300, idealWidth: 480, maxWidth: .infinity) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .litheWorkbenchSurface(LitheTheme.sidebar) + } + + private var executionInspector: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + sectionHeader("Threads", count: feature.threads.count) + if feature.threads.isEmpty { + Button("Load threads") { feature.inspectThreads() } + .buttonStyle(.plain) + .font(LitheTheme.smallFont) + .foregroundStyle(LitheTheme.accent) + .padding(10) + } else { + ForEach(feature.threads) { thread in + rowButton(selected: feature.selectedThreadID == thread.id) { + feature.selectThread(thread) + } label: { + Image(systemName: "circle") + Text(thread.name).lineLimit(1) + } + .contextMenu { + if feature.capabilities.supportsSingleThreadExecutionRequests { + Button(feature.state == .paused ? "Resume Thread" : "Pause Thread") { + feature.executeThread( + feature.state == .paused ? .continueExecution : .pause, + thread: thread + ) + } + .disabled(feature.state != .paused && feature.state != .running) + } + } + } + } + + divider + sectionHeader("Call Stack", count: feature.stackFrames.count) + if feature.stackFrames.isEmpty { + placeholder("Pause the process to inspect frames") + } else { + ForEach(feature.stackFrames) { frame in + rowButton(selected: feature.selectedFrameID == frame.id) { + feature.selectFrame(frame) + if let sourceURL = frame.sourceURL { + model.openSourceLocation( + url: sourceURL, + line: frame.line, + column: frame.column + ) + } + } label: { + Image(systemName: "chevron.right") + VStack(alignment: .leading, spacing: 1) { + Text(frame.name).lineLimit(1) + if let sourceURL = frame.sourceURL { + Text("\(sourceURL.lastPathComponent):\(frame.line)") + .font(.system(size: 9.5, design: .monospaced)) + .foregroundStyle(LitheTheme.secondaryText) + } + } + } + } + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .litheWorkbenchSurface(LitheTheme.sidebar) + } + + private var dataInspector: some View { + VStack(spacing: 0) { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + sectionHeader("Variables", count: feature.variables.count) + if feature.variables.isEmpty { + placeholder("Select a stack frame to inspect variables") + } else { + ForEach(feature.visibleVariableRows) { row in + let variable = row.variable + HStack(alignment: .firstTextBaseline, spacing: 6) { + Image(systemName: variableSymbol(variable)) + .font(.system(size: variable.isExpandable ? 8 : 4)) + .foregroundStyle(LitheTheme.secondaryText) + Text(variable.name) + .font(.system(size: 10.5, design: .monospaced)) + Text("=") + .foregroundStyle(LitheTheme.secondaryText) + Text(variable.value) + .font(.system(size: 10.5, design: .monospaced)) + .foregroundStyle(LitheTheme.accent) + .lineLimit(2) + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + .onTapGesture { + feature.toggleVariableExpansion(variable) + } + .padding(.leading, 10 + CGFloat(row.depth * 14)) + .padding(.trailing, 10) + .padding(.vertical, 5) + .contextMenu { + if feature.capabilities.supportsSetVariable, + variable.containerReference != nil { + Button("Set Value…") { editingVariable = variable } + } + if feature.capabilities.supportsDataBreakpoints, + variable.containerReference != nil { + Button("Break on Field Access…") { + feature.requestDataBreakpoint(for: variable) + } + } + } + } + } + + divider + watchSectionHeader + if feature.watches.isEmpty { + placeholder("Add an expression to watch while paused") + } else { + ForEach(feature.watches) { watch in + HStack(alignment: .firstTextBaseline, spacing: 6) { + Image(systemName: "eye") + .font(.system(size: 9)) + .foregroundStyle(LitheTheme.secondaryText) + VStack(alignment: .leading, spacing: 2) { + Text(watch.expression) + .font(.system(size: 10.5, design: .monospaced)) + .lineLimit(1) + if let error = watch.error { + Text(error) + .font(.system(size: 9.5)) + .foregroundStyle(LitheTheme.error) + .lineLimit(2) + } else if let value = watch.value { + HStack(spacing: 4) { + Text(value) + .foregroundStyle(LitheTheme.accent) + if let type = watch.type { + Text(type).foregroundStyle(LitheTheme.secondaryText) + } + } + .font(.system(size: 9.5, design: .monospaced)) + .lineLimit(2) + } else { + Text(feature.state == .paused ? "Evaluating…" : "Not available") + .font(.system(size: 9.5)) + .foregroundStyle(LitheTheme.secondaryText) + } + } + Spacer(minLength: 0) + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .contextMenu { + Button("Refresh") { feature.refreshWatches() } + .disabled(feature.state != .paused) + Button("Edit…") { + watchEditor = WatchEditorContext(watch: watch) + } + Divider() + Button("Remove", role: .destructive) { + feature.removeWatch(watch) + } + } + } + } + } + } + divider + evaluateRow + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .litheWorkbenchSurface(LitheTheme.sidebar) + } + + private var breakpointInspector: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 0) { Group { @@ -572,171 +758,6 @@ struct GenericDebugView: View { } } - Group { - divider - sectionHeader("Threads", count: feature.threads.count) - if feature.threads.isEmpty { - Button("Load threads") { feature.inspectThreads() } - .buttonStyle(.plain) - .font(LitheTheme.smallFont) - .foregroundStyle(LitheTheme.accent) - .padding(10) - } else { - ForEach(feature.threads) { thread in - rowButton(selected: feature.selectedThreadID == thread.id) { - feature.selectThread(thread) - } label: { - Image(systemName: "circle") - Text(thread.name).lineLimit(1) - } - .contextMenu { - if feature.capabilities.supportsSingleThreadExecutionRequests { - Button(feature.state == .paused ? "Resume Thread" : "Pause Thread") { - feature.executeThread( - feature.state == .paused ? .continueExecution : .pause, - thread: thread - ) - } - .disabled(feature.state != .paused && feature.state != .running) - } - } - } - } - } - - Group { - divider - sectionHeader("Call Stack", count: feature.stackFrames.count) - if feature.stackFrames.isEmpty { - placeholder("Pause the process to inspect frames") - } else { - ForEach(feature.stackFrames) { frame in - rowButton(selected: feature.selectedFrameID == frame.id) { - feature.selectFrame(frame) - if let sourceURL = frame.sourceURL { - model.openSourceLocation( - url: sourceURL, - line: frame.line, - column: frame.column - ) - } - } label: { - Image(systemName: "chevron.right") - VStack(alignment: .leading, spacing: 1) { - Text(frame.name).lineLimit(1) - if let sourceURL = frame.sourceURL { - Text("\(sourceURL.lastPathComponent):\(frame.line)") - .font(.system(size: 9.5, design: .monospaced)) - .foregroundStyle(LitheTheme.secondaryText) - } - } - } - } - } - } - - Group { - divider - sectionHeader("Variables", count: feature.variables.count) - if feature.variables.isEmpty { - placeholder("Select a stack frame to inspect variables") - } else { - ForEach(feature.visibleVariableRows) { row in - let variable = row.variable - HStack(alignment: .firstTextBaseline, spacing: 6) { - Image(systemName: variableSymbol(variable)) - .font(.system(size: variable.isExpandable ? 8 : 4)) - .foregroundStyle(LitheTheme.secondaryText) - Text(variable.name) - .font(.system(size: 10.5, design: .monospaced)) - Text("=") - .foregroundStyle(LitheTheme.secondaryText) - Text(variable.value) - .font(.system(size: 10.5, design: .monospaced)) - .foregroundStyle(LitheTheme.accent) - .lineLimit(2) - Spacer(minLength: 0) - } - .contentShape(Rectangle()) - .onTapGesture { - feature.toggleVariableExpansion(variable) - } - .padding(.leading, 10 + CGFloat(row.depth * 14)) - .padding(.trailing, 10) - .padding(.vertical, 5) - .contextMenu { - if feature.capabilities.supportsSetVariable, - variable.containerReference != nil { - Button("Set Value…") { editingVariable = variable } - } - if feature.capabilities.supportsDataBreakpoints, - variable.containerReference != nil { - Button("Break on Field Access…") { - feature.requestDataBreakpoint(for: variable) - } - } - } - } - } - } - - Group { - divider - watchSectionHeader - if feature.watches.isEmpty { - placeholder("Add an expression to watch while paused") - } else { - ForEach(feature.watches) { watch in - HStack(alignment: .firstTextBaseline, spacing: 6) { - Image(systemName: "eye") - .font(.system(size: 9)) - .foregroundStyle(LitheTheme.secondaryText) - VStack(alignment: .leading, spacing: 2) { - Text(watch.expression) - .font(.system(size: 10.5, design: .monospaced)) - .lineLimit(1) - if let error = watch.error { - Text(error) - .font(.system(size: 9.5)) - .foregroundStyle(LitheTheme.error) - .lineLimit(2) - } else if let value = watch.value { - HStack(spacing: 4) { - Text(value) - .foregroundStyle(LitheTheme.accent) - if let type = watch.type { - Text(type).foregroundStyle(LitheTheme.secondaryText) - } - } - .font(.system(size: 9.5, design: .monospaced)) - .lineLimit(2) - } else { - Text(feature.state == .paused ? "Evaluating…" : "Not available") - .font(.system(size: 9.5)) - .foregroundStyle(LitheTheme.secondaryText) - } - } - Spacer(minLength: 0) - } - .padding(.horizontal, 10) - .padding(.vertical, 5) - .contextMenu { - Button("Refresh") { feature.refreshWatches() } - .disabled(feature.state != .paused) - Button("Edit…") { - watchEditor = WatchEditorContext(watch: watch) - } - Divider() - Button("Remove", role: .destructive) { - feature.removeWatch(watch) - } - } - } - } - } - - divider - evaluateRow } } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -997,6 +1018,7 @@ struct GenericDebugView: View { private enum DebugContent: CaseIterable, Identifiable { case debugger + case breakpoints case console var id: Self { self } @@ -1004,6 +1026,7 @@ private enum DebugContent: CaseIterable, Identifiable { var title: LocalizedStringKey { switch self { case .debugger: "Debugger" + case .breakpoints: "Breakpoints" case .console: "Console" } } @@ -1011,6 +1034,7 @@ private enum DebugContent: CaseIterable, Identifiable { var systemImage: String { switch self { case .debugger: "ladybug" + case .breakpoints: "circle.fill" case .console: "terminal" } } From 69830826cb99a0755671f859e2e097b1fd61196b Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sun, 30 Aug 2026 02:12:56 +0800 Subject: [PATCH 32/66] fix(debug): discard stale inspection results --- .../GenericDebugFeatureModel.swift | 141 ++++++++--- .../DebugModuleTests.swift | 221 ++++++++++++++++++ 2 files changed, 333 insertions(+), 29 deletions(-) diff --git a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift index 742105c9..e7d9ccdd 100644 --- a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift +++ b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift @@ -112,6 +112,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu private var activeFileURL: URL? private let maximumOutputCharacters = 400_000 private var watchGeneration = 0 + private var inspectionGeneration = 0 public init( sessions: DebugAdapterSessionManager, @@ -197,6 +198,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } public func stop() { + invalidateInspectionRequests() if let activeFileURL { dataBreakpoints.removeAll { !$0.canPersist } try? sessions.setDataBreakpoints(coreDataBreakpoints, for: activeFileURL) @@ -586,64 +588,98 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu public func inspectThreads() { guard let session = activeSession else { return } + let generation = inspectionGeneration session.requestThreads { [weak self] result in + guard let self, self.inspectionGeneration == generation else { return } switch result { case .success(let threads): - self?.threads = threads - if self?.selectedThreadID == nil { self?.selectedThreadID = threads.first?.id } - case .failure(let error): self?.record(error) + self.threads = threads + if self.selectedThreadID == nil { self.selectedThreadID = threads.first?.id } + case .failure(let error): self.record(error) } } } public func selectThread(_ thread: DebugThread) { - activeSession?.cancelPendingOperations() + let generation = beginInspectionTransition() selectedThreadID = thread.id + selectedFrameID = nil + selectedFrame = nil + stackFrames = [] + scopes = [] + resetVariableTree() + invalidateWatchResults() guard let session = activeSession else { return } session.requestStackTrace(threadID: thread.id) { [weak self] result in + guard let self, + self.inspectionGeneration == generation, + self.selectedThreadID == thread.id else { return } switch result { case .success(let frames): - self?.stackFrames = frames - self?.selectedFrameID = frames.first?.id + self.stackFrames = frames + self.selectedFrameID = frames.first?.id if let frame = frames.first { - self?.selectFrame(frame) + self.selectFrame(frame) + } else { + self.selectedFrame = nil } - case .failure(let error): self?.record(error) + case .failure(let error): self.record(error) } } } public func selectFrame(_ frame: DebugStackFrame) { - activeSession?.cancelPendingOperations() + let generation = beginInspectionTransition() selectedFrameID = frame.id selectedFrame = frame + scopes = [] + resetVariableTree() + invalidateWatchResults() publishStoppedLocation(frame) refreshWatches() guard let session = activeSession else { return } session.requestScopes(frameID: frame.id) { [weak self] result in + guard let self, + self.inspectionGeneration == generation, + self.selectedFrameID == frame.id else { return } switch result { case .success(let scopes): - self?.scopes = scopes + self.scopes = scopes if let scope = scopes.first(where: { !$0.expensive }) ?? scopes.first { - self?.loadVariables(reference: scope.variablesReference) + self.loadVariables( + reference: scope.variablesReference, + frameID: frame.id, + generation: generation + ) } else { - self?.resetVariableTree() + self.resetVariableTree() } - case .failure(let error): self?.record(error) + case .failure(let error): self.record(error) } } } public func loadVariables(reference: Int) { + loadVariables( + reference: reference, + frameID: selectedFrameID, + generation: inspectionGeneration + ) + } + + private func loadVariables(reference: Int, frameID: Int?, generation: Int) { guard let session = activeSession else { return } session.requestVariables(reference: reference) { [weak self] result in + guard let self, + self.inspectionGeneration == generation, + self.selectedFrameID == frameID else { return } switch result { case .success(let variables): - self?.variables = variables - self?.variableChildren = [:] - self?.expandedVariableIDs = [] - self?.loadingVariableIDs = [] - case .failure(let error): self?.record(error) + self.variables = variables + self.variableChildren = [:] + self.expandedVariableIDs = [] + self.loadingVariableIDs = [] + case .failure(let error): self.record(error) } } } @@ -660,8 +696,12 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } guard !loadingVariableIDs.contains(variable.id), let session = activeSession else { return } loadingVariableIDs.insert(variable.id) + let frameID = selectedFrameID + let generation = inspectionGeneration session.requestVariables(reference: variable.variablesReference) { [weak self] result in - guard let self else { return } + guard let self, + self.inspectionGeneration == generation, + self.selectedFrameID == frameID else { return } self.loadingVariableIDs.remove(variable.id) switch result { case .success(let children): @@ -690,12 +730,16 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu capabilities.supportsSetVariable, let containerReference = variable.containerReference, let session = activeSession else { return } + let frameID = selectedFrameID + let generation = inspectionGeneration session.setVariable( variablesReference: containerReference, name: variable.name, value: value ) { [weak self] result in - guard let self else { return } + guard let self, + self.inspectionGeneration == generation, + self.selectedFrameID == frameID else { return } switch result { case .success(let replacement): let updated = DebugVariable( @@ -837,17 +881,40 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu case .output(_, let text): append(text) case .stopped(let reason, let threadID, let description): + let generation = beginInspectionTransition() stoppedReason = description ?? reason selectedThreadID = threadID - loadStoppedContext(threadID: threadID) + selectedFrameID = nil + selectedFrame = nil + threads = [] + stackFrames = [] + scopes = [] + resetVariableTree() + invalidateWatchResults() + loadStoppedContext(threadID: threadID, generation: generation) case .continued: + invalidateInspectionRequests() stoppedReason = nil + selectedThreadID = nil + selectedFrameID = nil stoppedFrame = nil selectedFrame = nil - activeSession?.cancelPendingOperations() + threads = [] + stackFrames = [] + scopes = [] resetVariableTree() invalidateWatchResults() case .terminated(let exitCode): + invalidateInspectionRequests() + stoppedReason = nil + selectedThreadID = nil + selectedFrameID = nil + stoppedFrame = nil + selectedFrame = nil + threads = [] + stackFrames = [] + scopes = [] + resetVariableTree() invalidateWatchResults() if let exitCode { append("Debug session exited with code \(exitCode).\n") } case .breakpoint(let resolved): @@ -873,10 +940,10 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } } - private func loadStoppedContext(threadID: Int?) { + private func loadStoppedContext(threadID: Int?, generation: Int) { guard let session = activeSession else { return } session.requestThreads { [weak self] result in - guard let self else { return } + guard let self, self.inspectionGeneration == generation else { return } switch result { case .success(let threads): self.threads = threads @@ -885,31 +952,47 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } ?? threads.first?.id ?? threadID self.selectedThreadID = selectedThreadID if let selectedThreadID { - self.loadStoppedStack(threadID: selectedThreadID) + self.loadStoppedStack(threadID: selectedThreadID, generation: generation) } case .failure(let error): self.record(error) if let threadID { - self.loadStoppedStack(threadID: threadID) + self.loadStoppedStack(threadID: threadID, generation: generation) } } } } - private func loadStoppedStack(threadID: Int) { + private func loadStoppedStack(threadID: Int, generation: Int) { activeSession?.requestStackTrace(threadID: threadID) { [weak self] result in - guard let self else { return } + guard let self, + self.inspectionGeneration == generation, + self.selectedThreadID == threadID else { return } switch result { case .success(let frames): self.stackFrames = frames self.selectedFrameID = frames.first?.id - if let frame = frames.first { self.selectFrame(frame) } + if let frame = frames.first { + self.selectFrame(frame) + } else { + self.selectedFrame = nil + } case .failure(let error): self.record(error) } } } + private func beginInspectionTransition() -> Int { + inspectionGeneration &+= 1 + activeSession?.cancelPendingOperations() + return inspectionGeneration + } + + private func invalidateInspectionRequests() { + _ = beginInspectionTransition() + } + private func publishStoppedLocation(_ frame: DebugStackFrame) { stoppedFrame = frame guard let sourceURL = frame.sourceURL else { return } diff --git a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift index 0b14e72d..6c12061b 100644 --- a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift +++ b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift @@ -312,6 +312,136 @@ struct DebugModuleTests { #expect(stoppedLocation?.2 == 5) } + @Test + func rapidInspectionSelectionDiscardsOutOfOrderResults() throws { + let session = DeferredInspectionDebugSession() + let descriptor = DebugProviderDescriptor( + id: "java", + displayName: "Java", + fileExtensions: ["java"] + ) + let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in session } + let feature = GenericDebugFeatureModel(sessions: manager) + let root = URL(fileURLWithPath: "/tmp/java-inspection-selection", isDirectory: true) + let source = root.appendingPathComponent("src/Main.java") + #expect(feature.start( + fileURL: source, + rootURL: root, + configuration: DebugLaunchConfiguration( + name: "Main", + request: .launch, + arguments: ["mainClass": .string("example.Main")] + ) + )) + defer { feature.stop() } + + let firstThread = DebugThread(id: 1, name: "worker-1") + let secondThread = DebugThread(id: 2, name: "worker-2") + let staleFrame = DebugStackFrame( + id: 10, + name: "stale", + sourceURL: source, + line: 10, + column: 1 + ) + let firstFrame = DebugStackFrame( + id: 20, + name: "first", + sourceURL: source, + line: 20, + column: 1 + ) + let secondFrame = DebugStackFrame( + id: 21, + name: "second", + sourceURL: source, + line: 21, + column: 1 + ) + + // The older thread response arrives after the newer selection has + // already loaded its frames. It must not replace the active stack. + feature.selectThread(firstThread) + feature.selectThread(secondThread) + #expect(session.stackTraceThreadIDs == [1, 2]) + session.completeStackTrace(at: 1, with: [firstFrame, secondFrame]) + session.completeStackTrace(at: 0, with: [staleFrame]) + + #expect(feature.selectedThreadID == 2) + #expect(feature.stackFrames.map(\.id) == [20, 21]) + #expect(feature.selectedFrameID == 20) + #expect(session.scopeFrameIDs == [20]) + + // The first frame's scope response arrives after the second frame was + // selected. It must not start a stale variables request. + feature.selectFrame(secondFrame) + #expect(session.scopeFrameIDs == [20, 21]) + session.completeScopes( + at: 1, + with: [DebugScope(id: 210, name: "Locals", variablesReference: 210, expensive: false)] + ) + session.completeScopes( + at: 0, + with: [DebugScope(id: 200, name: "Locals", variablesReference: 200, expensive: false)] + ) + + #expect(feature.selectedFrameID == 21) + #expect(feature.scopes.map(\.variablesReference) == [210]) + #expect(session.variableReferences == [210]) + + // A variables response from the previously selected frame must not + // overwrite the variables that belong to the current frame. + feature.selectFrame(firstFrame) + session.completeScopes( + at: 2, + with: [DebugScope(id: 200, name: "Locals", variablesReference: 200, expensive: false)] + ) + let currentVariable = DebugVariable( + id: "user", + name: "user", + value: "CurrentUser@1", + type: "CurrentUser", + evaluateName: "user", + variablesReference: 300 + ) + session.completeVariables(at: 1, with: [currentVariable]) + session.completeVariables(at: 0, with: [ + DebugVariable( + id: "stale", + name: "stale", + value: "OldUser@1", + type: "OldUser", + evaluateName: "stale", + variablesReference: 0 + ) + ]) + #expect(feature.variables == [currentVariable]) + + // Child-variable loading is also scoped to the selected frame. + feature.toggleVariableExpansion(currentVariable) + #expect(session.variableReferences == [210, 200, 300]) + feature.selectFrame(secondFrame) + session.completeVariables(at: 2, with: [ + DebugVariable( + id: "name", + name: "name", + value: "stale child", + type: "String", + evaluateName: "user.name", + variablesReference: 0 + ) + ]) + #expect(feature.variables.isEmpty) + #expect(feature.variableChildren.isEmpty) + #expect(feature.loadingVariableIDs.isEmpty) + + session.emit(.continued(threadID: secondThread.id)) + #expect(feature.selectedThreadID == nil) + #expect(feature.selectedFrameID == nil) + #expect(feature.stackFrames.isEmpty) + #expect(feature.scopes.isEmpty) + } + @Test func genericBreakpointsPreserveAdvancedOptionsAcrossMuteAndClear() throws { let transport = RecordingTransport() @@ -1076,6 +1206,97 @@ private struct RecordingDebugInspectionRequest: Equatable { let variablesReference: Int? } +@MainActor +private final class DeferredInspectionDebugSession: DebugAdapterControllingSession { + private(set) var isRunning = false + private(set) var state: DebugAdapterState = .idle + var onStateChange: ((DebugAdapterState) -> Void)? + var onEvent: ((DebugAdapterEvent) -> Void)? + + private var stackTraceRequests: [( + threadID: Int, + completion: (Result<[DebugStackFrame], Error>) -> Void + )] = [] + private var scopeRequests: [( + frameID: Int, + completion: (Result<[DebugScope], Error>) -> Void + )] = [] + private var variableRequests: [( + reference: Int, + completion: (Result<[DebugVariable], Error>) -> Void + )] = [] + + var stackTraceThreadIDs: [Int] { stackTraceRequests.map(\.threadID) } + var scopeFrameIDs: [Int] { scopeRequests.map(\.frameID) } + var variableReferences: [Int] { variableRequests.map(\.reference) } + + func start(rootURL _: URL) throws { + isRunning = true + state = .ready + } + + func stop() { + isRunning = false + state = .idle + } + + func launch(_: DebugLaunchConfiguration) throws { + state = .paused + onStateChange?(.paused) + } + + func setBreakpoints(_: [DebugSourceBreakpoint], in _: URL) {} + func execute(_: DebugExecutionCommand, threadID _: Int?) {} + func requestThreads(_: @escaping (Result<[DebugThread], Error>) -> Void) {} + + func requestStackTrace( + threadID: Int, + completion: @escaping (Result<[DebugStackFrame], Error>) -> Void + ) { + stackTraceRequests.append((threadID, completion)) + } + + func requestScopes( + frameID: Int, + completion: @escaping (Result<[DebugScope], Error>) -> Void + ) { + scopeRequests.append((frameID, completion)) + } + + func requestVariables( + reference: Int, + completion: @escaping (Result<[DebugVariable], Error>) -> Void + ) { + variableRequests.append((reference, completion)) + } + + func evaluate( + _: String, + frameID _: Int?, + completion _: @escaping (Result) -> Void + ) {} + + // This double deliberately delivers responses after cancellation to model + // adapters and callback queues that cannot retract an already-sent result. + func cancelPendingOperations() {} + + func emit(_ event: DebugAdapterEvent) { + onEvent?(event) + } + + func completeStackTrace(at index: Int, with frames: [DebugStackFrame]) { + stackTraceRequests[index].completion(.success(frames)) + } + + func completeScopes(at index: Int, with scopes: [DebugScope]) { + scopeRequests[index].completion(.success(scopes)) + } + + func completeVariables(at index: Int, with variables: [DebugVariable]) { + variableRequests[index].completion(.success(variables)) + } +} + @MainActor private final class RecordingDebugProtocolCore: DebugProtocolCore { private var receiveUpdates: [DebugCoreUpdate] = [] From 82dcd84fabc8a72d2277fafd7afc32c3826e32c0 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sun, 30 Aug 2026 03:19:42 +0800 Subject: [PATCH 33/66] feat(debug): add Java stepping filters --- .../Composition/DebugFeatureGraph.swift | 9 +- .../Core/Rust/RustDebugProtocolCore.swift | 20 + .../Debug/MacDebugSteppingFilterStore.swift | 46 ++ .../Platform/MacOS/MacServiceContainer.swift | 5 +- .../Lithe/Views/Debug/GenericDebugView.swift | 190 +++++++- .../Debug/DebugAdapterContracts.swift | 51 +- .../Debug/DebugProtocolCore.swift | 42 +- .../DebugSteppingFilterPersistence.swift | 7 + .../GenericDebugFeatureModel.swift | 157 +++++- .../CoreDebugAdapterProtocolSession.swift | 3 +- .../DebugModuleTests.swift | 292 ++++++++++- .../DebugBreakpointPersistenceTests.swift | 39 ++ rust/lithe-core/src/debug/engine.rs | 457 +++++++++++++++++- rust/lithe-core/src/debug/types.rs | 94 ++++ rust/lithe-core/src/protocol/command.rs | 4 + rust/lithe-core/src/runtime/dispatcher.rs | 20 + rust/lithe-core/src/tests/protocol.rs | 29 ++ shared/contracts/application-boundary.md | 9 +- shared/contracts/rust-core-api.md | 23 +- .../fixtures/debug/stepping-filters-v1.json | 89 ++++ 20 files changed, 1547 insertions(+), 39 deletions(-) create mode 100644 macos/Sources/Lithe/Platform/MacOS/Debug/MacDebugSteppingFilterStore.swift create mode 100644 macos/Sources/LitheDebugModule/Application/DebugSteppingFilterPersistence.swift create mode 100644 shared/fixtures/debug/stepping-filters-v1.json diff --git a/macos/Sources/Lithe/Application/Composition/DebugFeatureGraph.swift b/macos/Sources/Lithe/Application/Composition/DebugFeatureGraph.swift index 4a7788ab..1aad622e 100644 --- a/macos/Sources/Lithe/Application/Composition/DebugFeatureGraph.swift +++ b/macos/Sources/Lithe/Application/Composition/DebugFeatureGraph.swift @@ -1,5 +1,6 @@ import Combine import Foundation +import LitheCoreContracts import LitheDebugModule import LitheModuleAPI @@ -12,12 +13,16 @@ final class DebugFeatureGraph: NSObject, DebugServiceGraph { init( adapterSessions: DebugAdapterSessionManager, - breakpointPersistence: (any DebugBreakpointPersisting)? = nil + breakpointPersistence: (any DebugBreakpointPersisting)? = nil, + steppingFilterResolver: (any DebugSteppingFilterResolving)? = nil, + steppingFilterPersistence: (any DebugSteppingFilterPersisting)? = nil ) { self.adapterSessions = adapterSessions genericFeature = GenericDebugFeatureModel( sessions: adapterSessions, - breakpointPersistence: breakpointPersistence + breakpointPersistence: breakpointPersistence, + steppingFilterResolver: steppingFilterResolver, + steppingFilterPersistence: steppingFilterPersistence ) } diff --git a/macos/Sources/Lithe/Core/Rust/RustDebugProtocolCore.swift b/macos/Sources/Lithe/Core/Rust/RustDebugProtocolCore.swift index 5b6fbe29..89a04406 100644 --- a/macos/Sources/Lithe/Core/Rust/RustDebugProtocolCore.swift +++ b/macos/Sources/Lithe/Core/Rust/RustDebugProtocolCore.swift @@ -2,6 +2,16 @@ import Foundation import LitheCoreContracts extension RustCoreBridge: DebugProtocolCore { + func resolveDebugSteppingFilters( + adapterID: String, + filters: DebugSteppingFilters? + ) throws -> DebugSteppingFilters { + try executeResult( + command: "debug.steppingFilters", + payload: DebugSteppingFiltersPayload(adapterID: adapterID, filters: filters) + ).get() + } + func createDebugSession( sessionID: String, adapterID: String, @@ -233,6 +243,16 @@ private struct DebugCreateSessionPayload: Encodable { } } +private struct DebugSteppingFiltersPayload: Encodable { + let adapterID: String + let filters: DebugSteppingFilters? + + private enum CodingKeys: String, CodingKey { + case adapterID = "adapterId" + case filters + } +} + private struct DebugSessionPayload: Encodable { let sessionID: String diff --git a/macos/Sources/Lithe/Platform/MacOS/Debug/MacDebugSteppingFilterStore.swift b/macos/Sources/Lithe/Platform/MacOS/Debug/MacDebugSteppingFilterStore.swift new file mode 100644 index 00000000..7911f2c4 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Debug/MacDebugSteppingFilterStore.swift @@ -0,0 +1,46 @@ +import Foundation +import LitheCoreContracts +import LitheDebugModule + +enum MacDebugSteppingFilterStoreError: LocalizedError { + case invalidData + + var errorDescription: String? { + switch self { + case .invalidData: + "Saved debugger stepping filters could not be read." + } + } +} + +final class MacDebugSteppingFilterStore: DebugSteppingFilterPersisting, @unchecked Sendable { + private static let keyPrefix = "lithe.debug.steppingFilters." + private let store: any KeyValueStore + private let lock = NSLock() + + init(store: any KeyValueStore) { + self.store = store + } + + func loadSteppingFilters(adapterID: String) throws -> DebugSteppingFilters? { + lock.lock(); defer { lock.unlock() } + guard let data = store.data(forKey: key(adapterID)) else { return nil } + do { + return try JSONDecoder().decode(DebugSteppingFilters.self, from: data) + } catch { + throw MacDebugSteppingFilterStoreError.invalidData + } + } + + func saveSteppingFilters(_ filters: DebugSteppingFilters, adapterID: String) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(filters) + lock.lock(); defer { lock.unlock() } + store.set(data, forKey: key(adapterID)) + } + + private func key(_ adapterID: String) -> String { + Self.keyPrefix + adapterID + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 15d9cf9f..e966f57d 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -74,6 +74,7 @@ final class MacServiceContainer { ) self.runConfigurationStore = runConfigurationStore let debugBreakpointStore = MacDebugBreakpointStore(store: store) + let debugSteppingFilterStore = MacDebugSteppingFilterStore(store: store) let fileOperations = MacWorkspaceFileOperations() let processRunner = MacProcessRunner() let secureStore = MacLocalSecretStore() @@ -411,7 +412,9 @@ final class MacServiceContainer { ) let graph = DebugFeatureGraph( adapterSessions: adapterSessions, - breakpointPersistence: debugBreakpointStore + breakpointPersistence: debugBreakpointStore, + steppingFilterResolver: rustCore, + steppingFilterPersistence: debugSteppingFilterStore ) return graph }) diff --git a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift index 88493bd0..82eeadf5 100644 --- a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift +++ b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift @@ -15,6 +15,7 @@ struct GenericDebugView: View { @State private var smartStepTargets: [DebugStepInTarget] = [] @State private var isSmartStepPickerPresented = false @State private var isJavaAttachPresented = false + @State private var isJavaSteppingSettingsPresented = false @State private var selectedContent: DebugContent = .debugger var body: some View { @@ -102,6 +103,15 @@ struct GenericDebugView: View { model.attachJavaDebugger(host: host, port: port) } } + .sheet(isPresented: $isJavaSteppingSettingsPresented) { + if let filters = feature.javaSteppingFilters { + JavaSteppingFiltersView( + filters: filters, + onSave: feature.updateJavaSteppingFilters, + onReset: feature.resetJavaSteppingFilters + ) + } + } .onChange(of: feature.state) { state in switch state { case .paused: @@ -188,6 +198,14 @@ struct GenericDebugView: View { .lineLimit(1) } Spacer() + if feature.javaSteppingFilters != nil { + Button { isJavaSteppingSettingsPresented = true } label: { + Image(systemName: "line.3.horizontal.decrease.circle") + } + .litheIconButton() + .disabled(feature.isSessionActive) + .help("Java stepping filters") + } Button { isJavaAttachPresented = true } label: { Image(systemName: "link") } @@ -347,26 +365,59 @@ struct GenericDebugView: View { if feature.stackFrames.isEmpty { placeholder("Pause the process to inspect frames") } else { - ForEach(feature.stackFrames) { frame in - rowButton(selected: feature.selectedFrameID == frame.id) { - feature.selectFrame(frame) - if let sourceURL = frame.sourceURL { - model.openSourceLocation( - url: sourceURL, - line: frame.line, - column: frame.column - ) - } + if feature.areFilteredStackFramesExpanded, + feature.hiddenStackFrameCount > 0 { + Button { + feature.collapseFilteredStackFrames() } label: { - Image(systemName: "chevron.right") - VStack(alignment: .leading, spacing: 1) { - Text(frame.name).lineLimit(1) + Label("Collapse filtered frames", systemImage: "rectangle.compress.vertical") + .font(LitheTheme.smallFont) + .foregroundStyle(LitheTheme.secondaryText) + .padding(.horizontal, 10) + .frame(minHeight: 27) + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + } + ForEach(feature.visibleStackFrameRows) { row in + if let frame = row.frame { + rowButton(selected: feature.selectedFrameID == frame.id) { + feature.selectFrame(frame) if let sourceURL = frame.sourceURL { - Text("\(sourceURL.lastPathComponent):\(frame.line)") - .font(.system(size: 9.5, design: .monospaced)) - .foregroundStyle(LitheTheme.secondaryText) + model.openSourceLocation( + url: sourceURL, + line: frame.line, + column: frame.column + ) } + } label: { + Image(systemName: frame.isFiltered ? "ellipsis" : "chevron.right") + VStack(alignment: .leading, spacing: 1) { + Text(frame.name).lineLimit(1) + if let sourceURL = frame.sourceURL { + Text("\(sourceURL.lastPathComponent):\(frame.line)") + .font(.system(size: 9.5, design: .monospaced)) + .foregroundStyle(LitheTheme.secondaryText) + } + } + } + .opacity(frame.isFiltered ? 0.58 : 1) + } else { + Button { + feature.expandFilteredStackFrames() + } label: { + Label( + "\(row.hiddenFrameCount) filtered frames", + systemImage: "ellipsis.circle" + ) + .font(LitheTheme.smallFont) + .foregroundStyle(LitheTheme.secondaryText) + .padding(.horizontal, 10) + .frame(minHeight: 27) + .frame(maxWidth: .infinity, alignment: .leading) } + .buttonStyle(.plain) + .help("Show JDK, proxy, and framework frames") } } } @@ -1088,6 +1139,113 @@ private struct JavaAttachView: View { } } +private struct JavaSteppingFiltersView: View { + @Environment(\.dismiss) private var dismiss + let onSave: (DebugSteppingFilters) -> Void + let onReset: () -> Void + @State private var skipJDK: Bool + @State private var skipLibraries: Bool + @State private var skipSynthetics: Bool + @State private var skipStaticInitializers: Bool + @State private var skipConstructors: Bool + @State private var hideFilteredStackFrames: Bool + @State private var classPatterns: String + + init( + filters: DebugSteppingFilters, + onSave: @escaping (DebugSteppingFilters) -> Void, + onReset: @escaping () -> Void + ) { + self.onSave = onSave + self.onReset = onReset + _skipJDK = State(initialValue: filters.classNameFilters.contains("$JDK")) + _skipLibraries = State(initialValue: filters.classNameFilters.contains("$Libraries")) + _skipSynthetics = State(initialValue: filters.skipSynthetics) + _skipStaticInitializers = State(initialValue: filters.skipStaticInitializers) + _skipConstructors = State(initialValue: filters.skipConstructors) + _hideFilteredStackFrames = State(initialValue: filters.hideFilteredStackFrames) + _classPatterns = State(initialValue: filters.classNameFilters + .filter { $0 != "$JDK" && $0 != "$Libraries" } + .joined(separator: "\n")) + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 3) { + Text("Java Stepping Filters") + .font(.system(size: 15, weight: .semibold)) + Text("Controls where Step Into stops. Changes apply to the next Java debug session.") + .font(LitheTheme.smallFont) + .foregroundStyle(LitheTheme.secondaryText) + } + + Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) { + GridRow { + Toggle("Skip JDK and reflection code", isOn: $skipJDK) + Toggle("Skip third-party libraries", isOn: $skipLibraries) + } + GridRow { + Toggle("Skip synthetic methods", isOn: $skipSynthetics) + Toggle("Skip static initializers", isOn: $skipStaticInitializers) + } + GridRow { + Toggle("Skip constructors", isOn: $skipConstructors) + Toggle("Collapse matching stack frames", isOn: $hideFilteredStackFrames) + } + } + .toggleStyle(.checkbox) + .font(.system(size: 11)) + + VStack(alignment: .leading, spacing: 6) { + Text("Additional class patterns") + .font(.system(size: 11, weight: .semibold)) + Text("One pattern per line, for example org.mockito.* or com.example.generated.*") + .font(LitheTheme.smallFont) + .foregroundStyle(LitheTheme.secondaryText) + TextEditor(text: $classPatterns) + .font(.system(size: 11, design: .monospaced)) + .scrollContentBackground(.hidden) + .padding(6) + .background(LitheTheme.sidebar) + .overlay { + RoundedRectangle(cornerRadius: 5) + .stroke(LitheTheme.divider, lineWidth: 1) + } + .frame(minHeight: 185) + } + + HStack { + Button("Reset Defaults") { + onReset() + dismiss() + } + Spacer() + Button("Cancel", role: .cancel) { dismiss() } + .keyboardShortcut(.cancelAction) + Button("Save") { + var patterns = classPatterns + .split(whereSeparator: \Character.isNewline) + .map(String.init) + if skipJDK { patterns.append("$JDK") } + if skipLibraries { patterns.append("$Libraries") } + onSave(DebugSteppingFilters( + classNameFilters: patterns, + skipSynthetics: skipSynthetics, + skipStaticInitializers: skipStaticInitializers, + skipConstructors: skipConstructors, + hideFilteredStackFrames: hideFilteredStackFrames + )) + dismiss() + } + .keyboardShortcut(.defaultAction) + } + } + .padding(18) + .frame(width: 560, height: 470) + .litheWorkbenchSurface(LitheTheme.editor) + } +} + struct BreakpointEditorValue { let enabled: Bool let condition: String? diff --git a/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift b/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift index 2631389d..75a66a6f 100644 --- a/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift +++ b/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift @@ -53,11 +53,49 @@ public struct DebugLaunchConfiguration: Codable, Equatable, Sendable { public let name: String public let request: DebugRequestKind public let arguments: [String: ToolingJSONValue] + public let steppingFilters: DebugSteppingFilters? - public init(name: String, request: DebugRequestKind, arguments: [String: ToolingJSONValue]) { + public init( + name: String, + request: DebugRequestKind, + arguments: [String: ToolingJSONValue], + steppingFilters: DebugSteppingFilters? = nil + ) { self.name = name self.request = request self.arguments = arguments + self.steppingFilters = steppingFilters + } + + public func applying(steppingFilters: DebugSteppingFilters) -> Self { + Self( + name: name, + request: request, + arguments: arguments, + steppingFilters: steppingFilters + ) + } +} + +public struct DebugSteppingFilters: Codable, Equatable, Sendable { + public let classNameFilters: [String] + public let skipSynthetics: Bool + public let skipStaticInitializers: Bool + public let skipConstructors: Bool + public let hideFilteredStackFrames: Bool + + public init( + classNameFilters: [String], + skipSynthetics: Bool, + skipStaticInitializers: Bool, + skipConstructors: Bool, + hideFilteredStackFrames: Bool + ) { + self.classNameFilters = classNameFilters + self.skipSynthetics = skipSynthetics + self.skipStaticInitializers = skipStaticInitializers + self.skipConstructors = skipConstructors + self.hideFilteredStackFrames = hideFilteredStackFrames } } @@ -372,13 +410,22 @@ public struct DebugStackFrame: Identifiable, Equatable, Sendable { public let sourceURL: URL? public let line: Int public let column: Int + public let isFiltered: Bool - public init(id: Int, name: String, sourceURL: URL?, line: Int, column: Int) { + public init( + id: Int, + name: String, + sourceURL: URL?, + line: Int, + column: Int, + isFiltered: Bool = false + ) { self.id = id self.name = name self.sourceURL = sourceURL self.line = line self.column = column + self.isFiltered = isFiltered } } diff --git a/macos/Sources/LitheCoreContracts/Debug/DebugProtocolCore.swift b/macos/Sources/LitheCoreContracts/Debug/DebugProtocolCore.swift index e31b0df7..6f62d8ea 100644 --- a/macos/Sources/LitheCoreContracts/Debug/DebugProtocolCore.swift +++ b/macos/Sources/LitheCoreContracts/Debug/DebugProtocolCore.swift @@ -138,6 +138,37 @@ public struct DebugCoreStackFrame: Decodable, Equatable, Sendable { public let sourcePath: String? public let line: Int public let column: Int + public let isFiltered: Bool + + public init( + id: Int, + name: String, + sourcePath: String?, + line: Int, + column: Int, + isFiltered: Bool = false + ) { + self.id = id + self.name = name + self.sourcePath = sourcePath + self.line = line + self.column = column + self.isFiltered = isFiltered + } + + private enum CodingKeys: String, CodingKey { + case id, name, sourcePath, line, column, isFiltered + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(Int.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + sourcePath = try container.decodeIfPresent(String.self, forKey: .sourcePath) + line = try container.decode(Int.self, forKey: .line) + column = try container.decode(Int.self, forKey: .column) + isFiltered = try container.decodeIfPresent(Bool.self, forKey: .isFiltered) ?? false + } } public struct DebugCoreScope: Decodable, Equatable, Sendable { @@ -154,10 +185,19 @@ public struct DebugCoreVariable: Decodable, Equatable, Sendable { public let variablesReference: Int } +/// Focused policy boundary for portable debugger stepping defaults and validation. +@MainActor +public protocol DebugSteppingFilterResolving: Sendable { + func resolveDebugSteppingFilters( + adapterID: String, + filters: DebugSteppingFilters? + ) throws -> DebugSteppingFilters +} + /// Transport-neutral Debug Core boundary. Native products own processes and /// sockets; this contract owns DAP framing, state, sequencing, and normalized data. @MainActor -public protocol DebugProtocolCore: Sendable { +public protocol DebugProtocolCore: DebugSteppingFilterResolving, Sendable { func createDebugSession( sessionID: String, adapterID: String, diff --git a/macos/Sources/LitheDebugModule/Application/DebugSteppingFilterPersistence.swift b/macos/Sources/LitheDebugModule/Application/DebugSteppingFilterPersistence.swift new file mode 100644 index 00000000..7a64d3a9 --- /dev/null +++ b/macos/Sources/LitheDebugModule/Application/DebugSteppingFilterPersistence.swift @@ -0,0 +1,7 @@ +import Foundation +import LitheCoreContracts + +public protocol DebugSteppingFilterPersisting: Sendable { + func loadSteppingFilters(adapterID: String) throws -> DebugSteppingFilters? + func saveSteppingFilters(_ filters: DebugSteppingFilters, adapterID: String) throws +} diff --git a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift index e7d9ccdd..287e46c7 100644 --- a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift +++ b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift @@ -72,6 +72,14 @@ public struct GenericDebugVariableRow: Identifiable, Equatable, Sendable { public let depth: Int } +public struct GenericDebugStackFrameRow: Identifiable, Equatable, Sendable { + public let id: String + public let frame: DebugStackFrame? + public let hiddenFrameCount: Int + + public var isHiddenGroup: Bool { frame == nil } +} + @MainActor public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatureTarget { @Published public private(set) var providerID: String? @@ -100,6 +108,8 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu /// The frame currently selected in the call stack, which may differ from /// the frame that initially caused the stop. @Published public private(set) var selectedFrame: DebugStackFrame? + @Published public private(set) var javaSteppingFilters: DebugSteppingFilters? + @Published public private(set) var areFilteredStackFramesExpanded = false /// Delivers the selected stopped frame to the host editor for source /// navigation. The Debug module does not own editor presentation. @@ -107,6 +117,8 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu private let sessions: DebugAdapterSessionManager private let breakpointPersistence: (any DebugBreakpointPersisting)? + private let steppingFilterResolver: (any DebugSteppingFilterResolving)? + private let steppingFilterPersistence: (any DebugSteppingFilterPersisting)? private var requestedBreakpointsByFile: [URL: [Int: DebugSourceBreakpoint]] = [:] private var workspaceURL: URL? private var activeFileURL: URL? @@ -116,10 +128,14 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu public init( sessions: DebugAdapterSessionManager, - breakpointPersistence: (any DebugBreakpointPersisting)? = nil + breakpointPersistence: (any DebugBreakpointPersisting)? = nil, + steppingFilterResolver: (any DebugSteppingFilterResolving)? = nil, + steppingFilterPersistence: (any DebugSteppingFilterPersisting)? = nil ) { self.sessions = sessions self.breakpointPersistence = breakpointPersistence + self.steppingFilterResolver = steppingFilterResolver + self.steppingFilterPersistence = steppingFilterPersistence sessions.onStateChange = { [weak self] providerID, state in guard self?.providerID == providerID else { return } self?.state = state @@ -128,6 +144,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu guard self?.providerID == providerID else { return } self?.consume(event) } + loadJavaSteppingFilters() } public var isSessionActive: Bool { @@ -149,6 +166,35 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu appendVisibleVariables(variables, parentPath: "root", depth: 0, to: &rows) return rows } + public var visibleStackFrameRows: [GenericDebugStackFrameRow] { + guard javaSteppingFilters?.hideFilteredStackFrames == true, + !areFilteredStackFramesExpanded else { + return stackFrames.map(stackFrameRow) + } + var rows: [GenericDebugStackFrameRow] = [] + var hiddenCount = 0 + var hiddenStartID: Int? + for frame in stackFrames { + if frame.isFiltered { + hiddenCount += 1 + hiddenStartID = hiddenStartID ?? frame.id + continue + } + appendHiddenStackFrames( + count: hiddenCount, + startID: hiddenStartID, + to: &rows + ) + hiddenCount = 0 + hiddenStartID = nil + rows.append(stackFrameRow(frame)) + } + appendHiddenStackFrames(count: hiddenCount, startID: hiddenStartID, to: &rows) + return rows + } + public var hiddenStackFrameCount: Int { + stackFrames.lazy.filter(\.isFiltered).count + } public func start( fileURL: URL, @@ -182,10 +228,18 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu if !dataBreakpoints.isEmpty { try sessions.setDataBreakpoints(coreDataBreakpoints, for: fileURL) } + let effectiveConfiguration: DebugLaunchConfiguration + if providerID == "java", let javaSteppingFilters { + effectiveConfiguration = configuration.applying( + steppingFilters: javaSteppingFilters + ) + } else { + effectiveConfiguration = configuration + } let session = try sessions.launch( for: fileURL, rootURL: rootURL, - configuration: configuration + configuration: effectiveConfiguration ) state = session.state return true @@ -214,6 +268,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu selectedFrame = nil threads = [] stackFrames = [] + areFilteredStackFramesExpanded = false scopes = [] resetVariableTree() invalidateWatchResults() @@ -525,6 +580,41 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu session.execute(command, threadID: selectedThreadID) } + public func updateJavaSteppingFilters(_ filters: DebugSteppingFilters) { + do { + let normalized = try steppingFilterResolver?.resolveDebugSteppingFilters( + adapterID: "java", + filters: filters + ) ?? filters + javaSteppingFilters = normalized + areFilteredStackFramesExpanded = false + try steppingFilterPersistence?.saveSteppingFilters(normalized, adapterID: "java") + } catch { + record(error) + } + } + + public func resetJavaSteppingFilters() { + guard let steppingFilterResolver else { return } + do { + let defaults = try steppingFilterResolver.resolveDebugSteppingFilters( + adapterID: "java", + filters: nil + ) + updateJavaSteppingFilters(defaults) + } catch { + record(error) + } + } + + public func expandFilteredStackFrames() { + areFilteredStackFramesExpanded = true + } + + public func collapseFilteredStackFrames() { + areFilteredStackFramesExpanded = false + } + public func executeThread(_ command: DebugExecutionCommand, thread: DebugThread) { guard capabilities.supportsSingleThreadExecutionRequests, (command == .continueExecution && state == .paused) @@ -606,6 +696,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu selectedFrameID = nil selectedFrame = nil stackFrames = [] + areFilteredStackFramesExpanded = false scopes = [] resetVariableTree() invalidateWatchResults() @@ -617,6 +708,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu switch result { case .success(let frames): self.stackFrames = frames + self.areFilteredStackFramesExpanded = false self.selectedFrameID = frames.first?.id if let frame = frames.first { self.selectFrame(frame) @@ -888,6 +980,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu selectedFrame = nil threads = [] stackFrames = [] + areFilteredStackFramesExpanded = false scopes = [] resetVariableTree() invalidateWatchResults() @@ -901,6 +994,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu selectedFrame = nil threads = [] stackFrames = [] + areFilteredStackFramesExpanded = false scopes = [] resetVariableTree() invalidateWatchResults() @@ -913,6 +1007,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu selectedFrame = nil threads = [] stackFrames = [] + areFilteredStackFramesExpanded = false scopes = [] resetVariableTree() invalidateWatchResults() @@ -971,6 +1066,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu switch result { case .success(let frames): self.stackFrames = frames + self.areFilteredStackFramesExpanded = false self.selectedFrameID = frames.first?.id if let frame = frames.first { self.selectFrame(frame) @@ -989,6 +1085,63 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu return inspectionGeneration } + private func loadJavaSteppingFilters() { + guard let steppingFilterResolver else { return } + let persisted: DebugSteppingFilters? + do { + persisted = try steppingFilterPersistence?.loadSteppingFilters(adapterID: "java") + } catch { + record(error) + loadDefaultJavaSteppingFilters(using: steppingFilterResolver) + return + } + do { + javaSteppingFilters = try steppingFilterResolver.resolveDebugSteppingFilters( + adapterID: "java", + filters: persisted + ) + } catch { + record(error) + if persisted != nil { + loadDefaultJavaSteppingFilters(using: steppingFilterResolver) + } + } + } + + private func loadDefaultJavaSteppingFilters( + using steppingFilterResolver: any DebugSteppingFilterResolving + ) { + do { + javaSteppingFilters = try steppingFilterResolver.resolveDebugSteppingFilters( + adapterID: "java", + filters: nil + ) + } catch { + record(error) + } + } + + private func stackFrameRow(_ frame: DebugStackFrame) -> GenericDebugStackFrameRow { + GenericDebugStackFrameRow( + id: "frame-\(frame.id)", + frame: frame, + hiddenFrameCount: 0 + ) + } + + private func appendHiddenStackFrames( + count: Int, + startID: Int?, + to rows: inout [GenericDebugStackFrameRow] + ) { + guard count > 0, let startID else { return } + rows.append(GenericDebugStackFrameRow( + id: "filtered-\(startID)", + frame: nil, + hiddenFrameCount: count + )) + } + private func invalidateInspectionRequests() { _ = beginInspectionTransition() } diff --git a/macos/Sources/LitheDebugModule/Runtime/CoreDebugAdapterProtocolSession.swift b/macos/Sources/LitheDebugModule/Runtime/CoreDebugAdapterProtocolSession.swift index 7b697cd0..c8a3d9aa 100644 --- a/macos/Sources/LitheDebugModule/Runtime/CoreDebugAdapterProtocolSession.swift +++ b/macos/Sources/LitheDebugModule/Runtime/CoreDebugAdapterProtocolSession.swift @@ -248,7 +248,8 @@ public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSessi name: $0.name, sourceURL: $0.sourcePath.map { URL(fileURLWithPath: $0) }, line: $0.line, - column: $0.column + column: $0.column, + isFiltered: $0.isFiltered ) }) }) diff --git a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift index 6c12061b..b3e74a2a 100644 --- a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift +++ b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift @@ -305,6 +305,7 @@ struct DebugModuleTests { #expect(feature.selectedThreadID == 13) #expect(feature.threads.map(\.id) == [2, 13]) #expect(feature.selectedFrame?.id == 70) + #expect(feature.selectedFrame?.isFiltered == false) #expect(feature.scopes.first?.variablesReference == 200) #expect(feature.variables.first?.value == "7") #expect(stoppedLocation?.0 == source.standardizedFileURL) @@ -312,6 +313,219 @@ struct DebugModuleTests { #expect(stoppedLocation?.2 == 5) } + @Test + func javaSteppingFiltersLoadDefaultsAndPersistNormalizedOverrides() { + let defaults = DebugSteppingFilters( + classNameFilters: ["$JDK", "org.junit.*"], + skipSynthetics: true, + skipStaticInitializers: true, + skipConstructors: false, + hideFilteredStackFrames: true + ) + let normalized = DebugSteppingFilters( + classNameFilters: ["$JDK", "org.mockito.*"], + skipSynthetics: true, + skipStaticInitializers: false, + skipConstructors: true, + hideFilteredStackFrames: true + ) + let resolver = RecordingDebugSteppingFilterResolver( + defaults: defaults, + normalizedOverride: normalized + ) + let persistence = RecordingDebugSteppingFilterPersistence() + let manager = DebugAdapterSessionManager(providers: []) { _, _ in nil } + let feature = GenericDebugFeatureModel( + sessions: manager, + steppingFilterResolver: resolver, + steppingFilterPersistence: persistence + ) + + #expect(feature.javaSteppingFilters == defaults) + #expect(resolver.requests == [RecordingDebugSteppingFilterResolution( + adapterID: "java", + filters: nil + )]) + + let override = DebugSteppingFilters( + classNameFilters: [" org.mockito.* ", "$JDK", "org.mockito.*", ""], + skipSynthetics: true, + skipStaticInitializers: false, + skipConstructors: true, + hideFilteredStackFrames: true + ) + feature.updateJavaSteppingFilters(override) + + #expect(resolver.requests.last == RecordingDebugSteppingFilterResolution( + adapterID: "java", + filters: override + )) + #expect(feature.javaSteppingFilters == normalized) + #expect(persistence.filtersByAdapterID["java"] == normalized) + } + + @Test + func javaSteppingFiltersFallBackToDefaultsWhenPersistenceCannotBeRead() { + let defaults = DebugSteppingFilters( + classNameFilters: ["$JDK", "org.junit.*"], + skipSynthetics: true, + skipStaticInitializers: true, + skipConstructors: false, + hideFilteredStackFrames: true + ) + let resolver = RecordingDebugSteppingFilterResolver( + defaults: defaults, + normalizedOverride: defaults + ) + let manager = DebugAdapterSessionManager(providers: []) { _, _ in nil } + let feature = GenericDebugFeatureModel( + sessions: manager, + steppingFilterResolver: resolver, + steppingFilterPersistence: FailingDebugSteppingFilterPersistence() + ) + + #expect(feature.javaSteppingFilters == defaults) + #expect(feature.errorMessage != nil) + #expect(resolver.requests == [RecordingDebugSteppingFilterResolution( + adapterID: "java", + filters: nil + )]) + } + + @Test + func javaLaunchAppliesResolvedSteppingFilters() throws { + let transport = RecordingTransport() + let core = RecordingDebugProtocolCore() + let descriptor = DebugProviderDescriptor( + id: "java", + displayName: "Java", + fileExtensions: ["java"] + ) + let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in + CoreDebugAdapterProtocolSession( + adapterID: "java", + transport: transport, + core: core, + sessionID: "java-stepping-launch", + deadlineScheduler: RecordingDebugDeadlineScheduler() + ) + } + let feature = GenericDebugFeatureModel( + sessions: manager, + steppingFilterResolver: core + ) + let root = URL(fileURLWithPath: "/tmp/java-stepping-launch", isDirectory: true) + let source = root.appendingPathComponent("src/Main.java") + + #expect(feature.start( + fileURL: source, + rootURL: root, + configuration: DebugLaunchConfiguration( + name: "Main", + request: .launch, + arguments: ["mainClass": .string("example.Main")] + ) + )) + defer { feature.stop() } + + #expect(feature.javaSteppingFilters == core.defaultSteppingFilters) + #expect(core.lastLaunchConfiguration?.steppingFilters == core.defaultSteppingFilters) + } + + @Test + func filteredStackFramesCollapseByConsecutiveRunsAndRestoreOrder() { + let session = DeferredInspectionDebugSession() + let descriptor = DebugProviderDescriptor( + id: "java", + displayName: "Java", + fileExtensions: ["java"] + ) + let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in session } + let defaults = DebugSteppingFilters( + classNameFilters: ["$JDK"], + skipSynthetics: true, + skipStaticInitializers: true, + skipConstructors: false, + hideFilteredStackFrames: true + ) + let feature = GenericDebugFeatureModel( + sessions: manager, + steppingFilterResolver: RecordingDebugSteppingFilterResolver( + defaults: defaults, + normalizedOverride: defaults + ) + ) + let root = URL(fileURLWithPath: "/tmp/java-filtered-stack", isDirectory: true) + let source = root.appendingPathComponent("src/Main.java") + #expect(feature.start( + fileURL: source, + rootURL: root, + configuration: DebugLaunchConfiguration( + name: "Main", + request: .launch, + arguments: ["mainClass": .string("example.Main")] + ) + )) + defer { feature.stop() } + + feature.selectThread(DebugThread(id: 7, name: "main")) + session.completeStackTrace(at: 0, with: [ + DebugStackFrame( + id: 1, + name: "example.LoginController.login", + sourceURL: source, + line: 20, + column: 5 + ), + DebugStackFrame( + id: 2, + name: "java.lang.reflect.Method.invoke", + sourceURL: nil, + line: 1, + column: 1, + isFiltered: true + ), + DebugStackFrame( + id: 3, + name: "org.springframework.cglib.Proxy.invoke", + sourceURL: nil, + line: 1, + column: 1, + isFiltered: true + ), + DebugStackFrame( + id: 4, + name: "example.Dispatcher.dispatch", + sourceURL: source, + line: 42, + column: 3 + ), + DebugStackFrame( + id: 5, + name: "jdk.proxy1.$Proxy0.invoke", + sourceURL: nil, + line: 1, + column: 1, + isFiltered: true + ) + ]) + + #expect(feature.hiddenStackFrameCount == 3) + #expect(feature.visibleStackFrameRows.map(\.id) == [ + "frame-1", "filtered-2", "frame-4", "filtered-5" + ]) + #expect(feature.visibleStackFrameRows.map(\.hiddenFrameCount) == [0, 2, 0, 1]) + + feature.expandFilteredStackFrames() + #expect(feature.visibleStackFrameRows.compactMap(\.frame?.id) == [1, 2, 3, 4, 5]) + #expect(feature.visibleStackFrameRows.allSatisfy { !$0.isHiddenGroup }) + + feature.collapseFilteredStackFrames() + #expect(feature.visibleStackFrameRows.map(\.id) == [ + "frame-1", "filtered-2", "frame-4", "filtered-5" + ]) + } + @Test func rapidInspectionSelectionDiscardsOutOfOrderResults() throws { let session = DeferredInspectionDebugSession() @@ -1199,6 +1413,64 @@ private final class RecordingBreakpointPersistence: DebugBreakpointPersisting, @ } } +private struct RecordingDebugSteppingFilterResolution: Equatable { + let adapterID: String + let filters: DebugSteppingFilters? +} + +@MainActor +private final class RecordingDebugSteppingFilterResolver: DebugSteppingFilterResolving { + let defaults: DebugSteppingFilters + let normalizedOverride: DebugSteppingFilters + private(set) var requests: [RecordingDebugSteppingFilterResolution] = [] + + init(defaults: DebugSteppingFilters, normalizedOverride: DebugSteppingFilters) { + self.defaults = defaults + self.normalizedOverride = normalizedOverride + } + + func resolveDebugSteppingFilters( + adapterID: String, + filters: DebugSteppingFilters? + ) throws -> DebugSteppingFilters { + requests.append(RecordingDebugSteppingFilterResolution( + adapterID: adapterID, + filters: filters + )) + return filters == nil ? defaults : normalizedOverride + } +} + +private final class RecordingDebugSteppingFilterPersistence: + DebugSteppingFilterPersisting, + @unchecked Sendable +{ + var filtersByAdapterID: [String: DebugSteppingFilters] = [:] + + func loadSteppingFilters(adapterID: String) throws -> DebugSteppingFilters? { + filtersByAdapterID[adapterID] + } + + func saveSteppingFilters(_ filters: DebugSteppingFilters, adapterID: String) throws { + filtersByAdapterID[adapterID] = filters + } +} + +private enum DebugSteppingFilterPersistenceTestError: Error { + case unreadable +} + +private final class FailingDebugSteppingFilterPersistence: + DebugSteppingFilterPersisting, + @unchecked Sendable +{ + func loadSteppingFilters(adapterID _: String) throws -> DebugSteppingFilters? { + throw DebugSteppingFilterPersistenceTestError.unreadable + } + + func saveSteppingFilters(_: DebugSteppingFilters, adapterID _: String) throws {} +} + private struct RecordingDebugInspectionRequest: Equatable { let kind: String let threadID: Int? @@ -1312,6 +1584,21 @@ private final class RecordingDebugProtocolCore: DebugProtocolCore { private(set) var lastExecutionSingleThread: Bool? private(set) var lastExecutionThreadID: Int? private(set) var inspectionRequests: [RecordingDebugInspectionRequest] = [] + private(set) var lastLaunchConfiguration: DebugLaunchConfiguration? + let defaultSteppingFilters = DebugSteppingFilters( + classNameFilters: ["$JDK", "org.junit.*"], + skipSynthetics: true, + skipStaticInitializers: true, + skipConstructors: false, + hideFilteredStackFrames: true + ) + + func resolveDebugSteppingFilters( + adapterID _: String, + filters: DebugSteppingFilters? + ) throws -> DebugSteppingFilters { + filters ?? defaultSteppingFilters + } func createDebugSession( sessionID: String, @@ -1328,9 +1615,10 @@ private final class RecordingDebugProtocolCore: DebugProtocolCore { func launchDebugSession( sessionID: String, operationID _: String, - configuration _: DebugLaunchConfiguration + configuration: DebugLaunchConfiguration ) throws -> DebugCoreUpdate { - update(sessionID: sessionID, state: "launching") + lastLaunchConfiguration = configuration + return update(sessionID: sessionID, state: "launching") } func setDebugBreakpoints( diff --git a/macos/Tests/LitheTests/DebugBreakpointPersistenceTests.swift b/macos/Tests/LitheTests/DebugBreakpointPersistenceTests.swift index be1080af..9979b293 100644 --- a/macos/Tests/LitheTests/DebugBreakpointPersistenceTests.swift +++ b/macos/Tests/LitheTests/DebugBreakpointPersistenceTests.swift @@ -1,4 +1,5 @@ import Foundation +import LitheCoreContracts import LitheDebugModule @testable import Lithe import Testing @@ -50,6 +51,44 @@ struct DebugBreakpointPersistenceTests { try store.loadBreakpoints(for: root) } } + + @Test + func macStorePersistsSteppingFiltersByAdapter() throws { + let preferences = DebugBreakpointTestStore() + let store = MacDebugSteppingFilterStore(store: preferences) + let java = DebugSteppingFilters( + classNameFilters: ["$JDK", "org.mockito.*"], + skipSynthetics: true, + skipStaticInitializers: true, + skipConstructors: false, + hideFilteredStackFrames: true + ) + let go = DebugSteppingFilters( + classNameFilters: [], + skipSynthetics: false, + skipStaticInitializers: false, + skipConstructors: false, + hideFilteredStackFrames: false + ) + + try store.saveSteppingFilters(java, adapterID: "java") + try store.saveSteppingFilters(go, adapterID: "go") + + #expect(try store.loadSteppingFilters(adapterID: "java") == java) + #expect(try store.loadSteppingFilters(adapterID: "go") == go) + #expect(try store.loadSteppingFilters(adapterID: "python") == nil) + } + + @Test + func macStoreReportsCorruptSteppingFilterData() { + let preferences = DebugBreakpointTestStore() + preferences.set(Data("not-json".utf8), forKey: "lithe.debug.steppingFilters.java") + let store = MacDebugSteppingFilterStore(store: preferences) + + #expect(throws: MacDebugSteppingFilterStoreError.self) { + try store.loadSteppingFilters(adapterID: "java") + } + } } private final class DebugBreakpointTestStore: KeyValueStore, @unchecked Sendable { diff --git a/rust/lithe-core/src/debug/engine.rs b/rust/lithe-core/src/debug/engine.rs index 80434ebd..cdcdcd52 100644 --- a/rust/lithe-core/src/debug/engine.rs +++ b/rust/lithe-core/src/debug/engine.rs @@ -29,6 +29,7 @@ struct DebugSession { did_receive_initialized: bool, supports_configuration_done: bool, capabilities: DebugCapabilities, + stepping_filters: DebugSteppingFilters, pending_launch: Option<(String, DebugLaunchConfiguration)>, outbound_frames: Vec>, events: Vec, @@ -102,6 +103,7 @@ pub(crate) fn create_session( did_receive_initialized: false, supports_configuration_done: false, capabilities: DebugCapabilities::default(), + stepping_filters: DebugSteppingFilters::unfiltered(), pending_launch: None, outbound_frames: Vec::new(), events: Vec::new(), @@ -151,6 +153,18 @@ pub(crate) fn launch(request: LaunchRequest) -> Result Result { + validate_identifier(&request.adapter_id, "adapterId")?; + normalize_stepping_filters( + request + .filters + .unwrap_or_else(|| DebugSteppingFilters::defaults_for_adapter(&request.adapter_id)), + ) +} + /// Stores a deterministic breakpoint set and sends it after DAP initialization. pub(crate) fn set_breakpoints( mut request: SetBreakpointsRequest, @@ -678,6 +692,15 @@ impl DebugSession { configuration: DebugLaunchConfiguration, ) -> Result<(), CoreError> { let mut arguments = configuration.arguments; + let filters = normalize_stepping_filters( + configuration + .stepping_filters + .unwrap_or_else(|| DebugSteppingFilters::defaults_for_adapter(&self.adapter_id)), + )?; + if self.adapter_id == "java" && !arguments.contains_key("stepFilters") { + arguments.insert("stepFilters".to_string(), java_step_filters(&filters)); + } + self.stepping_filters = filters; arguments .entry("name".to_string()) .or_insert(Value::String(configuration.name)); @@ -1022,7 +1045,8 @@ impl DebugSession { }); } PendingRequest::Inspect { operation_id, kind } => { - let result = normalize_inspection(kind, &body)?; + let result = + normalize_inspection(kind, &body, &self.stepping_filters, &self.root_path)?; self.emit(DebugEventBody::OperationCompleted { operation_id, result, @@ -1305,6 +1329,8 @@ fn inspect_arguments(request: &InspectRequest) -> Result, Cor fn normalize_inspection( kind: DebugInspectKind, body: &Value, + stepping_filters: &DebugSteppingFilters, + root_path: &str, ) -> Result { match kind { DebugInspectKind::Threads => Ok(DebugOperationResult::Threads { @@ -1316,7 +1342,7 @@ fn normalize_inspection( DebugInspectKind::StackTrace => Ok(DebugOperationResult::StackTrace { stack_frames: required_array(body, "stackFrames")? .iter() - .filter_map(parse_stack_frame) + .filter_map(|value| parse_stack_frame(value, stepping_filters, root_path)) .collect(), }), DebugInspectKind::Scopes => Ok(DebugOperationResult::Scopes { @@ -1392,20 +1418,234 @@ fn parse_thread(value: &Value) -> Option { }) } -fn parse_stack_frame(value: &Value) -> Option { +fn parse_stack_frame( + value: &Value, + stepping_filters: &DebugSteppingFilters, + root_path: &str, +) -> Option { + let name = value.get("name")?.as_str()?.to_string(); + let source_path = value + .get("source") + .and_then(|source| source.get("path")) + .and_then(Value::as_str) + .map(str::to_string); + let presentation_hint = value.get("presentationHint").and_then(Value::as_str); Some(DebugStackFrame { id: value.get("id")?.as_i64()?, - name: value.get("name")?.as_str()?.to_string(), - source_path: value - .get("source") - .and_then(|source| source.get("path")) - .and_then(Value::as_str) - .map(str::to_string), + is_filtered: stack_frame_matches_filters( + &name, + source_path.as_deref(), + presentation_hint, + stepping_filters, + root_path, + ), + name, + source_path, line: value.get("line").and_then(Value::as_i64).unwrap_or(1), column: value.get("column").and_then(Value::as_i64).unwrap_or(1), }) } +fn normalize_stepping_filters( + mut filters: DebugSteppingFilters, +) -> Result { + const MAXIMUM_FILTER_COUNT: usize = 256; + const MAXIMUM_FILTER_LENGTH: usize = 256; + + let mut normalized = Vec::with_capacity(filters.class_name_filters.len()); + for filter in filters.class_name_filters { + let filter = filter.trim(); + if filter.is_empty() { + continue; + } + if filter.chars().count() > MAXIMUM_FILTER_LENGTH || filter.chars().any(char::is_control) { + return Err(invalid_request( + "Debug stepping filters must be short single-line class patterns.", + )); + } + normalized.push(filter.to_string()); + } + normalized.sort(); + normalized.dedup(); + if normalized.len() > MAXIMUM_FILTER_COUNT { + return Err(invalid_request( + "Debug stepping filters cannot contain more than 256 class patterns.", + )); + } + filters.class_name_filters = normalized; + Ok(filters) +} + +fn java_step_filters(filters: &DebugSteppingFilters) -> Value { + json!({ + "skipClasses": filters.class_name_filters, + "skipSynthetics": filters.skip_synthetics, + "skipStaticInitializers": filters.skip_static_initializers, + "skipConstructors": filters.skip_constructors + }) +} + +fn stack_frame_matches_filters( + name: &str, + source_path: Option<&str>, + presentation_hint: Option<&str>, + filters: &DebugSteppingFilters, + root_path: &str, +) -> bool { + filters + .class_name_filters + .iter() + .any(|filter| match filter.as_str() { + "$JDK" => is_jdk_frame(name, source_path, presentation_hint), + "$Libraries" => is_library_frame(source_path, presentation_hint, root_path), + pattern => class_pattern_matches_frame(pattern, name, source_path), + }) +} + +fn class_pattern_matches_frame(pattern: &str, name: &str, source_path: Option<&str>) -> bool { + if wildcard_match(pattern, name) || name == pattern || name.starts_with(&format!("{pattern}.")) + { + return true; + } + + let simple_type = name.split('.').next().unwrap_or(name); + if source_path.is_none() + && !pattern.contains('*') + && pattern + .rsplit('.') + .next() + .is_some_and(|value| value == simple_type) + { + return true; + } + + source_path.is_some_and(|source_path| source_path_matches_pattern(source_path, pattern)) +} + +fn source_path_matches_pattern(source_path: &str, pattern: &str) -> bool { + let source_path = source_path.replace('\\', "/"); + let slash_pattern = format!("*{}*", pattern.replace('.', "/")); + let dotted_pattern = format!("*{pattern}*"); + wildcard_match(&slash_pattern, &source_path) || wildcard_match(&dotted_pattern, &source_path) +} + +fn is_jdk_frame(name: &str, source_path: Option<&str>, presentation_hint: Option<&str>) -> bool { + if ["com.sun.", "java.", "javax.", "jdk.", "org.omg.", "sun."] + .iter() + .any(|prefix| name.starts_with(prefix)) + { + return true; + } + if presentation_hint == Some("subtle") && source_path.is_none() { + return true; + } + let Some(source_path) = source_path else { + return false; + }; + let source_path = source_path.replace('\\', "/"); + if source_path.contains("/java.base/") + || source_path.contains("/java.desktop/") + || source_path.contains("/java.logging/") + || source_path.contains("/java.management/") + || source_path.contains("/java.naming/") + || source_path.contains("/java.net.http/") + || source_path.contains("/java.sql/") + || source_path.contains("/java.xml/") + || source_path.contains("/jdk.") + { + return true; + } + [ + "java/applet", + "java/awt", + "java/beans", + "java/io", + "java/lang", + "java/math", + "java/net", + "java/nio", + "java/rmi", + "java/security", + "java/sql", + "java/text", + "java/time", + "java/util", + "javax", + "jdk", + "sun", + "com/sun", + "org/omg", + ] + .iter() + .any(|prefix| source_has_path_prefix(&source_path, prefix)) +} + +fn source_has_path_prefix(source_path: &str, prefix: &str) -> bool { + source_path.contains(&format!("/{prefix}/")) || source_path.contains(&format!("/{prefix}.")) +} + +fn is_library_frame( + source_path: Option<&str>, + presentation_hint: Option<&str>, + root_path: &str, +) -> bool { + if presentation_hint == Some("subtle") || source_path.is_none() { + return true; + } + let source_path = source_path.unwrap_or_default(); + !source_path_is_within_root(source_path, root_path) && path_is_absolute_or_uri(source_path) +} + +fn source_path_is_within_root(source_path: &str, root_path: &str) -> bool { + let source_path = normalized_comparison_path(source_path); + let root_path = normalized_comparison_path(root_path); + source_path == root_path + || source_path + .strip_prefix(&root_path) + .is_some_and(|suffix| suffix.starts_with('/')) +} + +fn normalized_comparison_path(path: &str) -> String { + path.strip_prefix("file://") + .unwrap_or(path) + .replace('\\', "/") + .trim_end_matches('/') + .to_ascii_lowercase() +} + +fn path_is_absolute_or_uri(path: &str) -> bool { + let bytes = path.as_bytes(); + path.starts_with('/') || path.contains("://") || (bytes.len() >= 2 && bytes[1] == b':') +} + +fn wildcard_match(pattern: &str, value: &str) -> bool { + let pattern = pattern.as_bytes(); + let value = value.as_bytes(); + let (mut pattern_index, mut value_index) = (0, 0); + let (mut star_index, mut star_value_index) = (None, 0); + + while value_index < value.len() { + if pattern_index < pattern.len() && pattern[pattern_index] == value[value_index] { + pattern_index += 1; + value_index += 1; + } else if pattern_index < pattern.len() && pattern[pattern_index] == b'*' { + star_index = Some(pattern_index); + pattern_index += 1; + star_value_index = value_index; + } else if let Some(star) = star_index { + pattern_index = star + 1; + star_value_index += 1; + value_index = star_value_index; + } else { + return false; + } + } + while pattern_index < pattern.len() && pattern[pattern_index] == b'*' { + pattern_index += 1; + } + pattern_index == pattern.len() +} + fn parse_scope(value: &Value) -> Option { Some(DebugScope { name: value.get("name")?.as_str()?.to_string(), @@ -1688,6 +1928,7 @@ mod tests { source_path: Some("/workspace/Main.java".to_string()), line: 12, column: 1, + is_filtered: false, }], }, }, @@ -1702,9 +1943,202 @@ mod tests { assert!(value["events"][1].get("operation_id").is_none()); assert_eq!(value["events"][1]["result"]["kind"], "stackTrace"); assert_eq!(value["events"][1]["result"]["stackFrames"][0]["id"], 7); + assert_eq!( + value["events"][1]["result"]["stackFrames"][0]["isFiltered"], + false + ); assert!(value["events"][1]["result"].get("stack_frames").is_none()); } + #[test] + fn java_stepping_filters_are_normalized_projected_and_mark_stack_frames() { + let defaults = stepping_filters(DebugSteppingFiltersRequest { + adapter_id: "java".to_string(), + filters: None, + }) + .unwrap(); + assert!(defaults.class_name_filters.contains(&"$JDK".to_string())); + assert!(defaults.skip_synthetics); + assert!(!defaults.skip_constructors); + + let filters = stepping_filters(DebugSteppingFiltersRequest { + adapter_id: "java".to_string(), + filters: Some(DebugSteppingFilters { + class_name_filters: vec![ + " org.mockito.* ".to_string(), + "$JDK".to_string(), + "org.mockito.*".to_string(), + String::new(), + ], + skip_synthetics: true, + skip_static_initializers: false, + skip_constructors: true, + hide_filtered_stack_frames: true, + }), + }) + .unwrap(); + assert_eq!(filters.class_name_filters, ["$JDK", "org.mockito.*"]); + + let multiline_error = stepping_filters(DebugSteppingFiltersRequest { + adapter_id: "java".to_string(), + filters: Some(DebugSteppingFilters { + class_name_filters: vec!["example.Valid\nexample.Invalid".to_string()], + skip_synthetics: false, + skip_static_initializers: false, + skip_constructors: false, + hide_filtered_stack_frames: false, + }), + }) + .unwrap_err(); + assert!(matches!(multiline_error.code, ErrorCode::InvalidRequest)); + + let excessive_filter_error = stepping_filters(DebugSteppingFiltersRequest { + adapter_id: "java".to_string(), + filters: Some(DebugSteppingFilters { + class_name_filters: (0..257) + .map(|index| format!("example.Type{index}")) + .collect(), + skip_synthetics: false, + skip_static_initializers: false, + skip_constructors: false, + hide_filtered_stack_frames: false, + }), + }) + .unwrap_err(); + assert!(matches!( + excessive_filter_error.code, + ErrorCode::InvalidRequest + )); + + let session_id = "debug-java-stepping-filters"; + create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "java".to_string(), + root_path: "/workspace".to_string(), + }) + .unwrap(); + launch(LaunchRequest { + session_id: session_id.to_string(), + operation_id: "launch".to_string(), + configuration: DebugLaunchConfiguration { + name: "Main".to_string(), + request: DebugRequestKind::Launch, + arguments: Map::new(), + stepping_filters: Some(filters), + }, + }) + .unwrap(); + let initialized = receive_messages( + session_id, + vec![response_message(1, "initialize", json!({}))], + ); + let launch_request = decode_frame(&initialized.outbound_frames[0]); + assert_eq!(launch_request["command"], "launch"); + assert_eq!( + launch_request["arguments"]["stepFilters"]["skipClasses"], + json!(["$JDK", "org.mockito.*"]) + ); + assert_eq!( + launch_request["arguments"]["stepFilters"]["skipConstructors"], + true + ); + receive_messages( + session_id, + vec![ + response_message(2, "launch", json!({})), + json!({ + "seq": 103, + "type": "event", + "event": "stopped", + "body": {"reason": "breakpoint", "threadId": 11} + }), + ], + ); + inspect(InspectRequest { + session_id: session_id.to_string(), + operation_id: "stack".to_string(), + kind: DebugInspectKind::StackTrace, + thread_id: Some(11), + frame_id: None, + variables_reference: None, + expression: None, + source_path: None, + line: None, + column: None, + }) + .unwrap(); + let stack = receive_messages( + session_id, + vec![response_message( + 3, + "stackTrace", + json!({"stackFrames": [ + { + "id": 7, + "name": "Method.invoke(Object,Object[])", + "source": { + "path": "jdt://contents/java.base/java/lang/reflect/Method.class" + }, + "line": 1, + "column": 1 + }, + { + "id": 8, + "name": "MockMethodInterceptor.intercept(Object,Method,Object[],Invoker)", + "source": { + "path": "jdt://contents/mockito-core/org/mockito/internal/creation/bytebuddy/MockMethodInterceptor.class" + }, + "line": 1, + "column": 1 + }, + { + "id": 9, + "name": "example.LoginService.authenticate", + "source": {"path": "/workspace/LoginService.java"}, + "line": 24, + "column": 5 + } + ]}), + )], + ); + assert!(stack.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::OperationCompleted { + operation_id, + result: DebugOperationResult::StackTrace { stack_frames } + } if operation_id == "stack" + && stack_frames.len() == 3 + && stack_frames[0].is_filtered + && stack_frames[1].is_filtered + && !stack_frames[2].is_filtered + ))); + let library_filters = DebugSteppingFilters { + class_name_filters: vec!["$Libraries".to_string()], + skip_synthetics: false, + skip_static_initializers: false, + skip_constructors: false, + hide_filtered_stack_frames: true, + }; + assert!(stack_frame_matches_filters( + "Dependency.call()", + Some("/dependencies/example/Dependency.java"), + None, + &library_filters, + "/workspace" + )); + assert!(!stack_frame_matches_filters( + "LoginService.authenticate()", + Some("/workspace/LoginService.java"), + None, + &library_filters, + "/workspace" + )); + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); + } + #[test] fn initialize_launch_breakpoints_and_inspection_are_reduced_in_order() { let session_id = "debug-engine-flow"; @@ -1727,6 +2161,7 @@ mod tests { name: "Main".to_string(), request: DebugRequestKind::Launch, arguments: Map::from_iter([("mainClass".to_string(), json!("example.Main"))]), + stepping_filters: None, }, }) .unwrap(); @@ -2112,6 +2547,7 @@ mod tests { name: "Main".to_string(), request: DebugRequestKind::Launch, arguments: Map::new(), + stepping_filters: None, }, }) .unwrap(); @@ -2188,6 +2624,7 @@ mod tests { name: "Main".to_string(), request: DebugRequestKind::Launch, arguments: Map::new(), + stepping_filters: None, }, }) .unwrap(); @@ -2304,6 +2741,7 @@ mod tests { name: "Main".to_string(), request: DebugRequestKind::Launch, arguments: Map::new(), + stepping_filters: None, }, }) .unwrap(); @@ -2399,6 +2837,7 @@ mod tests { name: "Main".to_string(), request: DebugRequestKind::Launch, arguments: Map::new(), + stepping_filters: None, }, }) .unwrap(); diff --git a/rust/lithe-core/src/debug/types.rs b/rust/lithe-core/src/debug/types.rs index 44662f60..70c3d58d 100644 --- a/rust/lithe-core/src/debug/types.rs +++ b/rust/lithe-core/src/debug/types.rs @@ -59,6 +59,98 @@ pub struct DebugLaunchConfiguration { pub request: DebugRequestKind, #[serde(default)] pub arguments: serde_json::Map, + /// Optional portable stepping policy projected into adapter-specific launch arguments. + #[serde(default)] + pub stepping_filters: Option, +} + +#[derive(Debug, Clone, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +/// Portable class and method filters used for stepping and stack-frame presentation. +pub struct DebugSteppingFilters { + /// Adapter-neutral class-name patterns; Java also accepts `$JDK` and `$Libraries`. + #[serde(default)] + pub class_name_filters: Vec, + /// Whether compiler-generated methods should be skipped. + #[serde(default)] + pub skip_synthetics: bool, + /// Whether class static initializers should be skipped. + #[serde(default)] + pub skip_static_initializers: bool, + /// Whether constructors should be skipped. + #[serde(default)] + pub skip_constructors: bool, + /// Whether native clients should collapse stack frames matched by the class filters. + #[serde(default)] + pub hide_filtered_stack_frames: bool, +} + +impl DebugSteppingFilters { + pub(crate) fn defaults_for_adapter(adapter_id: &str) -> Self { + if adapter_id == "java" { + Self::default() + } else { + Self::unfiltered() + } + } + + pub(crate) fn unfiltered() -> Self { + Self { + class_name_filters: Vec::new(), + skip_synthetics: false, + skip_static_initializers: false, + skip_constructors: false, + hide_filtered_stack_frames: false, + } + } +} + +impl Default for DebugSteppingFilters { + fn default() -> Self { + Self { + class_name_filters: default_java_class_name_filters(), + skip_synthetics: true, + skip_static_initializers: true, + // IDEA leaves constructor skipping off by default because application + // initialization is often meaningful user code. + skip_constructors: false, + hide_filtered_stack_frames: true, + } + } +} + +fn default_java_class_name_filters() -> Vec { + [ + "$JDK", + "com.ibm.ws.*", + "com.springsource.loaded.*", + "com.sun.proxy.*", + "javassist.*", + "jdk.proxy*.*", + "junit.*", + "net.bytebuddy.*", + "net.sf.cglib.*", + "org.apache.webbeans.*", + "org.junit.*", + "org.mockito.*", + "org.springframework.aop.framework.*", + "org.springframework.cglib.*", + "org.springsource.loaded.*", + ] + .into_iter() + .map(str::to_string) + .collect() +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Returns an adapter's defaults or normalizes a native client's stepping policy. +pub struct DebugSteppingFiltersRequest { + /// Stable adapter identifier, such as `java`. + pub adapter_id: String, + /// Optional client override; omission requests the adapter defaults. + #[serde(default)] + pub filters: Option, } #[derive(Debug, Clone, Deserialize)] @@ -524,6 +616,8 @@ pub struct DebugStackFrame { pub source_path: Option, pub line: i64, pub column: i64, + /// True when the active portable stepping policy matches this frame. + pub is_filtered: bool, } #[derive(Debug, Clone, Serialize)] diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index ed7967b2..954a60a8 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -87,6 +87,8 @@ pub enum CoreCommand { DebugCreateSession, /// Queues a launch or attach request for a debug session (`debug.launch`). DebugLaunch, + /// Returns or normalizes portable stepping filters (`debug.steppingFilters`). + DebugSteppingFilters, /// Replaces breakpoints for one source file (`debug.setBreakpoints`). DebugSetBreakpoints, /// Replaces exception filters for one debug session (`debug.setExceptionBreakpoints`). @@ -259,6 +261,7 @@ impl CoreCommand { "markdown.render" => Some(Self::MarkdownRender), "debug.createSession" => Some(Self::DebugCreateSession), "debug.launch" => Some(Self::DebugLaunch), + "debug.steppingFilters" => Some(Self::DebugSteppingFilters), "debug.setBreakpoints" => Some(Self::DebugSetBreakpoints), "debug.setExceptionBreakpoints" => Some(Self::DebugSetExceptionBreakpoints), "debug.setFunctionBreakpoints" => Some(Self::DebugSetFunctionBreakpoints), @@ -379,6 +382,7 @@ mod tests { for command in [ "debug.createSession", "debug.launch", + "debug.steppingFilters", "debug.setBreakpoints", "debug.setExceptionBreakpoints", "debug.setFunctionBreakpoints", diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index 750eb790..0df3de8f 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -515,6 +515,26 @@ fn execute(request: &str) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::DebugSteppingFilters => { + match serde_json::from_value::( + parsed.payload, + ) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid debug stepping-filters request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::debug::stepping_filters) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Debug stepping filters should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::DebugSetBreakpoints => { match serde_json::from_value::(parsed.payload) .map_err(|error| { diff --git a/rust/lithe-core/src/tests/protocol.rs b/rust/lithe-core/src/tests/protocol.rs index 0870fb64..eb961d29 100644 --- a/rust/lithe-core/src/tests/protocol.rs +++ b/rust/lithe-core/src/tests/protocol.rs @@ -59,3 +59,32 @@ fn debug_create_and_destroy_commands_cross_the_json_boundary() { assert_eq!(destroyed["ok"], true); assert_eq!(destroyed["data"]["destroyed"], true); } + +#[test] +fn debug_stepping_filters_match_the_shared_contract_fixture() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/debug/stepping-filters-v1.json" + ))) + .expect("debug stepping-filter fixture should be valid JSON"); + + for case in fixture["cases"] + .as_array() + .expect("debug stepping-filter fixture should contain cases") + { + let request = serde_json::json!({ + "id": case["name"], + "command": "debug.steppingFilters", + "payload": case["payload"] + }); + let response: Value = serde_json::from_str(&execute_json(&request.to_string())) + .expect("debug stepping-filter response should be JSON"); + + assert_eq!(response["ok"], true, "fixture case {}", case["name"]); + assert_eq!( + response["data"], case["expected"], + "fixture case {}", + case["name"] + ); + } +} diff --git a/shared/contracts/application-boundary.md b/shared/contracts/application-boundary.md index a051bcfa..2c454bc4 100644 --- a/shared/contracts/application-boundary.md +++ b/shared/contracts/application-boundary.md @@ -30,7 +30,7 @@ verification scripts are the executable source of boundary checks. | Runtime | Java/Maven requirements, normalized candidates, and effective toolchain references | JDK/Maven probing and executable paths | | Language tooling | provider catalog, local fallback results, complete LSP process/session runtime, capabilities, diagnostics, UTF-16 edits, and normalized feature results | executable/environment discovery and UI provider routing | | Java/Maven/Spring | deterministic Maven-root selection, project structure, modules and profiles; compiler diagnostic parsing; Java source structure, symbols, code vision, run-configuration detection, Spring configuration/bean/endpoint indexing, and JDTLS/Java Debug adapter policy | JDK/Maven discovery, local dependency-repository selection, Java/Maven child processes, and sockets | -| Run/Debug | versioned configuration documents, three-layer resolution, diagnostics, platform-neutral launch plans, DAP framing/state, breakpoints, threads, stacks, variables, and events | project file persistence, adapter discovery, child processes, sockets, native termination, and UI | +| Run/Debug | versioned configuration documents, three-layer resolution, diagnostics, platform-neutral launch plans, DAP framing/state, breakpoints, stepping filters, threads, stacks, variables, and events | project and preference persistence, adapter discovery, child processes, sockets, native termination, and UI | | Terminal | input bytes, output bytes, lifecycle | PTY/ConPTY, shell and environment | | Workbench background | versioned source (`none`, bundled slot `01`–`10`, or `custom`) and opacity | UI, image rendering, bundled-resource packaging, local-image access permission and persistence | | Local History | revision metadata, text content, restore result | persistence location and file operations | @@ -179,6 +179,13 @@ code, and diagnostic detail across the Rust, Swift, and TypeScript boundaries. Domain and adapter layers return stable reasons rather than user-facing prose; each product's presentation layer owns localized notification text. +Debugger stepping policy is portable. Rust Core owns adapter defaults, +normalization, validation, adapter launch projection, and the `isFiltered` +classification on normalized stack frames. Platform products own preference +persistence and decide whether matching consecutive frames are collapsed or +expanded in their native call-stack UI. No Debug session, adapter process, or +background task is created merely because stepping preferences exist. + For JDT LS, the standard initialize handshake and project-import readiness use separate Core-owned deadlines. Project import fails only after 45 seconds without changed progress or the 10-minute absolute safety cap; platform clients diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 26728e9d..cbe9e395 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -83,6 +83,7 @@ stable error code and a user-facing message: | `maven.diagnostics` | Parse stable Maven compiler diagnostics from build output | | `debug.createSession` | Create a transport-neutral DAP session and return its initialize frame | | `debug.launch` | Queue a launch or attach request, including during initialization | +| `debug.steppingFilters` | Return adapter defaults or normalize portable stepping filters | | `debug.setBreakpoints` | Replace and deterministically order one source's DAP breakpoints | | `debug.setExceptionBreakpoints` | Replace and deterministically order one session's exception filters | | `debug.setFunctionBreakpoints` | Replace and deterministically order one session's named function breakpoints | @@ -375,9 +376,27 @@ back through `debug.receive` as `{ sessionId, dataBase64 }`; partial and consecutive messages are buffered and reduced in Rust. `debug.launch` accepts an `operationId` and a language-neutral configuration -containing `name`, request kind (`launch` or `attach`), and provider arguments. +containing `name`, request kind (`launch` or `attach`), provider arguments, and +optional portable `steppingFilters`. Launch submitted during initialization is retained until the initialize -response. `debug.setBreakpoints` accepts one-based line and optional column, +response. For Java, Core projects those filters into the adapter's `stepFilters` +launch object unless the provider arguments already contain an explicit value. +`debug.steppingFilters` accepts `{ adapterId, filters? }`; omission of `filters` +returns deterministic adapter defaults, while a supplied value is trimmed, +sorted, de-duplicated, and validated before persistence or launch. Omitted +fields inside a supplied value are empty or false, so future adapters never +inherit Java policy accidentally. Java class +patterns support `$JDK`, `$Libraries`, and adapter-compatible wildcards. Other +adapters default to an unfiltered policy until their integration defines one. +The portable cases are in +`shared/fixtures/debug/stepping-filters-v1.json`. + +Normalized stack frames include `isFiltered`. Core derives it from the active +class filters using the DAP frame name, source path, presentation hint, and +session root. This classification is presentation metadata only: Core returns +the complete ordered stack, while native UIs may collapse consecutive matching +frames and must allow users to expand them. `debug.setBreakpoints` accepts +one-based line and optional column, enabled state, condition, hit condition, and log message values. Rust sorts and de-duplicates the complete source set, retains disabled entries without sending them to the adapter, waits for the DAP `initialized` event, then sends all diff --git a/shared/fixtures/debug/stepping-filters-v1.json b/shared/fixtures/debug/stepping-filters-v1.json new file mode 100644 index 00000000..61676859 --- /dev/null +++ b/shared/fixtures/debug/stepping-filters-v1.json @@ -0,0 +1,89 @@ +{ + "version": 1, + "cases": [ + { + "name": "java-defaults", + "payload": { + "adapterId": "java" + }, + "expected": { + "classNameFilters": [ + "$JDK", + "com.ibm.ws.*", + "com.springsource.loaded.*", + "com.sun.proxy.*", + "javassist.*", + "jdk.proxy*.*", + "junit.*", + "net.bytebuddy.*", + "net.sf.cglib.*", + "org.apache.webbeans.*", + "org.junit.*", + "org.mockito.*", + "org.springframework.aop.framework.*", + "org.springframework.cglib.*", + "org.springsource.loaded.*" + ], + "skipSynthetics": true, + "skipStaticInitializers": true, + "skipConstructors": false, + "hideFilteredStackFrames": true + } + }, + { + "name": "normalized-java-override", + "payload": { + "adapterId": "java", + "filters": { + "classNameFilters": [ + " org.mockito.* ", + "$JDK", + "org.mockito.*", + "" + ], + "skipSynthetics": true, + "skipStaticInitializers": false, + "skipConstructors": true, + "hideFilteredStackFrames": true + } + }, + "expected": { + "classNameFilters": [ + "$JDK", + "org.mockito.*" + ], + "skipSynthetics": true, + "skipStaticInitializers": false, + "skipConstructors": true, + "hideFilteredStackFrames": true + } + }, + { + "name": "unknown-adapter-is-unfiltered", + "payload": { + "adapterId": "go" + }, + "expected": { + "classNameFilters": [], + "skipSynthetics": false, + "skipStaticInitializers": false, + "skipConstructors": false, + "hideFilteredStackFrames": false + } + }, + { + "name": "unknown-adapter-empty-override-stays-unfiltered", + "payload": { + "adapterId": "python", + "filters": {} + }, + "expected": { + "classNameFilters": [], + "skipSynthetics": false, + "skipStaticInitializers": false, + "skipConstructors": false, + "hideFilteredStackFrames": false + } + } + ] +} From 0b080e1653cea2757ce7f309c581a680d04ee02e Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sun, 30 Aug 2026 03:53:20 +0800 Subject: [PATCH 34/66] feat(debug): show exception pause information --- .../Lithe/Views/Debug/GenericDebugView.swift | 83 +++++++ .../Debug/DebugAdapterContracts.swift | 57 +++++ .../Debug/DebugProtocolCore.swift | 25 +- .../GenericDebugFeatureModel.swift | 50 +++- .../CoreDebugAdapterProtocolSession.swift | 37 +++ .../DebugModuleTests.swift | 218 +++++++++++++++++- rust/lithe-core/src/debug/engine.rs | 199 ++++++++++++++++ rust/lithe-core/src/debug/types.rs | 28 +++ shared/contracts/application-boundary.md | 7 + shared/contracts/rust-core-api.md | 17 +- shared/fixtures/debug/dap-session-v1.json | 1 + shared/fixtures/debug/exception-info-v1.json | 51 ++++ 12 files changed, 753 insertions(+), 20 deletions(-) create mode 100644 shared/fixtures/debug/exception-info-v1.json diff --git a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift index 82eeadf5..de716d92 100644 --- a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift +++ b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift @@ -431,6 +431,10 @@ struct GenericDebugView: View { VStack(spacing: 0) { ScrollView { LazyVStack(alignment: .leading, spacing: 0) { + if let exceptionInfo = feature.exceptionInfo { + exceptionInspector(exceptionInfo) + divider + } sectionHeader("Variables", count: feature.variables.count) if feature.variables.isEmpty { placeholder("Select a stack frame to inspect variables") @@ -815,6 +819,85 @@ struct GenericDebugView: View { .litheWorkbenchSurface(LitheTheme.sidebar) } + private func exceptionInspector(_ info: DebugExceptionInfo) -> some View { + VStack(alignment: .leading, spacing: 7) { + HStack(spacing: 7) { + Label("Exception", systemImage: "exclamationmark.octagon.fill") + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(LitheTheme.error) + Spacer(minLength: 8) + Text(exceptionBreakModeTitle(info.breakMode)) + .font(.system(size: 9.5, weight: .medium)) + .foregroundStyle(LitheTheme.secondaryText) + } + Text(info.exceptionID) + .font(.system(size: 11, weight: .semibold, design: .monospaced)) + .foregroundStyle(LitheTheme.primaryText) + .textSelection(.enabled) + if let description = info.description, + !description.isEmpty, + description != info.exceptionID { + Text(description) + .font(.system(size: 10.5, design: .monospaced)) + .foregroundStyle(LitheTheme.warning) + .textSelection(.enabled) + } + if let details = info.details { + if let message = details.message, + !message.isEmpty, + message != info.description { + Text(message) + .font(.system(size: 10.5)) + .foregroundStyle(LitheTheme.secondaryText) + .textSelection(.enabled) + } + ForEach(Array(nestedExceptionDetails(details).enumerated()), id: \.offset) { _, cause in + HStack(alignment: .firstTextBaseline, spacing: 5) { + Image(systemName: "arrow.turn.down.right") + .font(.system(size: 8)) + .foregroundStyle(LitheTheme.secondaryText) + VStack(alignment: .leading, spacing: 1) { + Text(cause.fullTypeName ?? cause.typeName ?? "Nested exception") + .font(.system(size: 10, weight: .medium, design: .monospaced)) + if let message = cause.message, !message.isEmpty { + Text(message) + .font(.system(size: 9.5)) + .foregroundStyle(LitheTheme.secondaryText) + } + } + } + } + if let stackTrace = details.stackTrace, !stackTrace.isEmpty { + Text(stackTrace) + .font(.system(size: 9.5, design: .monospaced)) + .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(12) + .textSelection(.enabled) + } + } + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(LitheTheme.error.opacity(0.06)) + .accessibilityElement(children: .contain) + } + + private func nestedExceptionDetails( + _ details: DebugExceptionDetails + ) -> [DebugExceptionDetails] { + details.innerExceptions.flatMap { [$0] + nestedExceptionDetails($0) } + } + + private func exceptionBreakModeTitle(_ breakMode: String) -> String { + switch breakMode { + case "always": "Always break" + case "unhandled": "Unhandled" + case "userUnhandled": "User-unhandled" + case "never": "Never break" + default: breakMode + } + } + private var evaluateRow: some View { HStack(spacing: 6) { Image(systemName: "function") diff --git a/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift b/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift index 75a66a6f..994d8789 100644 --- a/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift +++ b/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift @@ -282,6 +282,50 @@ public struct DebugDataBreakpointInfo: Equatable, Sendable { } } +public struct DebugExceptionInfo: Equatable, Sendable { + public let exceptionID: String + public let description: String? + public let breakMode: String + public let details: DebugExceptionDetails? + + public init( + exceptionID: String, + description: String?, + breakMode: String, + details: DebugExceptionDetails? + ) { + self.exceptionID = exceptionID + self.description = description + self.breakMode = breakMode + self.details = details + } +} + +public struct DebugExceptionDetails: Equatable, Sendable { + public let message: String? + public let typeName: String? + public let fullTypeName: String? + public let evaluateName: String? + public let stackTrace: String? + public let innerExceptions: [DebugExceptionDetails] + + public init( + message: String?, + typeName: String?, + fullTypeName: String?, + evaluateName: String?, + stackTrace: String?, + innerExceptions: [DebugExceptionDetails] = [] + ) { + self.message = message + self.typeName = typeName + self.fullTypeName = fullTypeName + self.evaluateName = evaluateName + self.stackTrace = stackTrace + self.innerExceptions = innerExceptions + } +} + public struct DebugStepInTarget: Identifiable, Equatable, Sendable { public let id: Int public let label: String @@ -351,6 +395,7 @@ public struct DebugAdapterCapabilities: Equatable, Sendable { public let supportsRestartRequest: Bool public let supportsTerminateRequest: Bool public let supportsStepBack: Bool + public let supportsExceptionInfoRequest: Bool public let supportsStepInTargetsRequest: Bool public let supportsGotoTargetsRequest: Bool public let exceptionBreakpointFilters: [DebugExceptionBreakpointFilter] @@ -373,6 +418,7 @@ public struct DebugAdapterCapabilities: Equatable, Sendable { supportsRestartRequest: Bool = false, supportsTerminateRequest: Bool = false, supportsStepBack: Bool = false, + supportsExceptionInfoRequest: Bool = false, supportsStepInTargetsRequest: Bool = false, supportsGotoTargetsRequest: Bool = false, exceptionBreakpointFilters: [DebugExceptionBreakpointFilter] = [] @@ -392,6 +438,7 @@ public struct DebugAdapterCapabilities: Equatable, Sendable { self.supportsRestartRequest = supportsRestartRequest self.supportsTerminateRequest = supportsTerminateRequest self.supportsStepBack = supportsStepBack + self.supportsExceptionInfoRequest = supportsExceptionInfoRequest self.supportsStepInTargetsRequest = supportsStepInTargetsRequest self.supportsGotoTargetsRequest = supportsGotoTargetsRequest self.exceptionBreakpointFilters = exceptionBreakpointFilters @@ -522,6 +569,10 @@ public protocol DebugAdapterControllingSession: DebugAdapterSession { completion: @escaping (Result<[DebugGotoTarget], Error>) -> Void ) func requestThreads(_ completion: @escaping (Result<[DebugThread], Error>) -> Void) + func requestExceptionInfo( + threadID: Int, + completion: @escaping (Result) -> Void + ) func requestStackTrace(threadID: Int, completion: @escaping (Result<[DebugStackFrame], Error>) -> Void) func requestScopes(frameID: Int, completion: @escaping (Result<[DebugScope], Error>) -> Void) func requestVariables(reference: Int, completion: @escaping (Result<[DebugVariable], Error>) -> Void) @@ -573,6 +624,12 @@ public extension DebugAdapterControllingSession { ) { completion(.failure(DebugAdapterCapabilityError.unsupported("data breakpoints"))) } + func requestExceptionInfo( + threadID _: Int, + completion: @escaping (Result) -> Void + ) { + completion(.failure(DebugAdapterCapabilityError.unsupported("exception information"))) + } func setVariable( variablesReference _: Int, name _: String, diff --git a/macos/Sources/LitheCoreContracts/Debug/DebugProtocolCore.swift b/macos/Sources/LitheCoreContracts/Debug/DebugProtocolCore.swift index 6f62d8ea..d3087403 100644 --- a/macos/Sources/LitheCoreContracts/Debug/DebugProtocolCore.swift +++ b/macos/Sources/LitheCoreContracts/Debug/DebugProtocolCore.swift @@ -74,6 +74,7 @@ public struct DebugCoreCapabilities: Decodable, Equatable, Sendable { public let supportsRestartRequest: Bool public let supportsTerminateRequest: Bool public let supportsStepBack: Bool + public let supportsExceptionInfoRequest: Bool public let supportsStepInTargetsRequest: Bool public let supportsGotoTargetsRequest: Bool public let exceptionBreakpointFilters: [DebugExceptionBreakpointFilter] @@ -87,6 +88,7 @@ public struct DebugCoreOperationResult: Decodable, Equatable, Sendable { public let scopes: [DebugCoreScope]? public let variables: [DebugCoreVariable]? public let variable: DebugCoreVariable? + public let exceptionInfo: DebugCoreExceptionInfo? public let dataID: String? public let description: String? public let accessTypes: [String]? @@ -94,7 +96,7 @@ public struct DebugCoreOperationResult: Decodable, Equatable, Sendable { public let targets: [DebugCoreTarget]? private enum CodingKeys: String, CodingKey { - case kind, command, threads, stackFrames, scopes, variables, variable + case kind, command, threads, stackFrames, scopes, variables, variable, exceptionInfo case dataID = "dataId" case description, accessTypes, canPersist, targets } @@ -185,6 +187,27 @@ public struct DebugCoreVariable: Decodable, Equatable, Sendable { public let variablesReference: Int } +public struct DebugCoreExceptionInfo: Decodable, Equatable, Sendable { + public let exceptionID: String + public let description: String? + public let breakMode: String + public let details: DebugCoreExceptionDetails? + + private enum CodingKeys: String, CodingKey { + case exceptionID = "exceptionId" + case description, breakMode, details + } +} + +public struct DebugCoreExceptionDetails: Decodable, Equatable, Sendable { + public let message: String? + public let typeName: String? + public let fullTypeName: String? + public let evaluateName: String? + public let stackTrace: String? + public let innerExceptions: [DebugCoreExceptionDetails] +} + /// Focused policy boundary for portable debugger stepping defaults and validation. @MainActor public protocol DebugSteppingFilterResolving: Sendable { diff --git a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift index 287e46c7..dfb54b64 100644 --- a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift +++ b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift @@ -88,6 +88,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu @Published public private(set) var output = "" @Published public private(set) var errorMessage: String? @Published public private(set) var stoppedReason: String? + @Published public private(set) var exceptionInfo: DebugExceptionInfo? @Published public private(set) var breakpoints: [GenericDebugBreakpoint] = [] @Published public private(set) var exceptionBreakpoints: [GenericDebugExceptionBreakpoint] = [] @Published public private(set) var functionBreakpoints: [GenericDebugFunctionBreakpoint] = [] @@ -208,6 +209,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu output = "" errorMessage = nil stoppedReason = nil + exceptionInfo = nil threads = [] stackFrames = [] scopes = [] @@ -262,6 +264,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } state = .idle stoppedReason = nil + exceptionInfo = nil selectedThreadID = nil selectedFrameID = nil stoppedFrame = nil @@ -692,6 +695,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu public func selectThread(_ thread: DebugThread) { let generation = beginInspectionTransition() + exceptionInfo = nil selectedThreadID = thread.id selectedFrameID = nil selectedFrame = nil @@ -711,7 +715,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu self.areFilteredStackFramesExpanded = false self.selectedFrameID = frames.first?.id if let frame = frames.first { - self.selectFrame(frame) + self.selectFrame(frame, generation: generation) } else { self.selectedFrame = nil } @@ -722,6 +726,10 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu public func selectFrame(_ frame: DebugStackFrame) { let generation = beginInspectionTransition() + selectFrame(frame, generation: generation) + } + + private func selectFrame(_ frame: DebugStackFrame, generation: Int) { selectedFrameID = frame.id selectedFrame = frame scopes = [] @@ -975,6 +983,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu case .stopped(let reason, let threadID, let description): let generation = beginInspectionTransition() stoppedReason = description ?? reason + exceptionInfo = nil selectedThreadID = threadID selectedFrameID = nil selectedFrame = nil @@ -984,10 +993,18 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu scopes = [] resetVariableTree() invalidateWatchResults() - loadStoppedContext(threadID: threadID, generation: generation) + if reason == "exception", let threadID { + loadExceptionInfo(threadID: threadID, generation: generation) + } + loadStoppedContext( + threadID: threadID, + generation: generation, + shouldLoadExceptionInfo: reason == "exception" && threadID == nil + ) case .continued: invalidateInspectionRequests() stoppedReason = nil + exceptionInfo = nil selectedThreadID = nil selectedFrameID = nil stoppedFrame = nil @@ -1001,6 +1018,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu case .terminated(let exitCode): invalidateInspectionRequests() stoppedReason = nil + exceptionInfo = nil selectedThreadID = nil selectedFrameID = nil stoppedFrame = nil @@ -1035,7 +1053,11 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } } - private func loadStoppedContext(threadID: Int?, generation: Int) { + private func loadStoppedContext( + threadID: Int?, + generation: Int, + shouldLoadExceptionInfo: Bool + ) { guard let session = activeSession else { return } session.requestThreads { [weak self] result in guard let self, self.inspectionGeneration == generation else { return } @@ -1047,6 +1069,12 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } ?? threads.first?.id ?? threadID self.selectedThreadID = selectedThreadID if let selectedThreadID { + if shouldLoadExceptionInfo { + self.loadExceptionInfo( + threadID: selectedThreadID, + generation: generation + ) + } self.loadStoppedStack(threadID: selectedThreadID, generation: generation) } case .failure(let error): @@ -1069,7 +1097,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu self.areFilteredStackFramesExpanded = false self.selectedFrameID = frames.first?.id if let frame = frames.first { - self.selectFrame(frame) + self.selectFrame(frame, generation: generation) } else { self.selectedFrame = nil } @@ -1079,6 +1107,20 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } } + private func loadExceptionInfo(threadID: Int, generation: Int) { + guard capabilities.supportsExceptionInfoRequest, + let session = activeSession else { return } + session.requestExceptionInfo(threadID: threadID) { [weak self] result in + guard let self, self.inspectionGeneration == generation else { return } + switch result { + case .success(let info): + self.exceptionInfo = info + case .failure(let error): + self.record(error) + } + } + } + private func beginInspectionTransition() -> Int { inspectionGeneration &+= 1 activeSession?.cancelPendingOperations() diff --git a/macos/Sources/LitheDebugModule/Runtime/CoreDebugAdapterProtocolSession.swift b/macos/Sources/LitheDebugModule/Runtime/CoreDebugAdapterProtocolSession.swift index c8a3d9aa..4d7f92e2 100644 --- a/macos/Sources/LitheDebugModule/Runtime/CoreDebugAdapterProtocolSession.swift +++ b/macos/Sources/LitheDebugModule/Runtime/CoreDebugAdapterProtocolSession.swift @@ -233,6 +233,29 @@ public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSessi } } + public func requestExceptionInfo( + threadID: Int, + completion: @escaping (Result) -> Void + ) { + guard capabilities.supportsExceptionInfoRequest else { + completion(.failure(DebugAdapterCapabilityError.unsupported("exception information"))) + return + } + inspect(kind: "exceptionInfo", threadID: threadID) { result in + completion(result.flatMap { value in + guard value.kind == "exceptionInfo", let info = value.exceptionInfo else { + return .failure(DebugAdapterProtocolError.invalidResponse("exceptionInfo")) + } + return .success(DebugExceptionInfo( + exceptionID: info.exceptionID, + description: info.description, + breakMode: info.breakMode, + details: info.details.map(Self.makeExceptionDetails) + )) + }) + } + } + public func requestStackTrace( threadID: Int, completion: @escaping (Result<[DebugStackFrame], Error>) -> Void @@ -573,6 +596,7 @@ public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSessi supportsRestartRequest: value.supportsRestartRequest, supportsTerminateRequest: value.supportsTerminateRequest, supportsStepBack: value.supportsStepBack, + supportsExceptionInfoRequest: value.supportsExceptionInfoRequest, supportsStepInTargetsRequest: value.supportsStepInTargetsRequest, supportsGotoTargetsRequest: value.supportsGotoTargetsRequest, exceptionBreakpointFilters: value.exceptionBreakpointFilters @@ -647,4 +671,17 @@ public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSessi containerReference: containerReference ) } + + private static func makeExceptionDetails( + _ details: DebugCoreExceptionDetails + ) -> DebugExceptionDetails { + DebugExceptionDetails( + message: details.message, + typeName: details.typeName, + fullTypeName: details.fullTypeName, + evaluateName: details.evaluateName, + stackTrace: details.stackTrace, + innerExceptions: details.innerExceptions.map(makeExceptionDetails) + ) + } } diff --git a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift index b3e74a2a..2340af51 100644 --- a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift +++ b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift @@ -46,6 +46,7 @@ struct DebugModuleTests { "supportsRestartRequest": true, "supportsTerminateRequest": true, "supportsStepBack": true, + "supportsExceptionInfoRequest": true, "supportsStepInTargetsRequest": true, "supportsGotoTargetsRequest": true, "exceptionBreakpointFilters": [[ @@ -62,6 +63,7 @@ struct DebugModuleTests { #expect(session.capabilities.supportsConditionalBreakpoints) #expect(session.capabilities.supportsFunctionBreakpoints) #expect(session.capabilities.supportsDataBreakpoints) + #expect(session.capabilities.supportsExceptionInfoRequest) #expect(session.capabilities.exceptionBreakpointFilters.first?.filter == "caught") var dataInfoResult: Result? @@ -132,6 +134,56 @@ struct DebugModuleTests { #expect(try threadsResult?.get() == [DebugThread(id: 7, name: "main")]) + var exceptionInfoResult: Result? + session.requestExceptionInfo(threadID: 7) { exceptionInfoResult = $0 } + let exceptionOperationID = try #require(core.lastInspectionOperationID) + core.enqueueReceive(state: "paused", events: [[ + "sequence": 6, + "type": "operationCompleted", + "operationId": exceptionOperationID, + "result": [ + "kind": "exceptionInfo", + "exceptionInfo": [ + "exceptionId": "java.lang.IllegalStateException", + "description": "java.lang.IllegalStateException: session expired", + "breakMode": "always", + "details": [ + "message": "session expired", + "typeName": "IllegalStateException", + "fullTypeName": "java.lang.IllegalStateException", + "evaluateName": "exception", + "stackTrace": "at example.Main.run(Main.java:12)", + "innerExceptions": [[ + "message": "token expired", + "typeName": "TokenExpiredException", + "fullTypeName": "example.TokenExpiredException", + "innerExceptions": [] + ]] + ] + ] + ] + ]]) + transport.emitData(Data("exception-info-response".utf8)) + #expect(try exceptionInfoResult?.get() == DebugExceptionInfo( + exceptionID: "java.lang.IllegalStateException", + description: "java.lang.IllegalStateException: session expired", + breakMode: "always", + details: DebugExceptionDetails( + message: "session expired", + typeName: "IllegalStateException", + fullTypeName: "java.lang.IllegalStateException", + evaluateName: "exception", + stackTrace: "at example.Main.run(Main.java:12)", + innerExceptions: [DebugExceptionDetails( + message: "token expired", + typeName: "TokenExpiredException", + fullTypeName: "example.TokenExpiredException", + evaluateName: nil, + stackTrace: nil + )] + ) + )) + var stepTargetsResult: Result<[DebugStepInTarget], Error>? session.requestStepInTargets(frameID: 7) { stepTargetsResult = $0 } let stepTargetsOperationID = try #require(core.lastInspectionOperationID) @@ -221,14 +273,40 @@ struct DebugModuleTests { core.enqueueReceive(sessionID: "java-stopped-context", state: "paused", events: [[ "sequence": 1, + "type": "capabilities", + "capabilities": [ + "supportsConfigurationDone": false, + "supportsConditionalBreakpoints": false, + "supportsHitConditionalBreakpoints": false, + "supportsLogPoints": false, + "supportsFunctionBreakpoints": false, + "supportsDataBreakpoints": false, + "supportsExceptionOptions": false, + "supportsExceptionFilterOptions": false, + "supportsSetVariable": false, + "supportsCancelRequest": false, + "supportsSingleThreadExecutionRequests": false, + "supportsRestartRequest": false, + "supportsTerminateRequest": false, + "supportsStepBack": false, + "supportsExceptionInfoRequest": true, + "supportsStepInTargetsRequest": false, + "supportsGotoTargetsRequest": false, + "exceptionBreakpointFilters": [] + ] + ], [ + "sequence": 2, "type": "stopped", - "reason": "breakpoint", - "threadId": 13 + "reason": "exception", + "threadId": 13, + "description": "java.lang.IllegalStateException: session expired" ]]) transport.emitData(Data("stopped-event".utf8)) - #expect(core.inspectionRequests.map(\.kind) == ["threads"]) + #expect(core.inspectionRequests.map(\.kind) == ["exceptionInfo", "threads"]) - let threadsOperationID = try #require(core.lastInspectionOperationID) + let threadsOperationID = try #require( + core.inspectionRequests.first(where: { $0.kind == "threads" })?.operationID + ) core.enqueueReceive(sessionID: "java-stopped-context", state: "paused", events: [[ "sequence": 2, "type": "operationCompleted", @@ -242,10 +320,14 @@ struct DebugModuleTests { ] ]]) transport.emitData(Data("threads-response".utf8)) - #expect(core.inspectionRequests.map(\.kind) == ["threads", "stackTrace"]) + #expect(core.inspectionRequests.map(\.kind) == [ + "exceptionInfo", "threads", "stackTrace" + ]) #expect(core.inspectionRequests.last?.threadID == 13) - let stackOperationID = try #require(core.lastInspectionOperationID) + let stackOperationID = try #require( + core.inspectionRequests.first(where: { $0.kind == "stackTrace" })?.operationID + ) core.enqueueReceive(sessionID: "java-stopped-context", state: "paused", events: [[ "sequence": 3, "type": "operationCompleted", @@ -262,10 +344,14 @@ struct DebugModuleTests { ] ]]) transport.emitData(Data("stack-response".utf8)) - #expect(core.inspectionRequests.map(\.kind) == ["threads", "stackTrace", "scopes"]) + #expect(core.inspectionRequests.map(\.kind) == [ + "exceptionInfo", "threads", "stackTrace", "scopes" + ]) #expect(core.inspectionRequests.last?.frameID == 70) - let scopesOperationID = try #require(core.lastInspectionOperationID) + let scopesOperationID = try #require( + core.inspectionRequests.first(where: { $0.kind == "scopes" })?.operationID + ) core.enqueueReceive(sessionID: "java-stopped-context", state: "paused", events: [[ "sequence": 4, "type": "operationCompleted", @@ -281,11 +367,13 @@ struct DebugModuleTests { ]]) transport.emitData(Data("scopes-response".utf8)) #expect(core.inspectionRequests.map(\.kind) == [ - "threads", "stackTrace", "scopes", "variables" + "exceptionInfo", "threads", "stackTrace", "scopes", "variables" ]) #expect(core.inspectionRequests.last?.variablesReference == 200) - let variablesOperationID = try #require(core.lastInspectionOperationID) + let variablesOperationID = try #require( + core.inspectionRequests.first(where: { $0.kind == "variables" })?.operationID + ) core.enqueueReceive(sessionID: "java-stopped-context", state: "paused", events: [[ "sequence": 5, "type": "operationCompleted", @@ -311,6 +399,25 @@ struct DebugModuleTests { #expect(stoppedLocation?.0 == source.standardizedFileURL) #expect(stoppedLocation?.1 == 12) #expect(stoppedLocation?.2 == 5) + + let exceptionOperationID = try #require( + core.inspectionRequests.first(where: { $0.kind == "exceptionInfo" })?.operationID + ) + core.enqueueReceive(sessionID: "java-stopped-context", state: "paused", events: [[ + "sequence": 6, + "type": "operationCompleted", + "operationId": exceptionOperationID, + "result": [ + "kind": "exceptionInfo", + "exceptionInfo": [ + "exceptionId": "java.lang.IllegalStateException", + "description": "java.lang.IllegalStateException: session expired", + "breakMode": "always" + ] + ] + ]]) + transport.emitData(Data("late-exception-response".utf8)) + #expect(feature.exceptionInfo?.exceptionID == "java.lang.IllegalStateException") } @Test @@ -656,6 +763,72 @@ struct DebugModuleTests { #expect(feature.scopes.isEmpty) } + @Test + func exceptionStopsLoadCurrentMetadataAndDiscardStaleResponses() throws { + let capabilities = DebugAdapterCapabilities( + negotiated: true, + supportsExceptionInfoRequest: true + ) + let session = DeferredInspectionDebugSession(capabilities: capabilities) + let descriptor = DebugProviderDescriptor( + id: "java", + displayName: "Java", + fileExtensions: ["java"] + ) + let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in session } + let feature = GenericDebugFeatureModel(sessions: manager) + let root = URL(fileURLWithPath: "/tmp/java-exception-info", isDirectory: true) + let source = root.appendingPathComponent("src/Main.java") + #expect(feature.start( + fileURL: source, + rootURL: root, + configuration: DebugLaunchConfiguration( + name: "Main", + request: .launch, + arguments: ["mainClass": .string("example.Main")] + ) + )) + defer { feature.stop() } + session.emit(.capabilities(capabilities)) + + let staleInfo = DebugExceptionInfo( + exceptionID: "example.StaleException", + description: "stale", + breakMode: "always", + details: nil + ) + let currentInfo = DebugExceptionInfo( + exceptionID: "example.LoginException", + description: "Login failed", + breakMode: "userUnhandled", + details: nil + ) + + session.emit(.stopped(reason: "exception", threadID: 1, description: "stale stop")) + #expect(session.exceptionInfoThreadIDs == [1]) + session.emit(.stopped(reason: "breakpoint", threadID: 2, description: nil)) + session.completeExceptionInfo(at: 0, with: staleInfo) + #expect(feature.exceptionInfo == nil) + + session.emit(.stopped(reason: "exception", threadID: 3, description: "Login failed")) + #expect(session.exceptionInfoThreadIDs == [1, 3]) + session.completeExceptionInfo(at: 1, with: currentInfo) + #expect(feature.exceptionInfo == currentInfo) + + session.emit(.continued(threadID: 3)) + #expect(feature.exceptionInfo == nil) + session.emit(.capabilities(.unknown)) + session.emit(.stopped(reason: "exception", threadID: 4, description: nil)) + #expect(session.exceptionInfoThreadIDs == [1, 3]) + + session.emit(.capabilities(capabilities)) + session.emit(.stopped(reason: "exception", threadID: 5, description: nil)) + session.completeExceptionInfo(at: 2, with: currentInfo) + #expect(feature.exceptionInfo == currentInfo) + session.emit(.terminated(exitCode: 1)) + #expect(feature.exceptionInfo == nil) + } + @Test func genericBreakpointsPreserveAdvancedOptionsAcrossMuteAndClear() throws { let transport = RecordingTransport() @@ -729,6 +902,7 @@ struct DebugModuleTests { "supportsRestartRequest": false, "supportsTerminateRequest": false, "supportsStepBack": false, + "supportsExceptionInfoRequest": false, "supportsStepInTargetsRequest": false, "supportsGotoTargetsRequest": false, "exceptionBreakpointFilters": [[ @@ -1071,6 +1245,7 @@ struct DebugModuleTests { "supportsDataBreakpoints": true, "supportsSetVariable": true, "supportsStepBack": true, + "supportsExceptionInfoRequest": false, "supportsStepInTargetsRequest": true, "supportsGotoTargetsRequest": true, "supportsRestartRequest": true, @@ -1472,6 +1647,7 @@ private final class FailingDebugSteppingFilterPersistence: } private struct RecordingDebugInspectionRequest: Equatable { + let operationID: String let kind: String let threadID: Int? let frameID: Int? @@ -1480,6 +1656,7 @@ private struct RecordingDebugInspectionRequest: Equatable { @MainActor private final class DeferredInspectionDebugSession: DebugAdapterControllingSession { + let capabilities: DebugAdapterCapabilities private(set) var isRunning = false private(set) var state: DebugAdapterState = .idle var onStateChange: ((DebugAdapterState) -> Void)? @@ -1497,10 +1674,19 @@ private final class DeferredInspectionDebugSession: DebugAdapterControllingSessi reference: Int, completion: (Result<[DebugVariable], Error>) -> Void )] = [] + private var exceptionInfoRequests: [( + threadID: Int, + completion: (Result) -> Void + )] = [] var stackTraceThreadIDs: [Int] { stackTraceRequests.map(\.threadID) } var scopeFrameIDs: [Int] { scopeRequests.map(\.frameID) } var variableReferences: [Int] { variableRequests.map(\.reference) } + var exceptionInfoThreadIDs: [Int] { exceptionInfoRequests.map(\.threadID) } + + init(capabilities: DebugAdapterCapabilities = .unknown) { + self.capabilities = capabilities + } func start(rootURL _: URL) throws { isRunning = true @@ -1521,6 +1707,13 @@ private final class DeferredInspectionDebugSession: DebugAdapterControllingSessi func execute(_: DebugExecutionCommand, threadID _: Int?) {} func requestThreads(_: @escaping (Result<[DebugThread], Error>) -> Void) {} + func requestExceptionInfo( + threadID: Int, + completion: @escaping (Result) -> Void + ) { + exceptionInfoRequests.append((threadID, completion)) + } + func requestStackTrace( threadID: Int, completion: @escaping (Result<[DebugStackFrame], Error>) -> Void @@ -1567,6 +1760,10 @@ private final class DeferredInspectionDebugSession: DebugAdapterControllingSessi func completeVariables(at index: Int, with variables: [DebugVariable]) { variableRequests[index].completion(.success(variables)) } + + func completeExceptionInfo(at index: Int, with info: DebugExceptionInfo) { + exceptionInfoRequests[index].completion(.success(info)) + } } @MainActor @@ -1721,6 +1918,7 @@ private final class RecordingDebugProtocolCore: DebugProtocolCore { ) throws -> DebugCoreUpdate { lastInspectionOperationID = operationID inspectionRequests.append(RecordingDebugInspectionRequest( + operationID: operationID, kind: kind, threadID: threadID, frameID: frameID, diff --git a/rust/lithe-core/src/debug/engine.rs b/rust/lithe-core/src/debug/engine.rs index cdcdcd52..199bf4c2 100644 --- a/rust/lithe-core/src/debug/engine.rs +++ b/rust/lithe-core/src/debug/engine.rs @@ -625,6 +625,18 @@ pub(crate) fn inspect(request: InspectRequest) -> Result Result, Cor arguments.insert("frameId".to_string(), json!(frame_id)); } } + DebugInspectKind::ExceptionInfo => { + arguments.insert( + "threadId".to_string(), + json!(required_positive(request.thread_id, "threadId")?), + ); + } DebugInspectKind::StepInTargets => { arguments.insert( "frameId".to_string(), @@ -1373,6 +1391,14 @@ fn normalize_inspection( .unwrap_or(0), }, }), + DebugInspectKind::ExceptionInfo => Ok(DebugOperationResult::ExceptionInfo { + exception_info: DebugExceptionInfo { + exception_id: required_str(body, "exceptionId")?.to_string(), + description: string_field(body, "description"), + break_mode: required_str(body, "breakMode")?.to_string(), + details: body.get("details").and_then(parse_exception_details), + }, + }), DebugInspectKind::StepInTargets => Ok(DebugOperationResult::StepInTargets { targets: required_array(body, "targets")? .iter() @@ -1388,6 +1414,24 @@ fn normalize_inspection( } } +fn parse_exception_details(value: &Value) -> Option { + value.as_object()?; + Some(DebugExceptionDetails { + message: string_field(value, "message"), + type_name: string_field(value, "typeName"), + full_type_name: string_field(value, "fullTypeName"), + evaluate_name: string_field(value, "evaluateName"), + stack_trace: string_field(value, "stackTrace"), + inner_exceptions: value + .get("innerException") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(parse_exception_details) + .collect(), + }) +} + fn parse_step_in_target(value: &Value) -> Option { Some(DebugStepInTarget { id: value.get("id")?.as_i64()?, @@ -1721,6 +1765,7 @@ fn parse_capabilities(value: &Value) -> DebugCapabilities { supports_restart_request: bool_field(value, "supportsRestartRequest"), supports_terminate_request: bool_field(value, "supportsTerminateRequest"), supports_step_back: bool_field(value, "supportsStepBack"), + supports_exception_info_request: bool_field(value, "supportsExceptionInfoRequest"), supports_step_in_targets_request: bool_field(value, "supportsStepInTargetsRequest"), supports_goto_targets_request: bool_field(value, "supportsGotoTargetsRequest"), exception_breakpoint_filters: value @@ -2980,6 +3025,160 @@ mod tests { .unwrap(); } + #[test] + fn exception_information_is_capability_gated_and_normalized() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/debug/exception-info-v1.json" + ))) + .unwrap(); + let session_id = "debug-exception-info"; + create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "java".to_string(), + root_path: "/workspace".to_string(), + }) + .unwrap(); + launch(LaunchRequest { + session_id: session_id.to_string(), + operation_id: "launch".to_string(), + configuration: DebugLaunchConfiguration { + name: "Main".to_string(), + request: DebugRequestKind::Launch, + arguments: Map::new(), + stepping_filters: None, + }, + }) + .unwrap(); + let initialized = receive_messages( + session_id, + vec![response_message( + 1, + "initialize", + json!({"supportsExceptionInfoRequest": true}), + )], + ); + assert!(initialized.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::Capabilities { capabilities } + if capabilities.supports_exception_info_request + ))); + receive_messages(session_id, vec![response_message(2, "launch", json!({}))]); + receive_messages( + session_id, + vec![json!({ + "seq": 103, + "type": "event", + "event": "stopped", + "body": {"reason": "exception", "threadId": fixture["request"]["threadId"]} + })], + ); + + let inspection = inspect(InspectRequest { + session_id: session_id.to_string(), + operation_id: fixture["request"]["operationId"] + .as_str() + .unwrap() + .to_string(), + kind: DebugInspectKind::ExceptionInfo, + thread_id: fixture["request"]["threadId"].as_i64(), + frame_id: None, + variables_reference: None, + expression: None, + source_path: None, + line: None, + column: None, + }) + .unwrap(); + let request = decode_frame(&inspection.outbound_frames[0]); + assert_eq!(request["command"], "exceptionInfo"); + assert_eq!( + request["arguments"]["threadId"], + fixture["request"]["threadId"] + ); + + let completed = receive_messages( + session_id, + vec![response_message( + 3, + "exceptionInfo", + fixture["adapterResponse"].clone(), + )], + ); + let result = completed.events.iter().find_map(|event| match &event.body { + DebugEventBody::OperationCompleted { + operation_id, + result, + } if operation_id == fixture["request"]["operationId"].as_str().unwrap() => { + Some(result) + } + _ => None, + }); + assert_eq!( + serde_json::to_value(result.unwrap()).unwrap(), + fixture["expected"] + ); + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); + } + + #[test] + fn exception_information_is_rejected_when_the_adapter_does_not_support_it() { + let session_id = "debug-exception-info-unsupported"; + create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "java".to_string(), + root_path: "/workspace".to_string(), + }) + .unwrap(); + launch(LaunchRequest { + session_id: session_id.to_string(), + operation_id: "launch".to_string(), + configuration: DebugLaunchConfiguration { + name: "Main".to_string(), + request: DebugRequestKind::Launch, + arguments: Map::new(), + stepping_filters: None, + }, + }) + .unwrap(); + receive_messages( + session_id, + vec![response_message(1, "initialize", json!({}))], + ); + receive_messages(session_id, vec![response_message(2, "launch", json!({}))]); + receive_messages( + session_id, + vec![json!({ + "seq": 103, + "type": "event", + "event": "stopped", + "body": {"reason": "exception", "threadId": 13} + })], + ); + + let error = inspect(InspectRequest { + session_id: session_id.to_string(), + operation_id: "exception-main".to_string(), + kind: DebugInspectKind::ExceptionInfo, + thread_id: Some(13), + frame_id: None, + variables_reference: None, + expression: None, + source_path: None, + line: None, + column: None, + }) + .unwrap_err(); + assert!(matches!(error.code, ErrorCode::InvalidRequest)); + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); + } + #[test] fn unknown_server_request_gets_an_explicit_failure_response() { let session_id = "debug-server-request"; diff --git a/rust/lithe-core/src/debug/types.rs b/rust/lithe-core/src/debug/types.rs index 70c3d58d..130534ef 100644 --- a/rust/lithe-core/src/debug/types.rs +++ b/rust/lithe-core/src/debug/types.rs @@ -357,6 +357,7 @@ pub enum DebugInspectKind { Scopes, Variables, Evaluate, + ExceptionInfo, StepInTargets, GotoTargets, } @@ -369,6 +370,7 @@ impl DebugInspectKind { Self::Scopes => "scopes", Self::Variables => "variables", Self::Evaluate => "evaluate", + Self::ExceptionInfo => "exceptionInfo", Self::StepInTargets => "stepInTargets", Self::GotoTargets => "gotoTargets", } @@ -435,6 +437,7 @@ pub struct DebugCapabilities { pub supports_restart_request: bool, pub supports_terminate_request: bool, pub supports_step_back: bool, + pub supports_exception_info_request: bool, pub supports_step_in_targets_request: bool, pub supports_goto_targets_request: bool, pub exception_breakpoint_filters: Vec, @@ -541,6 +544,9 @@ pub enum DebugOperationResult { Evaluate { variable: DebugVariable, }, + ExceptionInfo { + exception_info: DebugExceptionInfo, + }, SetVariable { variable: DebugVariable, }, @@ -639,3 +645,25 @@ pub struct DebugVariable { pub evaluate_name: Option, pub variables_reference: i64, } + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Exception metadata returned for the thread that caused an exception pause. +pub struct DebugExceptionInfo { + pub exception_id: String, + pub description: Option, + pub break_mode: String, + pub details: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Optional adapter-provided exception detail tree. +pub struct DebugExceptionDetails { + pub message: Option, + pub type_name: Option, + pub full_type_name: Option, + pub evaluate_name: Option, + pub stack_trace: Option, + pub inner_exceptions: Vec, +} diff --git a/shared/contracts/application-boundary.md b/shared/contracts/application-boundary.md index 2c454bc4..c2f6a0f4 100644 --- a/shared/contracts/application-boundary.md +++ b/shared/contracts/application-boundary.md @@ -186,6 +186,13 @@ persistence and decide whether matching consecutive frames are collapsed or expanded in their native call-stack UI. No Debug session, adapter process, or background task is created merely because stepping preferences exist. +Exception pause metadata is portable when the adapter advertises the standard +exception-information request. Rust Core normalizes the exception type, +description, break mode, stack trace, evaluation name, and nested details; +native products decide how that data is presented beside the current frame's +ordinary scopes and variables. An adapter that supplies no object reference +does not make the exception itself expandable through this contract. + For JDT LS, the standard initialize handshake and project-import readiness use separate Core-owned deadlines. Project import fails only after 45 seconds without changed progress or the 10-minute absolute safety cap; platform clients diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index cbe9e395..b2234678 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -458,21 +458,28 @@ flows are rejected unless the adapter advertised the matching capability. The successful DAP initialize response emits a normalized `capabilities` event. It includes conditional, hit-count, log, function, data, and exception breakpoint support; variable mutation; restart and terminate requests; step -back; request cancellation; single-thread execution; step-in targets; goto -targets; and ordered exception filters. Native UIs +back; exception information; request cancellation; single-thread execution; +step-in targets; goto targets; and ordered exception filters. Native UIs must treat capability state as unknown until this event arrives and hide or disable unsupported actions after negotiation. `debug.execute` correlates continue, pause, next, step-in, and step-out to the caller's `operationId`. `debug.inspect` supports `threads`, `stackTrace`, -`scopes`, `variables`, and `evaluate`; required thread, frame, variable -reference, and expression fields are validated before a request is emitted. +`scopes`, `variables`, `evaluate`, and capability-gated `exceptionInfo`; +required thread, frame, variable reference, and expression fields are validated +before a request is emitted. Exception information is available only while +paused and normalizes the exception type, description, break mode, optional +stack trace, evaluation name, and nested exception details. The Java adapter +currently supplies the type, description, and break mode but no expandable +exception object reference, so native clients continue to inspect ordinary +frame scopes for local state. Terminal operation events are exactly one of `operationCompleted` with a typed result or `operationFailed` with the adapter command and safe message. Other ordered events are `stateChanged`, `initialized`, `output`, `stopped`, `continued`, `terminated`, and `breakpoint`. Source coordinates are one-based. The compatibility flow is captured in -`shared/fixtures/debug/dap-session-v1.json`. +`shared/fixtures/debug/dap-session-v1.json`; exception normalization cases are +captured in `shared/fixtures/debug/exception-info-v1.json`. `debug.disconnect` emits the protocol handshake and enters `terminating`; the platform keeps the socket or process alive long enough to flush the frame, diff --git a/shared/fixtures/debug/dap-session-v1.json b/shared/fixtures/debug/dap-session-v1.json index 1cf6a5bf..89278e05 100644 --- a/shared/fixtures/debug/dap-session-v1.json +++ b/shared/fixtures/debug/dap-session-v1.json @@ -100,6 +100,7 @@ "supportsCancelRequest": true, "supportsSingleThreadExecutionRequests": true, "supportsRestartRequest": true, + "supportsExceptionInfoRequest": true, "supportsExceptionFilterOptions": true, "exceptionBreakpointFilters": [ { diff --git a/shared/fixtures/debug/exception-info-v1.json b/shared/fixtures/debug/exception-info-v1.json new file mode 100644 index 00000000..c7810523 --- /dev/null +++ b/shared/fixtures/debug/exception-info-v1.json @@ -0,0 +1,51 @@ +{ + "version": 1, + "request": { + "operationId": "exception-main", + "threadId": 13 + }, + "adapterResponse": { + "exceptionId": "java.lang.IllegalStateException", + "description": "java.lang.IllegalStateException: session expired", + "breakMode": "always", + "details": { + "message": "session expired", + "typeName": "IllegalStateException", + "fullTypeName": "java.lang.IllegalStateException", + "evaluateName": "exception", + "stackTrace": "java.lang.IllegalStateException: session expired\n\tat example.LoginService.login(LoginService.java:42)", + "innerException": [ + { + "message": "token expired", + "typeName": "TokenExpiredException", + "fullTypeName": "example.TokenExpiredException" + } + ] + } + }, + "expected": { + "kind": "exceptionInfo", + "exceptionInfo": { + "exceptionId": "java.lang.IllegalStateException", + "description": "java.lang.IllegalStateException: session expired", + "breakMode": "always", + "details": { + "message": "session expired", + "typeName": "IllegalStateException", + "fullTypeName": "java.lang.IllegalStateException", + "evaluateName": "exception", + "stackTrace": "java.lang.IllegalStateException: session expired\n\tat example.LoginService.login(LoginService.java:42)", + "innerExceptions": [ + { + "message": "token expired", + "typeName": "TokenExpiredException", + "fullTypeName": "example.TokenExpiredException", + "evaluateName": null, + "stackTrace": null, + "innerExceptions": [] + } + ] + } + } + } +} From 419c244bea0e7aa78272f7814e0d15be5894a291 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sun, 30 Aug 2026 04:28:49 +0800 Subject: [PATCH 35/66] feat(debug): page large variable trees --- .../Core/Rust/RustDebugProtocolCore.swift | 10 + .../Lithe/Views/Debug/GenericDebugView.swift | 108 ++++-- .../Debug/DebugAdapterContracts.swift | 41 ++- .../Debug/DebugProtocolCore.swift | 35 ++ .../GenericDebugFeatureModel.swift | 310 ++++++++++++++-- .../CoreDebugAdapterProtocolSession.swift | 44 ++- .../Runtime/DebugAdapterProtocolSession.swift | 52 ++- .../DebugModuleTests.swift | 340 +++++++++++++++++- rust/lithe-core/src/debug/engine.rs | 234 +++++++++++- rust/lithe-core/src/debug/types.rs | 33 ++ shared/contracts/application-boundary.md | 10 + shared/contracts/rust-core-api.md | 11 +- shared/fixtures/debug/variable-paging-v1.json | 55 +++ 13 files changed, 1199 insertions(+), 84 deletions(-) create mode 100644 shared/fixtures/debug/variable-paging-v1.json diff --git a/macos/Sources/Lithe/Core/Rust/RustDebugProtocolCore.swift b/macos/Sources/Lithe/Core/Rust/RustDebugProtocolCore.swift index 89a04406..277e4ea9 100644 --- a/macos/Sources/Lithe/Core/Rust/RustDebugProtocolCore.swift +++ b/macos/Sources/Lithe/Core/Rust/RustDebugProtocolCore.swift @@ -182,6 +182,9 @@ extension RustCoreBridge: DebugProtocolCore { threadID: Int?, frameID: Int?, variablesReference: Int?, + variableFilter: DebugVariableFilter?, + start: Int?, + count: Int?, expression: String?, sourcePath: String?, line: Int?, @@ -196,6 +199,9 @@ extension RustCoreBridge: DebugProtocolCore { threadID: threadID, frameID: frameID, variablesReference: variablesReference, + variableFilter: variableFilter, + start: start, + count: count, expression: expression, sourcePath: sourcePath, line: line, @@ -381,6 +387,9 @@ private struct DebugInspectPayload: Encodable { let threadID: Int? let frameID: Int? let variablesReference: Int? + let variableFilter: DebugVariableFilter? + let start: Int? + let count: Int? let expression: String? let sourcePath: String? let line: Int? @@ -393,6 +402,7 @@ private struct DebugInspectPayload: Encodable { case threadID = "threadId" case frameID = "frameId" case variablesReference + case variableFilter, start, count case expression case sourcePath, line, column } diff --git a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift index de716d92..65b5d528 100644 --- a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift +++ b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift @@ -436,43 +436,52 @@ struct GenericDebugView: View { divider } sectionHeader("Variables", count: feature.variables.count) - if feature.variables.isEmpty { + if feature.visibleVariableRows.isEmpty { placeholder("Select a stack frame to inspect variables") } else { ForEach(feature.visibleVariableRows) { row in - let variable = row.variable - HStack(alignment: .firstTextBaseline, spacing: 6) { - Image(systemName: variableSymbol(variable)) - .font(.system(size: variable.isExpandable ? 8 : 4)) - .foregroundStyle(LitheTheme.secondaryText) - Text(variable.name) - .font(.system(size: 10.5, design: .monospaced)) - Text("=") - .foregroundStyle(LitheTheme.secondaryText) - Text(variable.value) - .font(.system(size: 10.5, design: .monospaced)) - .foregroundStyle(LitheTheme.accent) - .lineLimit(2) - Spacer(minLength: 0) - } - .contentShape(Rectangle()) - .onTapGesture { - feature.toggleVariableExpansion(variable) - } - .padding(.leading, 10 + CGFloat(row.depth * 14)) - .padding(.trailing, 10) - .padding(.vertical, 5) - .contextMenu { - if feature.capabilities.supportsSetVariable, - variable.containerReference != nil { - Button("Set Value…") { editingVariable = variable } + switch row.content { + case .variable(let variable): + HStack(alignment: .firstTextBaseline, spacing: 6) { + Image(systemName: variableSymbol(variable)) + .font(.system(size: variable.isExpandable ? 8 : 4)) + .foregroundStyle(LitheTheme.secondaryText) + Text(variable.name) + .font(.system(size: 10.5, design: .monospaced)) + Text("=") + .foregroundStyle(LitheTheme.secondaryText) + Text(variable.value) + .font(.system(size: 10.5, design: .monospaced)) + .foregroundStyle(LitheTheme.accent) + .lineLimit(2) + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + .onTapGesture { + feature.toggleVariableExpansion(variable) } - if feature.capabilities.supportsDataBreakpoints, - variable.containerReference != nil { - Button("Break on Field Access…") { - feature.requestDataBreakpoint(for: variable) + .padding(.leading, 10 + CGFloat(row.depth * 14)) + .padding(.trailing, 10) + .padding(.vertical, 5) + .contextMenu { + if feature.capabilities.supportsSetVariable, + variable.containerReference != nil { + Button("Set Value…") { editingVariable = variable } + } + if feature.capabilities.supportsDataBreakpoints, + variable.containerReference != nil { + Button("Break on Field Access…") { + feature.requestDataBreakpoint(for: variable) + } } } + case .loadMore(let parentVariableID, let nextCount, let remainingCount): + variableLoadMoreRow( + parentVariableID: parentVariableID, + nextCount: nextCount, + remainingCount: remainingCount, + depth: row.depth + ) } } } @@ -1087,6 +1096,43 @@ struct GenericDebugView: View { return feature.isVariableExpanded(variable) ? "chevron.down" : "chevron.right" } + private func variableLoadMoreRow( + parentVariableID: String?, + nextCount: Int, + remainingCount: Int?, + depth: Int + ) -> some View { + let isLoading = feature.isVariablePageLoading(parentVariableID: parentVariableID) + return Button { + feature.loadMoreVariables(parentVariableID: parentVariableID) + } label: { + HStack(spacing: 6) { + if isLoading { + ProgressView().controlSize(.mini) + } else { + Image(systemName: "ellipsis.circle") + .font(.system(size: 9)) + } + Text(isLoading ? "Loading…" : "Load \(nextCount) more") + .font(LitheTheme.smallFont) + if let remainingCount, !isLoading { + Text("\(remainingCount) remaining") + .font(.system(size: 9.5, design: .monospaced)) + .foregroundStyle(LitheTheme.secondaryText) + } + Spacer(minLength: 0) + } + .foregroundStyle(LitheTheme.secondaryText) + .padding(.leading, 10 + CGFloat(depth * 14)) + .padding(.trailing, 10) + .frame(minHeight: 27) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(isLoading) + .accessibilityLabel(isLoading ? "Loading debugger variables" : "Load more debugger variables") + } + private func breakpointColor(_ breakpoint: GenericDebugBreakpoint) -> Color { guard breakpoint.enabled, !feature.areBreakpointsMuted else { return LitheTheme.secondaryText diff --git a/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift b/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift index 994d8789..2e23e590 100644 --- a/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift +++ b/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift @@ -481,15 +481,30 @@ public struct DebugScope: Identifiable, Equatable, Sendable { public let name: String public let variablesReference: Int public let expensive: Bool + public let namedVariables: Int + public let indexedVariables: Int - public init(id: Int, name: String, variablesReference: Int, expensive: Bool) { + public init( + id: Int, + name: String, + variablesReference: Int, + expensive: Bool, + namedVariables: Int = 0, + indexedVariables: Int = 0 + ) { self.id = id self.name = name self.variablesReference = variablesReference self.expensive = expensive + self.namedVariables = max(0, namedVariables) + self.indexedVariables = max(0, indexedVariables) } } +public enum DebugVariableFilter: String, Codable, Equatable, Sendable { + case named, indexed +} + public struct DebugVariable: Identifiable, Equatable, Sendable { public let id: String public let name: String @@ -498,6 +513,8 @@ public struct DebugVariable: Identifiable, Equatable, Sendable { public let evaluateName: String? public let variablesReference: Int public let containerReference: Int? + public let namedVariables: Int + public let indexedVariables: Int public var isExpandable: Bool { variablesReference > 0 } public init( @@ -507,7 +524,9 @@ public struct DebugVariable: Identifiable, Equatable, Sendable { type: String?, evaluateName: String?, variablesReference: Int, - containerReference: Int? = nil + containerReference: Int? = nil, + namedVariables: Int = 0, + indexedVariables: Int = 0 ) { self.id = id self.name = name @@ -516,6 +535,8 @@ public struct DebugVariable: Identifiable, Equatable, Sendable { self.evaluateName = evaluateName self.variablesReference = variablesReference self.containerReference = containerReference + self.namedVariables = max(0, namedVariables) + self.indexedVariables = max(0, indexedVariables) } } @@ -576,6 +597,13 @@ public protocol DebugAdapterControllingSession: DebugAdapterSession { func requestStackTrace(threadID: Int, completion: @escaping (Result<[DebugStackFrame], Error>) -> Void) func requestScopes(frameID: Int, completion: @escaping (Result<[DebugScope], Error>) -> Void) func requestVariables(reference: Int, completion: @escaping (Result<[DebugVariable], Error>) -> Void) + func requestVariables( + reference: Int, + filter: DebugVariableFilter?, + start: Int?, + count: Int?, + completion: @escaping (Result<[DebugVariable], Error>) -> Void + ) func setVariable( variablesReference: Int, name: String, @@ -630,6 +658,15 @@ public extension DebugAdapterControllingSession { ) { completion(.failure(DebugAdapterCapabilityError.unsupported("exception information"))) } + func requestVariables( + reference: Int, + filter _: DebugVariableFilter?, + start _: Int?, + count _: Int?, + completion: @escaping (Result<[DebugVariable], Error>) -> Void + ) { + requestVariables(reference: reference, completion: completion) + } func setVariable( variablesReference _: Int, name _: String, diff --git a/macos/Sources/LitheCoreContracts/Debug/DebugProtocolCore.swift b/macos/Sources/LitheCoreContracts/Debug/DebugProtocolCore.swift index d3087403..8fe37b6b 100644 --- a/macos/Sources/LitheCoreContracts/Debug/DebugProtocolCore.swift +++ b/macos/Sources/LitheCoreContracts/Debug/DebugProtocolCore.swift @@ -177,6 +177,21 @@ public struct DebugCoreScope: Decodable, Equatable, Sendable { public let name: String public let variablesReference: Int public let expensive: Bool + public let namedVariables: Int + public let indexedVariables: Int + + private enum CodingKeys: String, CodingKey { + case name, variablesReference, expensive, namedVariables, indexedVariables + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decode(String.self, forKey: .name) + variablesReference = try container.decode(Int.self, forKey: .variablesReference) + expensive = try container.decode(Bool.self, forKey: .expensive) + namedVariables = max(0, try container.decodeIfPresent(Int.self, forKey: .namedVariables) ?? 0) + indexedVariables = max(0, try container.decodeIfPresent(Int.self, forKey: .indexedVariables) ?? 0) + } } public struct DebugCoreVariable: Decodable, Equatable, Sendable { @@ -185,6 +200,23 @@ public struct DebugCoreVariable: Decodable, Equatable, Sendable { public let type: String? public let evaluateName: String? public let variablesReference: Int + public let namedVariables: Int + public let indexedVariables: Int + + private enum CodingKeys: String, CodingKey { + case name, value, type, evaluateName, variablesReference, namedVariables, indexedVariables + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decode(String.self, forKey: .name) + value = try container.decode(String.self, forKey: .value) + type = try container.decodeIfPresent(String.self, forKey: .type) + evaluateName = try container.decodeIfPresent(String.self, forKey: .evaluateName) + variablesReference = try container.decode(Int.self, forKey: .variablesReference) + namedVariables = max(0, try container.decodeIfPresent(Int.self, forKey: .namedVariables) ?? 0) + indexedVariables = max(0, try container.decodeIfPresent(Int.self, forKey: .indexedVariables) ?? 0) + } } public struct DebugCoreExceptionInfo: Decodable, Equatable, Sendable { @@ -282,6 +314,9 @@ public protocol DebugProtocolCore: DebugSteppingFilterResolving, Sendable { threadID: Int?, frameID: Int?, variablesReference: Int?, + variableFilter: DebugVariableFilter?, + start: Int?, + count: Int?, expression: String?, sourcePath: String?, line: Int?, diff --git a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift index dfb54b64..0cc83443 100644 --- a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift +++ b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift @@ -66,10 +66,20 @@ public struct GenericDebugWatch: Identifiable, Equatable, Sendable { public var id: String { expression } } +public enum GenericDebugVariableRowContent: Equatable, Sendable { + case variable(DebugVariable) + case loadMore(parentVariableID: String?, nextCount: Int, remainingCount: Int?) +} + public struct GenericDebugVariableRow: Identifiable, Equatable, Sendable { public let id: String - public let variable: DebugVariable + public let content: GenericDebugVariableRowContent public let depth: Int + + public var variable: DebugVariable? { + guard case .variable(let variable) = content else { return nil } + return variable + } } public struct GenericDebugStackFrameRow: Identifiable, Equatable, Sendable { @@ -80,6 +90,35 @@ public struct GenericDebugStackFrameRow: Identifiable, Equatable, Sendable { public var isHiddenGroup: Bool { frame == nil } } +private struct GenericDebugVariablePageSegment: Equatable, Sendable { + let filter: DebugVariableFilter? + var nextStart: Int + let totalCount: Int? +} + +private struct GenericDebugVariablePageState: Equatable, Sendable { + let reference: Int + var segments: [GenericDebugVariablePageSegment] + var loadedPageFingerprints: Set<[GenericDebugVariablePageItemFingerprint]> + + var remainingCount: Int? { + var remaining = 0 + for segment in segments { + guard let totalCount = segment.totalCount else { return nil } + remaining += max(0, totalCount - segment.nextStart) + } + return remaining + } +} + +private struct GenericDebugVariablePageItemFingerprint: Hashable, Sendable { + let name: String + let value: String + let type: String? + let evaluateName: String? + let variablesReference: Int +} + @MainActor public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatureTarget { @Published public private(set) var providerID: String? @@ -100,6 +139,8 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu @Published public private(set) var variableChildren: [String: [DebugVariable]] = [:] @Published public private(set) var expandedVariableIDs: Set = [] @Published public private(set) var loadingVariableIDs: Set = [] + @Published private var variablePageStates: [String: GenericDebugVariablePageState] = [:] + @Published private var loadingVariablePageIDs: Set = [] @Published public private(set) var watches: [GenericDebugWatch] = [] @Published public private(set) var selectedThreadID: Int? @Published public private(set) var selectedFrameID: Int? @@ -124,8 +165,10 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu private var workspaceURL: URL? private var activeFileURL: URL? private let maximumOutputCharacters = 400_000 + private let variablePageSize = 100 private var watchGeneration = 0 private var inspectionGeneration = 0 + private static let rootVariablePageID = "__lithe_debug_root_variables__" public init( sessions: DebugAdapterSessionManager, @@ -165,6 +208,12 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu public var visibleVariableRows: [GenericDebugVariableRow] { var rows: [GenericDebugVariableRow] = [] appendVisibleVariables(variables, parentPath: "root", depth: 0, to: &rows) + appendVariableLoadMoreRow( + parentVariableID: nil, + parentPath: "root", + depth: 0, + to: &rows + ) return rows } public var visibleStackFrameRows: [GenericDebugStackFrameRow] { @@ -748,6 +797,8 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu if let scope = scopes.first(where: { !$0.expensive }) ?? scopes.first { self.loadVariables( reference: scope.variablesReference, + namedVariables: scope.namedVariables, + indexedVariables: scope.indexedVariables, frameID: frame.id, generation: generation ) @@ -762,26 +813,32 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu public func loadVariables(reference: Int) { loadVariables( reference: reference, + namedVariables: 0, + indexedVariables: 0, frameID: selectedFrameID, generation: inspectionGeneration ) } - private func loadVariables(reference: Int, frameID: Int?, generation: Int) { - guard let session = activeSession else { return } - session.requestVariables(reference: reference) { [weak self] result in - guard let self, - self.inspectionGeneration == generation, - self.selectedFrameID == frameID else { return } - switch result { - case .success(let variables): - self.variables = variables - self.variableChildren = [:] - self.expandedVariableIDs = [] - self.loadingVariableIDs = [] - case .failure(let error): self.record(error) - } - } + private func loadVariables( + reference: Int, + namedVariables: Int, + indexedVariables: Int, + frameID: Int?, + generation: Int + ) { + resetVariableTree() + variablePageStates[Self.rootVariablePageID] = makeVariablePageState( + reference: reference, + namedVariables: namedVariables, + indexedVariables: indexedVariables + ) + requestVariablePage( + parentVariableID: nil, + frameID: frameID, + generation: generation, + expandsParent: false + ) } public func toggleVariableExpansion(_ variable: DebugVariable) { @@ -794,23 +851,32 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu expandedVariableIDs.insert(variable.id) return } - guard !loadingVariableIDs.contains(variable.id), let session = activeSession else { return } + guard !loadingVariableIDs.contains(variable.id), activeSession != nil else { return } loadingVariableIDs.insert(variable.id) - let frameID = selectedFrameID - let generation = inspectionGeneration - session.requestVariables(reference: variable.variablesReference) { [weak self] result in - guard let self, - self.inspectionGeneration == generation, - self.selectedFrameID == frameID else { return } - self.loadingVariableIDs.remove(variable.id) - switch result { - case .success(let children): - self.variableChildren[variable.id] = children - self.expandedVariableIDs.insert(variable.id) - case .failure(let error): - self.record(error) - } - } + variablePageStates[variable.id] = makeVariablePageState( + reference: variable.variablesReference, + namedVariables: variable.namedVariables, + indexedVariables: variable.indexedVariables + ) + requestVariablePage( + parentVariableID: variable.id, + frameID: selectedFrameID, + generation: inspectionGeneration, + expandsParent: true + ) + } + + public func loadMoreVariables(parentVariableID: String?) { + requestVariablePage( + parentVariableID: parentVariableID, + frameID: selectedFrameID, + generation: inspectionGeneration, + expandsParent: parentVariableID != nil + ) + } + + public func isVariablePageLoading(parentVariableID: String?) -> Bool { + loadingVariablePageIDs.contains(variablePageID(parentVariableID)) } public func children(of variable: DebugVariable) -> [DebugVariable] { @@ -849,7 +915,9 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu type: replacement.type ?? variable.type, evaluateName: variable.evaluateName, variablesReference: replacement.variablesReference, - containerReference: containerReference + containerReference: containerReference, + namedVariables: replacement.namedVariables, + indexedVariables: replacement.indexedVariables ) self.replaceVariable(updated) self.refreshWatches() @@ -1383,11 +1451,155 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu dataBreakpoints.sort { ($0.label, $0.id) < ($1.label, $1.id) } } + private func makeVariablePageState( + reference: Int, + namedVariables: Int, + indexedVariables: Int + ) -> GenericDebugVariablePageState { + var segments: [GenericDebugVariablePageSegment] = [] + if namedVariables > 0 { + segments.append(GenericDebugVariablePageSegment( + filter: .named, + nextStart: 0, + totalCount: namedVariables + )) + } + if indexedVariables > 0 { + segments.append(GenericDebugVariablePageSegment( + filter: .indexed, + nextStart: 0, + totalCount: indexedVariables + )) + } + if segments.isEmpty { + segments.append(GenericDebugVariablePageSegment( + filter: nil, + nextStart: 0, + totalCount: nil + )) + } + return GenericDebugVariablePageState( + reference: reference, + segments: segments, + loadedPageFingerprints: [] + ) + } + + private func requestVariablePage( + parentVariableID: String?, + frameID: Int?, + generation: Int, + expandsParent: Bool + ) { + let pageID = variablePageID(parentVariableID) + guard !loadingVariablePageIDs.contains(pageID), + let state = variablePageStates[pageID], + let segment = state.segments.first, + let session = activeSession else { return } + let remaining = segment.totalCount.map { max(0, $0 - segment.nextStart) } + let requestedCount = min(variablePageSize, remaining ?? variablePageSize) + guard requestedCount > 0 else { return } + loadingVariablePageIDs.insert(pageID) + session.requestVariables( + reference: state.reference, + filter: segment.filter, + start: segment.nextStart, + count: requestedCount + ) { [weak self] result in + guard let self, + self.inspectionGeneration == generation, + self.selectedFrameID == frameID else { return } + self.loadingVariablePageIDs.remove(pageID) + if let parentVariableID { + self.loadingVariableIDs.remove(parentVariableID) + } + switch result { + case .success(let values): + self.mergeVariablePage( + values, + parentVariableID: parentVariableID, + requestedFilter: segment.filter, + requestedStart: segment.nextStart, + requestedCount: requestedCount + ) + if expandsParent, let parentVariableID { + self.expandedVariableIDs.insert(parentVariableID) + } + case .failure(let error): + self.record(error) + } + } + } + + private func mergeVariablePage( + _ page: [DebugVariable], + parentVariableID: String?, + requestedFilter: DebugVariableFilter?, + requestedStart: Int, + requestedCount: Int + ) { + let pageID = variablePageID(parentVariableID) + guard var state = variablePageStates[pageID], + let currentSegment = state.segments.first, + currentSegment.filter == requestedFilter, + currentSegment.nextStart == requestedStart else { return } + + let pageFingerprint = page.map { + GenericDebugVariablePageItemFingerprint( + name: $0.name, + value: $0.value, + type: $0.type, + evaluateName: $0.evaluateName, + variablesReference: $0.variablesReference + ) + } + if !page.isEmpty, !state.loadedPageFingerprints.insert(pageFingerprint).inserted { + variablePageStates.removeValue(forKey: pageID) + return + } + + let existing = parentVariableID.map { variableChildren[$0] ?? [] } ?? variables + var knownIDs = Set(existing.map(\.id)) + let additions = page.filter { knownIDs.insert($0.id).inserted } + if let parentVariableID { + variableChildren[parentVariableID] = existing + additions + } else { + variables = existing + additions + } + + if page.count > requestedCount || (!page.isEmpty && additions.isEmpty) { + state.segments = [] + } else { + var segment = state.segments.removeFirst() + segment.nextStart = requestedStart + page.count + let reachedReportedTotal = segment.totalCount.map { + segment.nextStart >= $0 + } ?? false + let shouldContinue = !page.isEmpty + && !reachedReportedTotal + && (segment.totalCount != nil || page.count == requestedCount) + if shouldContinue { + state.segments.insert(segment, at: 0) + } + } + if state.segments.isEmpty { + variablePageStates.removeValue(forKey: pageID) + } else { + variablePageStates[pageID] = state + } + } + + private func variablePageID(_ parentVariableID: String?) -> String { + parentVariableID ?? Self.rootVariablePageID + } + private func resetVariableTree() { variables = [] variableChildren = [:] expandedVariableIDs = [] loadingVariableIDs = [] + variablePageStates = [:] + loadingVariablePageIDs = [] } private func replaceVariable(_ replacement: DebugVariable) { @@ -1414,7 +1626,11 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu ) { for (index, variable) in values.enumerated() { let path = "\(parentPath)/\(index):\(variable.id)" - rows.append(GenericDebugVariableRow(id: path, variable: variable, depth: depth)) + rows.append(GenericDebugVariableRow( + id: path, + content: .variable(variable), + depth: depth + )) if expandedVariableIDs.contains(variable.id) { appendVisibleVariables( variableChildren[variable.id] ?? [], @@ -1422,10 +1638,36 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu depth: depth + 1, to: &rows ) + appendVariableLoadMoreRow( + parentVariableID: variable.id, + parentPath: path, + depth: depth + 1, + to: &rows + ) } } } + private func appendVariableLoadMoreRow( + parentVariableID: String?, + parentPath: String, + depth: Int, + to rows: inout [GenericDebugVariableRow] + ) { + let pageID = variablePageID(parentVariableID) + guard let pageState = variablePageStates[pageID], + !pageState.segments.isEmpty else { return } + rows.append(GenericDebugVariableRow( + id: "\(parentPath)/load-more", + content: .loadMore( + parentVariableID: parentVariableID, + nextCount: min(variablePageSize, pageState.remainingCount ?? variablePageSize), + remainingCount: pageState.remainingCount + ), + depth: depth + )) + } + private func normalizedOptionalText(_ value: String?) -> String? { guard let value else { return nil } let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/macos/Sources/LitheDebugModule/Runtime/CoreDebugAdapterProtocolSession.swift b/macos/Sources/LitheDebugModule/Runtime/CoreDebugAdapterProtocolSession.swift index 4d7f92e2..f85ee1bf 100644 --- a/macos/Sources/LitheDebugModule/Runtime/CoreDebugAdapterProtocolSession.swift +++ b/macos/Sources/LitheDebugModule/Runtime/CoreDebugAdapterProtocolSession.swift @@ -293,7 +293,9 @@ public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSessi id: scope.variablesReference * 1_000 + offset, name: scope.name, variablesReference: scope.variablesReference, - expensive: scope.expensive + expensive: scope.expensive, + namedVariables: scope.namedVariables, + indexedVariables: scope.indexedVariables ) }) }) @@ -304,7 +306,29 @@ public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSessi reference: Int, completion: @escaping (Result<[DebugVariable], Error>) -> Void ) { - inspect(kind: "variables", variablesReference: reference) { result in + requestVariables( + reference: reference, + filter: nil, + start: nil, + count: nil, + completion: completion + ) + } + + public func requestVariables( + reference: Int, + filter: DebugVariableFilter?, + start: Int?, + count: Int?, + completion: @escaping (Result<[DebugVariable], Error>) -> Void + ) { + inspect( + kind: "variables", + variablesReference: reference, + variableFilter: filter, + start: start, + count: count + ) { result in completion(result.flatMap { value in guard value.kind == "variables", let variables = value.variables else { return .failure(DebugAdapterProtocolError.invalidResponse("variables")) @@ -312,7 +336,11 @@ public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSessi return .success(variables.enumerated().map { offset, variable in Self.makeVariable( variable, - fallbackID: "\(reference):\(offset)", + fallbackID: [ + String(reference), + filter?.rawValue ?? "all", + String((start ?? 0) + offset) + ].joined(separator: ":"), containerReference: reference ) }) @@ -435,6 +463,9 @@ public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSessi threadID: Int? = nil, frameID: Int? = nil, variablesReference: Int? = nil, + variableFilter: DebugVariableFilter? = nil, + start: Int? = nil, + count: Int? = nil, expression: String? = nil, sourcePath: String? = nil, line: Int? = nil, @@ -455,6 +486,9 @@ public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSessi threadID: threadID, frameID: frameID, variablesReference: variablesReference, + variableFilter: variableFilter, + start: start, + count: count, expression: expression, sourcePath: sourcePath, line: line, @@ -668,7 +702,9 @@ public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSessi type: variable.type, evaluateName: variable.evaluateName, variablesReference: variable.variablesReference, - containerReference: containerReference + containerReference: containerReference, + namedVariables: variable.namedVariables, + indexedVariables: variable.indexedVariables ) } diff --git a/macos/Sources/LitheDebugModule/Runtime/DebugAdapterProtocolSession.swift b/macos/Sources/LitheDebugModule/Runtime/DebugAdapterProtocolSession.swift index 45319c49..28f5e23d 100644 --- a/macos/Sources/LitheDebugModule/Runtime/DebugAdapterProtocolSession.swift +++ b/macos/Sources/LitheDebugModule/Runtime/DebugAdapterProtocolSession.swift @@ -363,12 +363,38 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession { public func requestVariables( reference: Int, completion: @escaping (Result<[DebugVariable], Error>) -> Void + ) { + requestVariables( + reference: reference, + filter: nil, + start: nil, + count: nil, + completion: completion + ) + } + + public func requestVariables( + reference: Int, + filter: DebugVariableFilter?, + start: Int?, + count: Int?, + completion: @escaping (Result<[DebugVariable], Error>) -> Void ) { if let activeChildSession { - activeChildSession.requestVariables(reference: reference, completion: completion) + activeChildSession.requestVariables( + reference: reference, + filter: filter, + start: start, + count: count, + completion: completion + ) return } - sendRequest(command: "variables", arguments: ["variablesReference": reference]) { result in + var arguments: [String: Any] = ["variablesReference": reference] + if let filter { arguments["filter"] = filter.rawValue } + if let start { arguments["start"] = start } + if let count { arguments["count"] = count } + sendRequest(command: "variables", arguments: arguments) { result in completion(result.flatMap { response in guard let values = (response["body"] as? [String: Any])?["variables"] as? [[String: Any]] else { return .failure(DebugAdapterProtocolError.invalidResponse("variables")) @@ -376,7 +402,11 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession { return .success(values.enumerated().compactMap { index, value in Self.parseVariable( value, - fallbackID: "\(reference):\(index)", + fallbackID: [ + String(reference), + filter?.rawValue ?? "all", + String((start ?? 0) + index) + ].joined(separator: ":"), containerReference: reference ) }) @@ -420,7 +450,9 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession { type: body["type"] as? String, evaluateName: nil, variablesReference: body["variablesReference"] as? Int ?? 0, - containerReference: variablesReference + containerReference: variablesReference, + namedVariables: body["namedVariables"] as? Int ?? 0, + indexedVariables: body["indexedVariables"] as? Int ?? 0 )) }) } @@ -449,7 +481,9 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession { value: value, type: body["type"] as? String, evaluateName: expression, - variablesReference: body["variablesReference"] as? Int ?? 0 + variablesReference: body["variablesReference"] as? Int ?? 0, + namedVariables: body["namedVariables"] as? Int ?? 0, + indexedVariables: body["indexedVariables"] as? Int ?? 0 )) }) } @@ -941,7 +975,9 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession { id: value["presentationHint"] as? Int ?? reference * 1_000 + offset, name: name, variablesReference: reference, - expensive: value["expensive"] as? Bool ?? false + expensive: value["expensive"] as? Bool ?? false, + namedVariables: value["namedVariables"] as? Int ?? 0, + indexedVariables: value["indexedVariables"] as? Int ?? 0 ) } @@ -959,7 +995,9 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession { type: value["type"] as? String, evaluateName: value["evaluateName"] as? String, variablesReference: value["variablesReference"] as? Int ?? 0, - containerReference: containerReference + containerReference: containerReference, + namedVariables: value["namedVariables"] as? Int ?? 0, + indexedVariables: value["indexedVariables"] as? Int ?? 0 ) } diff --git a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift index 2340af51..0fcec21e 100644 --- a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift +++ b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift @@ -237,6 +237,60 @@ struct DebugModuleTests { #expect(transport.stopCalls == 1) } + @Test + func coreProtocolSessionForwardsVariablePagingAndChildCounts() throws { + let transport = RecordingTransport() + let core = RecordingDebugProtocolCore() + let session = CoreDebugAdapterProtocolSession( + adapterID: "java", + transport: transport, + core: core, + sessionID: "java-variable-paging", + deadlineScheduler: RecordingDebugDeadlineScheduler() + ) + try session.start(rootURL: URL(fileURLWithPath: "/tmp/java-variable-paging")) + defer { session.stop() } + + var result: Result<[DebugVariable], Error>? + session.requestVariables( + reference: 700, + filter: .indexed, + start: 100, + count: 2 + ) { result = $0 } + + #expect(core.inspectionRequests.last?.variablesReference == 700) + #expect(core.inspectionRequests.last?.variableFilter == .indexed) + #expect(core.inspectionRequests.last?.start == 100) + #expect(core.inspectionRequests.last?.count == 2) + + let operationID = try #require(core.lastInspectionOperationID) + core.enqueueReceive(sessionID: "java-variable-paging", state: "paused", events: [[ + "sequence": 2, + "type": "operationCompleted", + "operationId": operationID, + "result": [ + "kind": "variables", + "variables": [[ + "name": "[100]", + "value": "Customer@100", + "type": "example.Customer", + "evaluateName": "customers[100]", + "variablesReference": 701, + "namedVariables": 4, + "indexedVariables": 5 + ]] + ] + ]]) + transport.emitData(Data("variables-response".utf8)) + + let variable = try #require(try result?.get().first) + #expect(variable.id == "customers[100]") + #expect(variable.containerReference == 700) + #expect(variable.namedVariables == 4) + #expect(variable.indexedVariables == 5) + } + @Test func stoppedEventLoadsThreadStackScopeAndVariablesInOrder() throws { let transport = RecordingTransport() @@ -763,6 +817,198 @@ struct DebugModuleTests { #expect(feature.scopes.isEmpty) } + @Test + func largeIndexedVariableCollectionsLoadInBoundedPages() throws { + let session = DeferredInspectionDebugSession() + let feature = makeDeferredFeature( + session: session, + rootPath: "/tmp/java-large-variable-pages" + ) + defer { feature.stop() } + let frame = DebugStackFrame(id: 70, name: "main", sourceURL: nil, line: 12, column: 1) + + feature.selectFrame(frame) + session.completeScopes(at: 0, with: [DebugScope( + id: 70, + name: "Locals", + variablesReference: 700, + expensive: false, + indexedVariables: 250 + )]) + #expect(session.variablePageRequests == [RecordingDebugVariablePageRequest( + reference: 700, + filter: .indexed, + start: 0, + count: 100 + )]) + + session.completeVariables(at: 0, with: indexedVariables(0..<100)) + #expect(feature.variables.count == 100) + #expect(feature.visibleVariableRows.last?.content == .loadMore( + parentVariableID: nil, + nextCount: 100, + remainingCount: 150 + )) + + feature.loadMoreVariables(parentVariableID: nil) + #expect(session.variablePageRequests.last == RecordingDebugVariablePageRequest( + reference: 700, + filter: .indexed, + start: 100, + count: 100 + )) + session.completeVariables(at: 1, with: indexedVariables(100..<200)) + #expect(feature.visibleVariableRows.last?.content == .loadMore( + parentVariableID: nil, + nextCount: 50, + remainingCount: 50 + )) + + feature.loadMoreVariables(parentVariableID: nil) + #expect(session.variablePageRequests.last == RecordingDebugVariablePageRequest( + reference: 700, + filter: .indexed, + start: 200, + count: 50 + )) + session.completeVariables(at: 2, with: indexedVariables(200..<250)) + + #expect(feature.variables.count == 250) + #expect(feature.variables.first?.name == "[0]") + #expect(feature.variables.last?.name == "[249]") + #expect(feature.visibleVariableRows.count == 250) + } + + @Test + func namedAndIndexedVariableSegmentsLoadInProtocolOrder() throws { + let session = DeferredInspectionDebugSession() + let feature = makeDeferredFeature( + session: session, + rootPath: "/tmp/java-named-indexed-pages" + ) + defer { feature.stop() } + let frame = DebugStackFrame(id: 71, name: "main", sourceURL: nil, line: 12, column: 1) + + feature.selectFrame(frame) + session.completeScopes(at: 0, with: [DebugScope( + id: 71, + name: "Locals", + variablesReference: 710, + expensive: false, + namedVariables: 2, + indexedVariables: 3 + )]) + #expect(session.variablePageRequests.last == RecordingDebugVariablePageRequest( + reference: 710, + filter: .named, + start: 0, + count: 2 + )) + session.completeVariables(at: 0, with: [ + DebugVariable(id: "size", name: "size", value: "3", type: "int", evaluateName: "items.size", variablesReference: 0), + DebugVariable(id: "empty", name: "empty", value: "false", type: "boolean", evaluateName: "items.empty", variablesReference: 0) + ]) + #expect(feature.visibleVariableRows.last?.content == .loadMore( + parentVariableID: nil, + nextCount: 3, + remainingCount: 3 + )) + + feature.loadMoreVariables(parentVariableID: nil) + #expect(session.variablePageRequests.last == RecordingDebugVariablePageRequest( + reference: 710, + filter: .indexed, + start: 0, + count: 3 + )) + session.completeVariables(at: 1, with: indexedVariables(0..<3)) + + #expect(feature.variables.map(\.name) == ["size", "empty", "[0]", "[1]", "[2]"]) + #expect(feature.visibleVariableRows.count == 5) + } + + @Test + func repeatedVariablePageStopsWhenAdapterIgnoresStart() throws { + let session = DeferredInspectionDebugSession() + let feature = makeDeferredFeature( + session: session, + rootPath: "/tmp/java-ignored-variable-paging" + ) + defer { feature.stop() } + let frame = DebugStackFrame(id: 72, name: "main", sourceURL: nil, line: 12, column: 1) + + feature.selectFrame(frame) + session.completeScopes(at: 0, with: [DebugScope( + id: 72, + name: "Locals", + variablesReference: 720, + expensive: false, + indexedVariables: 250 + )]) + session.completeVariables( + at: 0, + with: indexedVariables(0..<100, idPrefix: "page-zero") + ) + feature.loadMoreVariables(parentVariableID: nil) + #expect(session.variablePageRequests.last?.start == 100) + + session.completeVariables( + at: 1, + with: indexedVariables(0..<100, idPrefix: "page-one") + ) + + #expect(feature.variables.count == 100) + #expect(feature.visibleVariableRows.count == 100) + feature.loadMoreVariables(parentVariableID: nil) + #expect(session.variablePageRequests.count == 2) + } + + @Test + func staleVariablePageDoesNotEnterNewStackFrame() throws { + let session = DeferredInspectionDebugSession() + let feature = makeDeferredFeature( + session: session, + rootPath: "/tmp/java-stale-variable-page" + ) + defer { feature.stop() } + let firstFrame = DebugStackFrame(id: 80, name: "first", sourceURL: nil, line: 12, column: 1) + let secondFrame = DebugStackFrame(id: 81, name: "second", sourceURL: nil, line: 20, column: 1) + + feature.selectFrame(firstFrame) + session.completeScopes(at: 0, with: [DebugScope( + id: 80, + name: "Locals", + variablesReference: 800, + expensive: false, + indexedVariables: 250 + )]) + session.completeVariables(at: 0, with: indexedVariables(0..<100)) + feature.loadMoreVariables(parentVariableID: nil) + + feature.selectFrame(secondFrame) + session.completeScopes(at: 1, with: [DebugScope( + id: 81, + name: "Locals", + variablesReference: 810, + expensive: false, + indexedVariables: 1 + )]) + let current = DebugVariable( + id: "current", + name: "current", + value: "true", + type: "boolean", + evaluateName: "current", + variablesReference: 0 + ) + session.completeVariables(at: 2, with: [current]) + session.completeVariables(at: 1, with: indexedVariables(100..<200)) + + #expect(feature.selectedFrameID == 81) + #expect(feature.variables == [current]) + #expect(feature.visibleVariableRows.map(\.variable) == [current]) + } + @Test func exceptionStopsLoadCurrentMetadataAndDiscardStaleResponses() throws { let capabilities = DebugAdapterCapabilities( @@ -1121,10 +1367,10 @@ struct DebugModuleTests { ] ]]) transport.emitData(Data("child-variables-response".utf8)) - #expect(feature.visibleVariableRows.map(\.variable.name) == ["user", "name"]) + #expect(feature.visibleVariableRows.compactMap { $0.variable?.name } == ["user", "name"]) #expect(feature.visibleVariableRows.map(\.depth) == [0, 1]) feature.toggleVariableExpansion(user) - #expect(feature.visibleVariableRows.map(\.variable.name) == ["user"]) + #expect(feature.visibleVariableRows.compactMap { $0.variable?.name } == ["user"]) #expect(feature.variables.first?.name == "user") feature.toggleBreakpointMute() @@ -1511,6 +1757,47 @@ struct DebugModuleTests { EmptyModule(id: .execution, name: "Execution") } } + + private func makeDeferredFeature( + session: DeferredInspectionDebugSession, + rootPath: String + ) -> GenericDebugFeatureModel { + let descriptor = DebugProviderDescriptor( + id: "java", + displayName: "Java", + fileExtensions: ["java"] + ) + let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in session } + let feature = GenericDebugFeatureModel(sessions: manager) + let root = URL(fileURLWithPath: rootPath, isDirectory: true) + let source = root.appendingPathComponent("src/Main.java") + precondition(feature.start( + fileURL: source, + rootURL: root, + configuration: DebugLaunchConfiguration( + name: "Main", + request: .launch, + arguments: ["mainClass": .string("example.Main")] + ) + )) + return feature + } + + private func indexedVariables( + _ range: Range, + idPrefix: String = "item" + ) -> [DebugVariable] { + range.map { index in + DebugVariable( + id: "\(idPrefix)-\(index)", + name: "[\(index)]", + value: "Item@\(index)", + type: "example.Item", + evaluateName: nil, + variablesReference: 0 + ) + } + } } @MainActor @@ -1652,6 +1939,16 @@ private struct RecordingDebugInspectionRequest: Equatable { let threadID: Int? let frameID: Int? let variablesReference: Int? + let variableFilter: DebugVariableFilter? + let start: Int? + let count: Int? +} + +private struct RecordingDebugVariablePageRequest: Equatable { + let reference: Int + let filter: DebugVariableFilter? + let start: Int? + let count: Int? } @MainActor @@ -1672,6 +1969,9 @@ private final class DeferredInspectionDebugSession: DebugAdapterControllingSessi )] = [] private var variableRequests: [( reference: Int, + filter: DebugVariableFilter?, + start: Int?, + count: Int?, completion: (Result<[DebugVariable], Error>) -> Void )] = [] private var exceptionInfoRequests: [( @@ -1682,6 +1982,16 @@ private final class DeferredInspectionDebugSession: DebugAdapterControllingSessi var stackTraceThreadIDs: [Int] { stackTraceRequests.map(\.threadID) } var scopeFrameIDs: [Int] { scopeRequests.map(\.frameID) } var variableReferences: [Int] { variableRequests.map(\.reference) } + var variablePageRequests: [RecordingDebugVariablePageRequest] { + variableRequests.map { + RecordingDebugVariablePageRequest( + reference: $0.reference, + filter: $0.filter, + start: $0.start, + count: $0.count + ) + } + } var exceptionInfoThreadIDs: [Int] { exceptionInfoRequests.map(\.threadID) } init(capabilities: DebugAdapterCapabilities = .unknown) { @@ -1732,7 +2042,23 @@ private final class DeferredInspectionDebugSession: DebugAdapterControllingSessi reference: Int, completion: @escaping (Result<[DebugVariable], Error>) -> Void ) { - variableRequests.append((reference, completion)) + requestVariables( + reference: reference, + filter: nil, + start: nil, + count: nil, + completion: completion + ) + } + + func requestVariables( + reference: Int, + filter: DebugVariableFilter?, + start: Int?, + count: Int?, + completion: @escaping (Result<[DebugVariable], Error>) -> Void + ) { + variableRequests.append((reference, filter, start, count, completion)) } func evaluate( @@ -1911,6 +2237,9 @@ private final class RecordingDebugProtocolCore: DebugProtocolCore { threadID: Int?, frameID: Int?, variablesReference: Int?, + variableFilter: DebugVariableFilter?, + start: Int?, + count: Int?, expression _: String?, sourcePath _: String?, line _: Int?, @@ -1922,7 +2251,10 @@ private final class RecordingDebugProtocolCore: DebugProtocolCore { kind: kind, threadID: threadID, frameID: frameID, - variablesReference: variablesReference + variablesReference: variablesReference, + variableFilter: variableFilter, + start: start, + count: count )) return update(sessionID: sessionID, state: "paused") } diff --git a/rust/lithe-core/src/debug/engine.rs b/rust/lithe-core/src/debug/engine.rs index 199bf4c2..4bc7d558 100644 --- a/rust/lithe-core/src/debug/engine.rs +++ b/rust/lithe-core/src/debug/engine.rs @@ -1032,6 +1032,8 @@ impl DebugSession { .get("variablesReference") .and_then(Value::as_i64) .unwrap_or(0), + named_variables: nonnegative_count_field(&body, "namedVariables"), + indexed_variables: nonnegative_count_field(&body, "indexedVariables"), }, }, }); @@ -1273,6 +1275,13 @@ impl PendingRequest { } fn inspect_arguments(request: &InspectRequest) -> Result, CoreError> { + if request.kind != DebugInspectKind::Variables + && (request.variable_filter.is_some() || request.start.is_some() || request.count.is_some()) + { + return Err(invalid_request( + "Debug variable paging is only valid for variables inspection.", + )); + } let mut arguments = Map::new(); match request.kind { DebugInspectKind::Threads => {} @@ -1296,6 +1305,21 @@ fn inspect_arguments(request: &InspectRequest) -> Result, Cor "variablesReference" )?), ); + if let Some(filter) = request.variable_filter { + arguments.insert("filter".to_string(), json!(filter.argument())); + } + if let Some(start) = request.start { + arguments.insert( + "start".to_string(), + json!(required_nonnegative(Some(start), "start")?), + ); + } + if let Some(count) = request.count { + arguments.insert( + "count".to_string(), + json!(required_positive(Some(count), "count")?), + ); + } } DebugInspectKind::Evaluate => { let expression = request.expression.as_deref().unwrap_or_default().trim(); @@ -1389,6 +1413,8 @@ fn normalize_inspection( .get("variablesReference") .and_then(Value::as_i64) .unwrap_or(0), + named_variables: nonnegative_count_field(body, "namedVariables"), + indexed_variables: nonnegative_count_field(body, "indexedVariables"), }, }), DebugInspectKind::ExceptionInfo => Ok(DebugOperationResult::ExceptionInfo { @@ -1698,6 +1724,8 @@ fn parse_scope(value: &Value) -> Option { .get("expensive") .and_then(Value::as_bool) .unwrap_or(false), + named_variables: nonnegative_count_field(value, "namedVariables"), + indexed_variables: nonnegative_count_field(value, "indexedVariables"), }) } @@ -1711,9 +1739,15 @@ fn parse_variable(value: &Value) -> Option { .get("variablesReference") .and_then(Value::as_i64) .unwrap_or(0), + named_variables: nonnegative_count_field(value, "namedVariables"), + indexed_variables: nonnegative_count_field(value, "indexedVariables"), }) } +fn nonnegative_count_field(value: &Value, key: &str) -> i64 { + value.get(key).and_then(Value::as_i64).unwrap_or(0).max(0) +} + fn parse_breakpoint( value: &Value, function_name: Option<&str>, @@ -1995,6 +2029,81 @@ mod tests { assert!(value["events"][1]["result"].get("stack_frames").is_none()); } + #[test] + fn variable_paging_arguments_reject_invalid_combinations() { + let base = InspectRequest { + session_id: "debug-variable-validation".to_string(), + operation_id: "variables".to_string(), + kind: DebugInspectKind::Variables, + thread_id: None, + frame_id: None, + variables_reference: Some(700), + variable_filter: Some(DebugVariableFilter::Indexed), + start: Some(0), + count: Some(100), + expression: None, + source_path: None, + line: None, + column: None, + }; + + let mut negative_start = base.clone(); + negative_start.start = Some(-1); + let error = inspect_arguments(&negative_start).unwrap_err(); + assert!(matches!(error.code, ErrorCode::InvalidRequest)); + + let mut zero_count = base.clone(); + zero_count.count = Some(0); + let error = inspect_arguments(&zero_count).unwrap_err(); + assert!(matches!(error.code, ErrorCode::InvalidRequest)); + + let mut non_variable_request = base; + non_variable_request.kind = DebugInspectKind::Threads; + non_variable_request.variables_reference = None; + non_variable_request.start = None; + non_variable_request.count = None; + let error = inspect_arguments(&non_variable_request).unwrap_err(); + assert!(matches!(error.code, ErrorCode::InvalidRequest)); + } + + #[test] + fn variable_child_counts_are_normalized_for_scopes_and_evaluation() { + let scopes = normalize_inspection( + DebugInspectKind::Scopes, + &json!({ + "scopes": [{ + "name": "Locals", + "variablesReference": 700, + "expensive": false, + "namedVariables": -2, + "indexedVariables": 250 + }] + }), + &DebugSteppingFilters::default(), + "/workspace", + ) + .unwrap(); + let scopes = serde_json::to_value(scopes).unwrap(); + assert_eq!(scopes["scopes"][0]["namedVariables"], 0); + assert_eq!(scopes["scopes"][0]["indexedVariables"], 250); + + let evaluation = normalize_inspection( + DebugInspectKind::Evaluate, + &json!({ + "result": "Customer[250]", + "variablesReference": 701, + "namedVariables": 4, + "indexedVariables": -1 + }), + &DebugSteppingFilters::default(), + "/workspace", + ) + .unwrap(); + let evaluation = serde_json::to_value(evaluation).unwrap(); + assert_eq!(evaluation["variable"]["namedVariables"], 4); + assert_eq!(evaluation["variable"]["indexedVariables"], 0); + } + #[test] fn java_stepping_filters_are_normalized_projected_and_mark_stack_frames() { let defaults = stepping_filters(DebugSteppingFiltersRequest { @@ -2106,6 +2215,9 @@ mod tests { thread_id: Some(11), frame_id: None, variables_reference: None, + variable_filter: None, + start: None, + count: None, expression: None, source_path: None, line: None, @@ -2426,6 +2538,9 @@ mod tests { thread_id: None, frame_id: None, variables_reference: None, + variable_filter: None, + start: None, + count: None, expression: None, source_path: None, line: None, @@ -2634,7 +2749,13 @@ mod tests { vec![response_message( 3, "setVariable", - json!({"value": "7", "type": "int", "variablesReference": 0}), + json!({ + "value": "7", + "type": "int", + "variablesReference": 0, + "namedVariables": -1, + "indexedVariables": 2 + }), )], ); assert!(completed.events.iter().any(|event| matches!( @@ -2646,6 +2767,8 @@ mod tests { && variable.name == "count" && variable.value == "7" && variable.r#type.as_deref() == Some("int") + && variable.named_variables == 0 + && variable.indexed_variables == 2 ))); destroy_session(SessionRequest { session_id: session_id.to_string(), @@ -2701,6 +2824,9 @@ mod tests { thread_id: None, frame_id: None, variables_reference: None, + variable_filter: None, + start: None, + count: None, expression: None, source_path: None, line: None, @@ -2915,6 +3041,9 @@ mod tests { thread_id: None, frame_id: Some(7), variables_reference: None, + variable_filter: None, + start: None, + count: None, expression: None, source_path: None, line: None, @@ -2978,6 +3107,9 @@ mod tests { thread_id: None, frame_id: None, variables_reference: None, + variable_filter: None, + start: None, + count: None, expression: None, source_path: Some("/workspace/src/Main.java".to_string()), line: Some(20), @@ -3084,6 +3216,9 @@ mod tests { thread_id: fixture["request"]["threadId"].as_i64(), frame_id: None, variables_reference: None, + variable_filter: None, + start: None, + count: None, expression: None, source_path: None, line: None, @@ -3124,6 +3259,100 @@ mod tests { .unwrap(); } + #[test] + fn variable_paging_is_forwarded_and_normalized_from_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/debug/variable-paging-v1.json" + ))) + .unwrap(); + let session_id = "debug-variable-paging"; + create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "java".to_string(), + root_path: "/workspace".to_string(), + }) + .unwrap(); + launch(LaunchRequest { + session_id: session_id.to_string(), + operation_id: "launch".to_string(), + configuration: DebugLaunchConfiguration { + name: "Main".to_string(), + request: DebugRequestKind::Launch, + arguments: Map::new(), + stepping_filters: None, + }, + }) + .unwrap(); + receive_messages( + session_id, + vec![response_message(1, "initialize", json!({}))], + ); + receive_messages(session_id, vec![response_message(2, "launch", json!({}))]); + receive_messages( + session_id, + vec![json!({ + "seq": 103, + "type": "event", + "event": "stopped", + "body": {"reason": "breakpoint", "threadId": 11} + })], + ); + + let inspection = inspect(InspectRequest { + session_id: session_id.to_string(), + operation_id: fixture["request"]["operationId"] + .as_str() + .unwrap() + .to_string(), + kind: DebugInspectKind::Variables, + thread_id: None, + frame_id: None, + variables_reference: fixture["request"]["variablesReference"].as_i64(), + variable_filter: serde_json::from_value(fixture["request"]["variableFilter"].clone()) + .unwrap(), + start: fixture["request"]["start"].as_i64(), + count: fixture["request"]["count"].as_i64(), + expression: None, + source_path: None, + line: None, + column: None, + }) + .unwrap(); + let request = decode_frame(&inspection.outbound_frames[0]); + assert_eq!(request["command"], "variables"); + assert_eq!(request["arguments"]["variablesReference"], 700); + assert_eq!(request["arguments"]["filter"], "indexed"); + assert_eq!(request["arguments"]["start"], 100); + assert_eq!(request["arguments"]["count"], 2); + + let completed = receive_messages( + session_id, + vec![response_message( + 3, + "variables", + fixture["adapterResponse"].clone(), + )], + ); + let result = completed.events.iter().find_map(|event| match &event.body { + DebugEventBody::OperationCompleted { + operation_id, + result, + } if operation_id == fixture["request"]["operationId"].as_str().unwrap() => { + Some(result) + } + _ => None, + }); + assert_eq!( + serde_json::to_value(result.unwrap()).unwrap(), + fixture["expected"] + ); + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); + } + #[test] fn exception_information_is_rejected_when_the_adapter_does_not_support_it() { let session_id = "debug-exception-info-unsupported"; @@ -3166,6 +3395,9 @@ mod tests { thread_id: Some(13), frame_id: None, variables_reference: None, + variable_filter: None, + start: None, + count: None, expression: None, source_path: None, line: None, diff --git a/rust/lithe-core/src/debug/types.rs b/rust/lithe-core/src/debug/types.rs index 130534ef..f441052e 100644 --- a/rust/lithe-core/src/debug/types.rs +++ b/rust/lithe-core/src/debug/types.rs @@ -377,6 +377,23 @@ impl DebugInspectKind { } } +#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +/// Selects the named or indexed child collection of a debugger variable. +pub enum DebugVariableFilter { + Named, + Indexed, +} + +impl DebugVariableFilter { + pub(crate) fn argument(self) -> &'static str { + match self { + Self::Named => "named", + Self::Indexed => "indexed", + } + } +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] /// Parameters for one thread, frame, variable, or expression inspection. @@ -391,6 +408,14 @@ pub struct InspectRequest { #[serde(default)] pub variables_reference: Option, #[serde(default)] + pub variable_filter: Option, + /// Zero-based child offset for a paged variables request. + #[serde(default)] + pub start: Option, + /// Maximum child count for a paged variables request. + #[serde(default)] + pub count: Option, + #[serde(default)] pub expression: Option, #[serde(default)] pub source_path: Option, @@ -633,6 +658,10 @@ pub struct DebugScope { pub name: String, pub variables_reference: i64, pub expensive: bool, + /// Adapter-reported count of named children, or zero when unavailable. + pub named_variables: i64, + /// Adapter-reported count of indexed children, or zero when unavailable. + pub indexed_variables: i64, } #[derive(Debug, Clone, Serialize)] @@ -644,6 +673,10 @@ pub struct DebugVariable { pub r#type: Option, pub evaluate_name: Option, pub variables_reference: i64, + /// Adapter-reported count of named children, or zero when unavailable. + pub named_variables: i64, + /// Adapter-reported count of indexed children, or zero when unavailable. + pub indexed_variables: i64, } #[derive(Debug, Clone, Serialize)] diff --git a/shared/contracts/application-boundary.md b/shared/contracts/application-boundary.md index c2f6a0f4..812a9c19 100644 --- a/shared/contracts/application-boundary.md +++ b/shared/contracts/application-boundary.md @@ -193,6 +193,16 @@ native products decide how that data is presented beside the current frame's ordinary scopes and variables. An adapter that supplies no object reference does not make the exception itself expandable through this contract. +Debugger variable paging is portable. Rust Core owns the standard DAP +`filter`, zero-based `start`, and positive `count` request projection and +normalizes adapter-reported `namedVariables` and `indexedVariables` counts to +non-negative values. Native products own tree expansion and page-size policy; +the macOS reference product loads at most 100 children per request, appends +named children before indexed children, exposes an in-tree load-more action, +and discards stale pages after the selected frame changes. A native client must +also stop offering more pages when an adapter returns more children than were +requested or repeats an already loaded page. + For JDT LS, the standard initialize handshake and project-import readiness use separate Core-owned deadlines. Project import fails only after 45 seconds without changed progress or the 10-minute absolute safety cap; platform clients diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index b2234678..16ec38bf 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -467,7 +467,16 @@ disable unsupported actions after negotiation. caller's `operationId`. `debug.inspect` supports `threads`, `stackTrace`, `scopes`, `variables`, `evaluate`, and capability-gated `exceptionInfo`; required thread, frame, variable reference, and expression fields are validated -before a request is emitted. Exception information is available only while +before a request is emitted. A `variables` inspection may additionally carry +`variableFilter` (`named` or `indexed`), zero-based `start`, and positive +`count`; Core maps them to DAP `filter`, `start`, and `count` and rejects those +fields for every other inspection kind. Normalized scopes, variables, +evaluations, and variable-mutation results include non-negative +`namedVariables` and `indexedVariables` counts, using zero when the adapter +omits or reports an invalid negative value. The compatibility cases are in +`shared/fixtures/debug/variable-paging-v1.json`. + +Exception information is available only while paused and normalizes the exception type, description, break mode, optional stack trace, evaluation name, and nested exception details. The Java adapter currently supplies the type, description, and break mode but no expandable diff --git a/shared/fixtures/debug/variable-paging-v1.json b/shared/fixtures/debug/variable-paging-v1.json new file mode 100644 index 00000000..40c643c5 --- /dev/null +++ b/shared/fixtures/debug/variable-paging-v1.json @@ -0,0 +1,55 @@ +{ + "version": 1, + "request": { + "operationId": "variables-customers-100", + "variablesReference": 700, + "variableFilter": "indexed", + "start": 100, + "count": 2 + }, + "adapterResponse": { + "variables": [ + { + "name": "[100]", + "value": "Customer@100", + "type": "example.Customer", + "evaluateName": "customers[100]", + "variablesReference": 701, + "namedVariables": 4, + "indexedVariables": 0 + }, + { + "name": "[101]", + "value": "Customer@101", + "type": "example.Customer", + "evaluateName": "customers[101]", + "variablesReference": 702, + "namedVariables": -3, + "indexedVariables": 5 + } + ] + }, + "expected": { + "kind": "variables", + "variables": [ + { + "name": "[100]", + "value": "Customer@100", + "type": "example.Customer", + "evaluateName": "customers[100]", + "variablesReference": 701, + "namedVariables": 4, + "indexedVariables": 0 + }, + { + "name": "[101]", + "value": "Customer@101", + "type": "example.Customer", + "evaluateName": "customers[101]", + "variablesReference": 702, + "namedVariables": 0, + "indexedVariables": 5 + } + ] + } +} From 17158803b4d07382e294dffc15e5efd51d481cf9 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sun, 30 Aug 2026 04:41:02 +0800 Subject: [PATCH 36/66] fix(debug): detach without terminating remote JVM --- .../Runtime/DebugAdapterProtocolSession.swift | 5 +- .../DebugModuleTests.swift | 48 ++++++++++++++ rust/lithe-core/src/debug/engine.rs | 63 ++++++++++++++++++- shared/contracts/application-boundary.md | 7 +++ shared/contracts/rust-core-api.md | 13 ++-- .../fixtures/debug/disconnect-policy-v1.json | 29 +++++++++ 6 files changed, 158 insertions(+), 7 deletions(-) create mode 100644 shared/fixtures/debug/disconnect-policy-v1.json diff --git a/macos/Sources/LitheDebugModule/Runtime/DebugAdapterProtocolSession.swift b/macos/Sources/LitheDebugModule/Runtime/DebugAdapterProtocolSession.swift index 28f5e23d..16ddd739 100644 --- a/macos/Sources/LitheDebugModule/Runtime/DebugAdapterProtocolSession.swift +++ b/macos/Sources/LitheDebugModule/Runtime/DebugAdapterProtocolSession.swift @@ -47,6 +47,7 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession { private var didReceiveInitializedEvent = false private var supportsConfigurationDone = false private var pendingLaunch: DebugLaunchConfiguration? + private var activeRequestKind: DebugRequestKind? private var childSessions: [DebugAdapterProtocolSession] = [] private weak var activeChildSession: DebugAdapterProtocolSession? @@ -137,6 +138,7 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession { if requestArguments["cwd"] == nil, let rootURL { requestArguments["cwd"] = rootURL.path } + activeRequestKind = configuration.request state = .launching sendRequest(command: configuration.request.rawValue, arguments: requestArguments) { [weak self] result in guard let self else { return } @@ -550,7 +552,7 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession { if transport.isRunning { sendRequest(command: "disconnect", arguments: [ "restart": false, - "terminateDebuggee": true + "terminateDebuggee": activeRequestKind == .launch ]) { _ in } } transport.stop() @@ -944,6 +946,7 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession { supportsConfigurationDone = false capabilities = .unknown pendingLaunch = nil + activeRequestKind = nil activeChildSession = nil childSessions = [] if !keepingState { state = .idle } diff --git a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift index 0fcec21e..1e09281e 100644 --- a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift +++ b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift @@ -1642,6 +1642,21 @@ struct DebugModuleTests { #expect(transport.stopCalls == 1) } + @Test + func protocolSessionDisconnectTerminatesOnlyLaunchedDebuggees() throws { + let launchArguments = try disconnectArguments(for: .launch) + #expect(launchArguments["restart"] as? Bool == false) + #expect(launchArguments["terminateDebuggee"] as? Bool == true) + + let attachArguments = try disconnectArguments(for: .attach) + #expect(attachArguments["restart"] as? Bool == false) + #expect(attachArguments["terminateDebuggee"] as? Bool == false) + + let unstartedArguments = try disconnectArguments(for: nil) + #expect(unstartedArguments["restart"] as? Bool == false) + #expect(unstartedArguments["terminateDebuggee"] as? Bool == false) + } + @Test func protocolSessionCreatesAndStopsChildTransport() throws { let parent = RecordingTransport() @@ -1798,6 +1813,39 @@ struct DebugModuleTests { ) } } + + private func disconnectArguments( + for requestKind: DebugRequestKind? + ) throws -> [String: Any] { + let transport = RecordingTransport() + let session = DebugAdapterProtocolSession( + adapterID: "test-adapter", + transport: transport + ) + try session.start(rootURL: URL(fileURLWithPath: "/tmp/debug-disconnect")) + defer { + if session.isRunning { session.stop() } + } + let initialize = try #require(transport.request(named: "initialize")) + transport.emitJSON([ + "seq": 2, + "type": "response", + "request_seq": initialize["seq"] as! Int, + "success": true, + "command": "initialize", + "body": [:] + ]) + if let requestKind { + try session.launch(DebugLaunchConfiguration( + name: "Disconnect Policy", + request: requestKind, + arguments: [:] + )) + } + session.stop() + let disconnect = try #require(transport.request(named: "disconnect")) + return try #require(disconnect["arguments"] as? [String: Any]) + } } @MainActor diff --git a/rust/lithe-core/src/debug/engine.rs b/rust/lithe-core/src/debug/engine.rs index 4bc7d558..bf469a3c 100644 --- a/rust/lithe-core/src/debug/engine.rs +++ b/rust/lithe-core/src/debug/engine.rs @@ -30,6 +30,7 @@ struct DebugSession { supports_configuration_done: bool, capabilities: DebugCapabilities, stepping_filters: DebugSteppingFilters, + debug_request_kind: Option, pending_launch: Option<(String, DebugLaunchConfiguration)>, outbound_frames: Vec>, events: Vec, @@ -104,6 +105,7 @@ pub(crate) fn create_session( supports_configuration_done: false, capabilities: DebugCapabilities::default(), stepping_filters: DebugSteppingFilters::unfiltered(), + debug_request_kind: None, pending_launch: None, outbound_frames: Vec::new(), events: Vec::new(), @@ -678,9 +680,12 @@ pub(crate) fn disconnect(request: SessionRequest) -> Result Result<(), CoreError> { + let request_kind = configuration.request; let mut arguments = configuration.arguments; let filters = normalize_stepping_filters( configuration @@ -719,9 +725,10 @@ impl DebugSession { arguments .entry("cwd".to_string()) .or_insert(Value::String(self.root_path.clone())); + self.debug_request_kind = Some(request_kind); self.transition(DebugSessionState::Launching); self.send_request( - configuration.request.command(), + request_kind.command(), Value::Object(arguments), PendingRequest::Launch { operation_id }, ) @@ -2029,6 +2036,58 @@ mod tests { assert!(value["events"][1]["result"].get("stack_frames").is_none()); } + #[test] + fn disconnect_policy_matches_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/debug/disconnect-policy-v1.json" + ))) + .unwrap(); + + for case in fixture["cases"].as_array().unwrap() { + let request_name = case["request"].as_str().unwrap_or("unstarted"); + let session_id = format!("debug-disconnect-{request_name}"); + create_session(CreateSessionRequest { + session_id: session_id.clone(), + adapter_id: "java".to_string(), + root_path: "/workspace".to_string(), + }) + .unwrap(); + + if case["request"].is_string() { + let request_kind = serde_json::from_value(case["request"].clone()).unwrap(); + launch(LaunchRequest { + session_id: session_id.clone(), + operation_id: format!("{request_name}-main"), + configuration: DebugLaunchConfiguration { + name: "Main".to_string(), + request: request_kind, + arguments: Map::new(), + stepping_filters: None, + }, + }) + .unwrap(); + let initialized = receive_messages( + &session_id, + vec![response_message(1, "initialize", json!({}))], + ); + assert_eq!( + decode_frame(&initialized.outbound_frames[0])["command"], + request_name + ); + } + + let disconnected = disconnect(SessionRequest { + session_id: session_id.clone(), + }) + .unwrap(); + let request = decode_frame(&disconnected.outbound_frames[0]); + assert_eq!(request["command"], "disconnect"); + assert_eq!(request["arguments"], case["expectedArguments"]); + destroy_session(SessionRequest { session_id }).unwrap(); + } + } + #[test] fn variable_paging_arguments_reject_invalid_combinations() { let base = InspectRequest { diff --git a/shared/contracts/application-boundary.md b/shared/contracts/application-boundary.md index 812a9c19..7c8b0938 100644 --- a/shared/contracts/application-boundary.md +++ b/shared/contracts/application-boundary.md @@ -203,6 +203,13 @@ and discards stale pages after the selected frame changes. A native client must also stop offering more pages when an adapter returns more children than were requested or repeats an already loaded page. +Debugger disconnect ownership is portable. A session started with `launch` +owns its local debuggee and sends `terminateDebuggee: true` when stopping. A +session started with `attach` does not own the remote JVM and sends +`terminateDebuggee: false`; closing the native transport must therefore detach +without killing the remote process. A session stopped before launch or attach +also uses the non-terminating policy. + For JDT LS, the standard initialize handshake and project-import readiness use separate Core-owned deadlines. Project import fails only after 45 seconds without changed progress or the 10-minute absolute safety cap; platform clients diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 16ec38bf..46792a64 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -490,10 +490,15 @@ The compatibility flow is captured in `shared/fixtures/debug/dap-session-v1.json`; exception normalization cases are captured in `shared/fixtures/debug/exception-info-v1.json`. -`debug.disconnect` emits the protocol handshake and enters `terminating`; the -platform keeps the socket or process alive long enough to flush the frame, -then closes it and calls `debug.destroySession`. A session allocates no process, -socket, timer, or background task, and no session exists until Debug is used. +`debug.disconnect` emits the protocol handshake and enters `terminating`. +Core derives DAP `terminateDebuggee` from the session's request kind: `launch` +uses `true`, while `attach` and a session stopped before either request use +`false`. This prevents a remote detach from killing a JVM the IDE does not own. +The compatibility cases are in +`shared/fixtures/debug/disconnect-policy-v1.json`. The platform keeps the +socket or process alive long enough to flush the frame, then closes it and +calls `debug.destroySession`. A session allocates no process, socket, timer, or +background task, and no session exists until Debug is used. `lsp.builtinCompletions`, `lsp.builtinHover`, and `lsp.builtinNavigation` are the no-process lightweight language path. They accept current-file text, an diff --git a/shared/fixtures/debug/disconnect-policy-v1.json b/shared/fixtures/debug/disconnect-policy-v1.json new file mode 100644 index 00000000..386652db --- /dev/null +++ b/shared/fixtures/debug/disconnect-policy-v1.json @@ -0,0 +1,29 @@ +{ + "version": 1, + "cases": [ + { + "name": "local launch owns the debuggee", + "request": "launch", + "expectedArguments": { + "restart": false, + "terminateDebuggee": true + } + }, + { + "name": "remote attach does not own the debuggee", + "request": "attach", + "expectedArguments": { + "restart": false, + "terminateDebuggee": false + } + }, + { + "name": "unstarted session has no debuggee ownership", + "request": null, + "expectedArguments": { + "restart": false, + "terminateDebuggee": false + } + } + ] +} From 368584afc612a5d26276679d5a6d41ede00650c0 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sun, 30 Aug 2026 05:17:11 +0800 Subject: [PATCH 37/66] feat(debug): prepare Java test runtime bundles --- .../Lithe/Core/Rust/RustCoreBridge.swift | 4 +- .../MacJDTLSLaunchResourceResolver.swift | 28 ++++++-- .../LanguageServerRuntimeContracts.swift | 18 ++++- .../JavaLanguageServerRuntimeTests.swift | 19 +++-- .../RunConfigurationIntegrationTests.swift | 8 ++- rust/lithe-core/src/lsp/interface/engine.rs | 71 +++++++++++++++++-- rust/lithe-core/src/lsp/languages/jdt.rs | 30 +++++--- scripts/prepare-jdtls.sh | 48 +++++++++++++ scripts/verify-macos-package.sh | 37 +++++++++- shared/contracts/application-boundary.md | 14 ++-- shared/contracts/rust-core-api.md | 17 +++-- shared/fixtures/lsp/jdt-direct-launch-v1.json | 6 +- third_party/jdtls/manifest.json | 7 ++ 13 files changed, 261 insertions(+), 46 deletions(-) diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index eafeefea..bcc79109 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -1426,6 +1426,7 @@ struct RustCoreBridge: Sendable { let configurationDirectory: String let lombokAgentPath: String let javaDebugBundlePath: String? + let javaExtensionBundlePaths: [String] } private struct LspSessionIdentifierRequest: Encodable { @@ -2973,7 +2974,8 @@ struct RustCoreBridge: Sendable { launcherJarPath: $0.launcherJarURL.path, configurationDirectory: $0.configurationDirectoryURL.path, lombokAgentPath: $0.lombokAgentURL.path, - javaDebugBundlePath: $0.javaDebugBundleURL?.path + javaDebugBundlePath: $0.javaDebugBundleURL?.path, + javaExtensionBundlePaths: $0.javaExtensionBundleURLs.map(\.path) ) }, cacheDirectory: cacheDirectoryURL?.standardizedFileURL.path, diff --git a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacJDTLSLaunchResourceResolver.swift b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacJDTLSLaunchResourceResolver.swift index 13f090bf..68ed0d13 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacJDTLSLaunchResourceResolver.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacJDTLSLaunchResourceResolver.swift @@ -10,6 +10,7 @@ enum MacJDTLSLaunchResourceResolution { struct MacJDTLSLaunchResourceResolver { private static let equinoxLauncherPrefix = "org.eclipse.equinox.launcher_" private static let javaDebugBundlePrefix = "com.microsoft.java.debug.plugin-" + private static let javaTestBundlePrefix = "com.microsoft.java.test.plugin-" private let bundledJdtlsRootURL: URL? private let fileManager: FileManager @@ -43,9 +44,15 @@ struct MacJDTLSLaunchResourceResolver { let javaDebugURL = try firstJavaDebugBundle( in: rootURL.appendingPathComponent("java-debug", isDirectory: true) ) + let javaTestBundleURLs = try javaTestExtensionBundles( + in: rootURL.appendingPathComponent("java-test/extensions", isDirectory: true) + ) guard let launcherURL = try firstEquinoxLauncher(in: pluginsURL), let configurationURL, let javaDebugURL, + javaTestBundleURLs.contains(where: { + $0.lastPathComponent.hasPrefix(Self.javaTestBundlePrefix) + }), fileManager.fileExists(atPath: lombokURL.path) else { continue } @@ -53,7 +60,8 @@ struct MacJDTLSLaunchResourceResolver { launcherJarURL: launcherURL, configurationDirectoryURL: configurationURL, lombokAgentURL: lombokURL, - javaDebugBundleURL: javaDebugURL + javaDebugBundleURL: javaDebugURL, + javaExtensionBundleURLs: javaTestBundleURLs ) } throw ResolutionError.incompleteInstallation @@ -108,6 +116,18 @@ struct MacJDTLSLaunchResourceResolver { prefix: String, suffix: String ) throws -> URL? { + try regularFiles(in: directoryURL, prefix: prefix, suffix: suffix).first + } + + private func javaTestExtensionBundles(in directoryURL: URL) throws -> [URL] { + try regularFiles(in: directoryURL, prefix: "", suffix: ".jar") + } + + private func regularFiles( + in directoryURL: URL, + prefix: String, + suffix: String + ) throws -> [URL] { let entries: [URL] do { entries = try fileManager.contentsOfDirectory( @@ -116,7 +136,7 @@ struct MacJDTLSLaunchResourceResolver { options: [.skipsHiddenFiles] ) } catch let error as CocoaError where error.code == .fileReadNoSuchFile { - return nil + return [] } return try entries .filter { url in @@ -125,7 +145,6 @@ struct MacJDTLSLaunchResourceResolver { return try url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile == true } .sorted { $0.lastPathComponent < $1.lastPathComponent } - .first } private func isDirectory(_ url: URL) -> Bool { @@ -145,7 +164,8 @@ struct MacJDTLSLaunchResourceResolver { var errorDescription: String? { "Expected an Equinox launcher JAR, a macOS configuration directory, " - + "lombok/lombok.jar, and the Java Debug Server bundle in the selected JDTLS installation." + + "lombok/lombok.jar, and the Java Debug and Java Test extension bundles " + + "in the selected JDTLS installation." } } } diff --git a/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift b/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift index d2012b55..594c08d2 100644 --- a/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift +++ b/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift @@ -34,18 +34,30 @@ package struct JDTLSLaunchResources: Equatable, Sendable { package let launcherJarURL: URL package let configurationDirectoryURL: URL package let lombokAgentURL: URL - package let javaDebugBundleURL: URL? + /// Ordered OSGi bundles contributed by Java tooling extensions. The Java + /// Debug Server remains first for compatibility with older Rust cores. + package let javaExtensionBundleURLs: [URL] package init( launcherJarURL: URL, configurationDirectoryURL: URL, lombokAgentURL: URL, - javaDebugBundleURL: URL? = nil + javaDebugBundleURL: URL? = nil, + javaExtensionBundleURLs: [URL] = [] ) { self.launcherJarURL = launcherJarURL.standardizedFileURL self.configurationDirectoryURL = configurationDirectoryURL.standardizedFileURL self.lombokAgentURL = lombokAgentURL.standardizedFileURL - self.javaDebugBundleURL = javaDebugBundleURL?.standardizedFileURL + var seen = Set() + self.javaExtensionBundleURLs = ([javaDebugBundleURL].compactMap { $0 } + javaExtensionBundleURLs) + .map(\.standardizedFileURL) + .filter { seen.insert($0.path).inserted } + } + + package var javaDebugBundleURL: URL? { + javaExtensionBundleURLs.first { + $0.lastPathComponent.hasPrefix("com.microsoft.java.debug.plugin-") + } } } diff --git a/macos/Tests/LitheTests/JavaLanguageServerRuntimeTests.swift b/macos/Tests/LitheTests/JavaLanguageServerRuntimeTests.swift index f250a99f..67e86d60 100644 --- a/macos/Tests/LitheTests/JavaLanguageServerRuntimeTests.swift +++ b/macos/Tests/LitheTests/JavaLanguageServerRuntimeTests.swift @@ -94,7 +94,10 @@ struct JavaLanguageServerRuntimeTests { let root = fileManager.temporaryDirectory .appendingPathComponent("lithe-jdtls-resolver-\(UUID().uuidString)", isDirectory: true) defer { try? fileManager.removeItem(at: root) } - for directory in ["bin", "plugins", "config_mac", "config_mac_arm", "lombok", "java-debug"] { + for directory in [ + "bin", "plugins", "config_mac", "config_mac_arm", "lombok", "java-debug", + "java-test/extensions" + ] { try fileManager.createDirectory( at: root.appendingPathComponent(directory, isDirectory: true), withIntermediateDirectories: true @@ -109,7 +112,9 @@ struct JavaLanguageServerRuntimeTests { firstLauncher, root.appendingPathComponent("plugins/org.eclipse.equinox.launcher_2.0.0.jar"), root.appendingPathComponent("lombok/lombok.jar"), - root.appendingPathComponent("java-debug/com.microsoft.java.debug.plugin-0.53.1.jar") + root.appendingPathComponent("java-debug/com.microsoft.java.debug.plugin-0.53.1.jar"), + root.appendingPathComponent("java-test/extensions/org.opentest4j_1.2.0.jar"), + root.appendingPathComponent("java-test/extensions/com.microsoft.java.test.plugin-0.42.0.jar") ] { try Data().write(to: file) } @@ -130,6 +135,11 @@ struct JavaLanguageServerRuntimeTests { resources.javaDebugBundleURL?.lastPathComponent == "com.microsoft.java.debug.plugin-0.53.1.jar" ) + #expect(resources.javaExtensionBundleURLs.map(\.lastPathComponent) == [ + "com.microsoft.java.debug.plugin-0.53.1.jar", + "com.microsoft.java.test.plugin-0.42.0.jar", + "org.opentest4j_1.2.0.jar" + ]) } @Test @@ -158,7 +168,7 @@ struct JavaLanguageServerRuntimeTests { let root = fileManager.temporaryDirectory .appendingPathComponent("lithe-jdtls-architecture-\(UUID().uuidString)", isDirectory: true) defer { try? fileManager.removeItem(at: root) } - for directory in ["bin", "plugins", "lombok", "java-debug"] { + for directory in ["bin", "plugins", "lombok", "java-debug", "java-test/extensions"] { try fileManager.createDirectory( at: root.appendingPathComponent(directory, isDirectory: true), withIntermediateDirectories: true @@ -180,7 +190,8 @@ struct JavaLanguageServerRuntimeTests { root.appendingPathComponent("bin/jdtls"), root.appendingPathComponent("plugins/org.eclipse.equinox.launcher_1.0.0.jar"), root.appendingPathComponent("lombok/lombok.jar"), - root.appendingPathComponent("java-debug/com.microsoft.java.debug.plugin-0.53.1.jar") + root.appendingPathComponent("java-debug/com.microsoft.java.debug.plugin-0.53.1.jar"), + root.appendingPathComponent("java-test/extensions/com.microsoft.java.test.plugin-0.42.0.jar") ] { try Data().write(to: file) } diff --git a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift index f951ed72..ebef2255 100644 --- a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -1362,7 +1362,13 @@ struct RunConfigurationIntegrationTests { lombokAgentURL: URL(fileURLWithPath: "/jdtls/lombok/lombok.jar"), javaDebugBundleURL: URL( fileURLWithPath: "/jdtls/java-debug/com.microsoft.java.debug.plugin-0.53.1.jar" - ) + ), + javaExtensionBundleURLs: [ + URL( + fileURLWithPath: + "/jdtls/java-test/extensions/com.microsoft.java.test.plugin-0.42.0.jar" + ) + ] ) let runtime = StdioLanguageProviderRuntime( descriptor: descriptor, diff --git a/rust/lithe-core/src/lsp/interface/engine.rs b/rust/lithe-core/src/lsp/interface/engine.rs index 5630f3ae..fd0d76ce 100644 --- a/rust/lithe-core/src/lsp/interface/engine.rs +++ b/rust/lithe-core/src/lsp/interface/engine.rs @@ -111,9 +111,15 @@ pub struct JdtlsLaunchResources { pub configuration_directory: String, /// Lombok agent shipped with the selected JDT LS installation. pub lombok_agent_path: String, - /// Optional Java Debug Server bundle loaded lazily by JDT LS. + /// Legacy Java Debug Server bundle retained for older platform clients. #[serde(default)] pub java_debug_bundle_path: Option, + /// Ordered Java extension bundles loaded through JDT LS initialization. + /// + /// When the legacy Debug field is also present, Core loads it first and + /// removes duplicate paths while preserving the remaining caller order. + #[serde(default)] + pub java_extension_bundle_paths: Vec, } #[derive(Debug, Clone, Serialize)] @@ -718,6 +724,23 @@ impl LspEngine { .as_deref() .map(PathBuf::from) .or_else(|| java_executable_from_environment(&request.environment)); + let java_extension_bundle_paths = request + .jdtls_launch_resources + .as_ref() + .map(|resources| { + let mut paths = Vec::new(); + if let Some(path) = &resources.java_debug_bundle_path { + paths.push(PathBuf::from(path)); + } + for path in &resources.java_extension_bundle_paths { + let path = PathBuf::from(path); + if !paths.contains(&path) { + paths.push(path); + } + } + paths + }) + .unwrap_or_default(); let adaptation = adapt_start(&JdtStartContext { provider_id: request.provider_id.clone(), workspace_root: workspace_root.clone(), @@ -778,11 +801,7 @@ impl LspEngine { initialization_options: adapt_initialization_options( &request.provider_id, request.initialization_options, - request - .jdtls_launch_resources - .as_ref() - .and_then(|resources| resources.java_debug_bundle_path.as_deref()) - .map(Path::new), + &java_extension_bundle_paths, ), })?; let request_id = (initialize.state.next_request_id - 1).to_string(); @@ -2543,6 +2562,10 @@ fn validate_start_request(request: &StartServerRequest) -> Result<(), CoreError> .java_debug_bundle_path .as_deref() .is_some_and(|path| !is_valid_process_path(Some(path))) + || resources + .java_extension_bundle_paths + .iter() + .any(|path| !is_valid_process_path(Some(path))) { return Err(invalid_field("jdtlsLaunchResources/runtimeExecutablePath")); } @@ -4336,6 +4359,12 @@ mod tests { "/opt/lithe/jdtls/java-debug/com.microsoft.java.debug.plugin-0.53.1.jar" .to_string(), ), + java_extension_bundle_paths: vec![ + "/opt/lithe/jdtls/java-debug/com.microsoft.java.debug.plugin-0.53.1.jar" + .to_string(), + "/opt/lithe/jdtls/java-test/extensions/com.microsoft.java.test.plugin-0.42.0.jar" + .to_string(), + ], }); request.cache_directory = Some(cache.to_string_lossy().into_owned()); engine @@ -4899,8 +4928,20 @@ mod tests { let mut harness = Harness::start(|request| { request.provider_id = "java".to_string(); request.cache_directory = Some(cache.to_string_lossy().into_owned()); + request.runtime_executable_path = Some("/opt/lithe/jdk/bin/java".to_string()); + request.jdtls_launch_resources = Some(JdtlsLaunchResources { + launcher_jar_path: "/opt/lithe/jdtls/plugins/equinox.jar".to_string(), + configuration_directory: "/opt/lithe/jdtls/config_mac".to_string(), + lombok_agent_path: "/opt/lithe/jdtls/lombok/lombok.jar".to_string(), + java_debug_bundle_path: Some("/plugins/java-debug.jar".to_string()), + java_extension_bundle_paths: vec![ + "/plugins/java-debug.jar".to_string(), + "/plugins/java-test.jar".to_string(), + ], + }); request.initialization_options = Some(json!({ - "extendedClientCapabilities": { "customCapability": true } + "extendedClientCapabilities": { "customCapability": true }, + "bundles": ["/plugins/catalog.jar"] })); }); let initialize = harness @@ -4919,6 +4960,14 @@ mod tests { ["customCapability"], true ); + assert_eq!( + initialize["params"]["initializationOptions"]["bundles"], + json!([ + "/plugins/catalog.jar", + "/plugins/java-debug.jar", + "/plugins/java-test.jar" + ]) + ); harness .server @@ -6052,6 +6101,7 @@ public class Main { configuration_directory: "/jdtls/config_mac".to_string(), lombok_agent_path: "/jdtls/lombok/lombok.jar".to_string(), java_debug_bundle_path: None, + java_extension_bundle_paths: Vec::new(), }); assert!(validate_start_request(&request).is_err()); @@ -6083,6 +6133,13 @@ public class Main { resources.configuration_directory, "/opt/lithe/jdtls/config_mac" ); + assert_eq!( + resources.java_extension_bundle_paths, + vec![ + "/opt/lithe/jdtls/java-debug/com.microsoft.java.debug.plugin-0.53.1.jar", + "/opt/lithe/jdtls/java-test/extensions/com.microsoft.java.test.plugin-0.42.0.jar", + ] + ); assert_eq!(request.initialize_timeout_milliseconds, 30_000); assert_eq!(request.service_ready_idle_timeout_milliseconds, 45_000); assert_eq!(request.service_ready_absolute_timeout_milliseconds, 600_000); diff --git a/rust/lithe-core/src/lsp/languages/jdt.rs b/rust/lithe-core/src/lsp/languages/jdt.rs index 34d40154..985656ac 100644 --- a/rust/lithe-core/src/lsp/languages/jdt.rs +++ b/rust/lithe-core/src/lsp/languages/jdt.rs @@ -208,14 +208,16 @@ pub(crate) fn adapt_start(context: &JdtStartContext) -> JdtStartAdaptation { } } -/// Adds the JDT LS client extensions required for class-file navigation. +/// Adds the JDT LS client extensions required for Java tooling. /// /// Catalog-provided options are preserved, while the provider-owned capability /// is authoritative because virtual class files cannot be opened without it. +/// Extension bundles are appended in caller order without duplicating catalog +/// entries, which keeps Debug and Test plugin activation deterministic. pub(crate) fn adapt_initialization_options( provider_id: &str, initialization_options: Option, - java_debug_bundle_path: Option<&Path>, + java_extension_bundle_paths: &[PathBuf], ) -> Option { if !is_java_provider(provider_id) { return initialization_options; @@ -236,8 +238,7 @@ pub(crate) fn adapt_initialization_options( .expect("the extended capabilities were normalized to an object") .insert("classFileContentsSupport".to_string(), Value::Bool(true)); - if let Some(bundle_path) = java_debug_bundle_path { - let bundle = Value::String(bundle_path.to_string_lossy().into_owned()); + if !java_extension_bundle_paths.is_empty() { let bundles = options .entry("bundles") .or_insert_with(|| Value::Array(Vec::new())); @@ -247,8 +248,11 @@ pub(crate) fn adapt_initialization_options( let bundles = bundles .as_array_mut() .expect("the Java extension bundles were normalized to an array"); - if !bundles.contains(&bundle) { - bundles.push(bundle); + for bundle_path in java_extension_bundle_paths { + let bundle = Value::String(bundle_path.to_string_lossy().into_owned()); + if !bundles.contains(&bundle) { + bundles.push(bundle); + } } } @@ -1129,7 +1133,7 @@ mod tests { assert!(initialized_notification("rust").is_none()); assert!(virtual_source_resolve_params("rust", "jdt://contents/A.class").is_none()); assert_eq!( - adapt_initialization_options("rust", Some(json!({ "custom": true })), None), + adapt_initialization_options("rust", Some(json!({ "custom": true })), &[]), Some(json!({ "custom": true })) ); let location = ProviderLocation { @@ -1152,9 +1156,12 @@ mod tests { "classFileContentsSupport": false } })), - Some(Path::new( - "/jdtls/java-debug/com.microsoft.java.debug.plugin-0.53.1.jar", - )), + &[ + PathBuf::from("/jdtls/java-debug/com.microsoft.java.debug.plugin-0.53.1.jar"), + PathBuf::from( + "/jdtls/java-test/extensions/com.microsoft.java.test.plugin-0.42.0.jar", + ), + ], ) .unwrap(); @@ -1171,7 +1178,8 @@ mod tests { options["bundles"], json!([ "/plugins/custom.jar", - "/jdtls/java-debug/com.microsoft.java.debug.plugin-0.53.1.jar" + "/jdtls/java-debug/com.microsoft.java.debug.plugin-0.53.1.jar", + "/jdtls/java-test/extensions/com.microsoft.java.test.plugin-0.42.0.jar" ]) ); } diff --git a/scripts/prepare-jdtls.sh b/scripts/prepare-jdtls.sh index e4c6c36c..cea744d0 100755 --- a/scripts/prepare-jdtls.sh +++ b/scripts/prepare-jdtls.sh @@ -24,10 +24,17 @@ java_debug_archive_sha256="$(manifest_value javaDebugArchiveSHA256)" java_debug_plugin_sha256="$(manifest_value javaDebugPluginSHA256)" java_debug_license_url="$(manifest_value javaDebugLicenseURL)" java_debug_license_sha256="$(manifest_value javaDebugLicenseSHA256)" +java_test_archive_url="$(manifest_value javaTestArchiveURL)" +java_test_archive_sha256="$(manifest_value javaTestArchiveSHA256)" +java_test_plugin_sha256="$(manifest_value javaTestPluginSHA256)" +java_test_runner_sha256="$(manifest_value javaTestRunnerSHA256)" +java_test_license_url="$(manifest_value javaTestLicenseURL)" +java_test_license_sha256="$(manifest_value javaTestLicenseSHA256)" jdtls_version="$(manifest_value version)" lombok_version="$(manifest_value lombokVersion)" java_debug_extension_version="$(manifest_value javaDebugExtensionVersion)" java_debug_server_version="$(manifest_value javaDebugServerVersion)" +java_test_extension_version="$(manifest_value javaTestExtensionVersion)" archive_path="${LITHE_JDTLS_ARCHIVE:-$CACHE_DIR/jdtls-$jdtls_version-$archive_sha256.tar.gz}" license_path="$CACHE_DIR/EPL-2.0-$license_sha256.txt" lombok_path="$CACHE_DIR/lombok-$lombok_version-$lombok_sha256.jar" @@ -35,6 +42,10 @@ lombok_license_path="$CACHE_DIR/lombok-MIT-$lombok_version-$lombok_license_sha25 java_debug_archive_path="$CACHE_DIR/vscode-java-debug-$java_debug_extension_version-$java_debug_archive_sha256.vsix" java_debug_license_path="$CACHE_DIR/java-debug-EPL-1.0-$java_debug_server_version-$java_debug_license_sha256.txt" java_debug_plugin_name="com.microsoft.java.debug.plugin-$java_debug_server_version.jar" +java_test_archive_path="$CACHE_DIR/vscode-java-test-$java_test_extension_version-$java_test_archive_sha256.vsix" +java_test_license_path="$CACHE_DIR/java-test-MIT-$java_test_extension_version-$java_test_license_sha256.txt" +java_test_plugin_name="com.microsoft.java.test.plugin-$java_test_extension_version.jar" +java_test_runner_name="com.microsoft.java.test.runner-jar-with-dependencies.jar" file_sha256() { shasum -a 256 "$1" | awk '{print tolower($1)}' @@ -108,6 +119,11 @@ validate_output() { [[ -f "$OUTPUT_DIR/lombok/LICENSE-MIT.txt" ]] || { print -u2 -- "JDTLS Lombok license is missing: $OUTPUT_DIR"; exit 1; } [[ -f "$OUTPUT_DIR/java-debug/$java_debug_plugin_name" ]] || { print -u2 -- "Java Debug Server plugin is missing: $OUTPUT_DIR"; exit 1; } [[ -f "$OUTPUT_DIR/java-debug/LICENSE-EPL-1.0.txt" ]] || { print -u2 -- "Java Debug Server license is missing: $OUTPUT_DIR"; exit 1; } + [[ -f "$OUTPUT_DIR/java-test/extensions/$java_test_plugin_name" ]] || { print -u2 -- "Java Test extension plugin is missing: $OUTPUT_DIR"; exit 1; } + local java_test_extension_bundles=("$OUTPUT_DIR"/java-test/extensions/*.jar(N)) + (( ${#java_test_extension_bundles[@]} == 18 )) || { print -u2 -- "Java Test extension bundle set is incomplete: $OUTPUT_DIR/java-test/extensions"; exit 1; } + [[ -f "$OUTPUT_DIR/java-test/runner/$java_test_runner_name" ]] || { print -u2 -- "Java Test runner is missing: $OUTPUT_DIR"; exit 1; } + [[ -f "$OUTPUT_DIR/java-test/LICENSE-MIT.txt" ]] || { print -u2 -- "Java Test license is missing: $OUTPUT_DIR"; exit 1; } # Wrapper scripts remain available for external/legacy launch plans. The # packaged product launches bundled Java directly with the resources above. grep -Fq -- '-javaagent:' "$OUTPUT_DIR/bin/jdtls" || { print -u2 -- "JDTLS launcher does not load the Lombok agent: $OUTPUT_DIR"; exit 1; } @@ -136,6 +152,8 @@ download_verified_file "$lombok_url" "$lombok_sha256" "$lombok_path" "Lombok age download_verified_file "$lombok_license_url" "$lombok_license_sha256" "$lombok_license_path" "Lombok MIT license" download_verified_file "$java_debug_archive_url" "$java_debug_archive_sha256" "$java_debug_archive_path" "Java Debug extension" download_verified_file "$java_debug_license_url" "$java_debug_license_sha256" "$java_debug_license_path" "Java Debug EPL-1.0 license" +download_verified_file "$java_test_archive_url" "$java_test_archive_sha256" "$java_test_archive_path" "Java Test extension" +download_verified_file "$java_test_license_url" "$java_test_license_sha256" "$java_test_license_path" "Java Test MIT license" rm -rf "$OUTPUT_DIR" mkdir -p "$OUTPUT_DIR" @@ -156,6 +174,36 @@ if [[ "$actual_java_debug_plugin_sha256" != "$java_debug_plugin_sha256" ]]; then exit 1 fi cp "$java_debug_license_path" "$OUTPUT_DIR/java-debug/LICENSE-EPL-1.0.txt" +java_test_extraction="$(mktemp -d "$CACHE_DIR/java-test-extract.XXXXXX")" +unzip -q -j \ + "$java_test_archive_path" \ + "extension/server/*.jar" \ + -d "$java_test_extraction" +mkdir -p "$OUTPUT_DIR/java-test/extensions" "$OUTPUT_DIR/java-test/runner" +for java_test_jar in "$java_test_extraction"/*.jar(N); do + case "${java_test_jar:t}" in + jacocoagent.jar) + ;; + "$java_test_runner_name") + cp "$java_test_jar" "$OUTPUT_DIR/java-test/runner/$java_test_runner_name" + ;; + *) + cp "$java_test_jar" "$OUTPUT_DIR/java-test/extensions/${java_test_jar:t}" + ;; + esac +done +rm -rf -- "$java_test_extraction" +actual_java_test_plugin_sha256="$(file_sha256 "$OUTPUT_DIR/java-test/extensions/$java_test_plugin_name")" +if [[ "$actual_java_test_plugin_sha256" != "$java_test_plugin_sha256" ]]; then + print -u2 -- "Java Test plugin checksum mismatch: expected $java_test_plugin_sha256, got $actual_java_test_plugin_sha256" + exit 1 +fi +actual_java_test_runner_sha256="$(file_sha256 "$OUTPUT_DIR/java-test/runner/$java_test_runner_name")" +if [[ "$actual_java_test_runner_sha256" != "$java_test_runner_sha256" ]]; then + print -u2 -- "Java Test runner checksum mismatch: expected $java_test_runner_sha256, got $actual_java_test_runner_sha256" + exit 1 +fi +cp "$java_test_license_path" "$OUTPUT_DIR/java-test/LICENSE-MIT.txt" cat > "$OUTPUT_DIR/bin/jdtls" <<'EOF' #!/bin/zsh diff --git a/scripts/verify-macos-package.sh b/scripts/verify-macos-package.sh index f8978b9a..6b4db971 100755 --- a/scripts/verify-macos-package.sh +++ b/scripts/verify-macos-package.sh @@ -8,6 +8,11 @@ java_debug_server_version="$( /usr/bin/plutil -extract javaDebugServerVersion raw -o - third_party/jdtls/manifest.json )" java_debug_plugin_name="com.microsoft.java.debug.plugin-$java_debug_server_version.jar" +java_test_extension_version="$( + /usr/bin/plutil -extract javaTestExtensionVersion raw -o - third_party/jdtls/manifest.json +)" +java_test_plugin_name="com.microsoft.java.test.plugin-$java_test_extension_version.jar" +java_test_runner_name="com.microsoft.java.test.runner-jar-with-dependencies.jar" temporary_directory=$(mktemp -d "${TMPDIR:-/tmp}/lithe-package-verification.XXXXXX") trap 'rm -rf -- "$temporary_directory"' EXIT @@ -28,7 +33,9 @@ mkdir -p \ "$jdtls_root/config_win" \ "$jdtls_root/bin" \ "$jdtls_root/lombok" \ - "$jdtls_root/java-debug" + "$jdtls_root/java-debug" \ + "$jdtls_root/java-test/extensions" \ + "$jdtls_root/java-test/runner" cat > "$jdtls_root/bin/jdtls" <<'LAUNCHER' #!/bin/zsh java_agent_argument="-javaagent:../lombok/lombok.jar" @@ -44,6 +51,31 @@ LAUNCHER : > "$jdtls_root/plugins/org.eclipse.equinox.launcher_1.0.0.jar" : > "$jdtls_root/java-debug/$java_debug_plugin_name" : > "$jdtls_root/java-debug/LICENSE-EPL-1.0.txt" +java_test_extension_bundles=( + "junit-jupiter-api_5.9.3.jar" + "junit-jupiter-engine_5.9.3.jar" + "junit-jupiter-migrationsupport_5.9.3.jar" + "junit-jupiter-params_5.9.3.jar" + "junit-platform-commons_1.9.3.jar" + "junit-platform-engine_1.9.3.jar" + "junit-platform-launcher_1.9.3.jar" + "junit-platform-runner_1.9.3.jar" + "junit-platform-suite-api_1.9.3.jar" + "junit-platform-suite-commons_1.9.3.jar" + "junit-platform-suite-engine_1.9.3.jar" + "junit-vintage-engine_5.9.3.jar" + "org.apiguardian.api_1.1.2.jar" + "org.eclipse.jdt.junit4.runtime_1.3.0.v20220609-1843.jar" + "org.eclipse.jdt.junit5.runtime_1.1.100.v20220907-0450.jar" + "org.opentest4j_1.2.0.jar" + "org.jacoco.core_0.8.12.202403310830.jar" + "$java_test_plugin_name" +) +for bundle in "${java_test_extension_bundles[@]}"; do + : > "$jdtls_root/java-test/extensions/$bundle" +done +: > "$jdtls_root/java-test/runner/$java_test_runner_name" +: > "$jdtls_root/java-test/LICENSE-MIT.txt" for missing_configuration in config_mac_arm config_mac; do broken_jdtls_root="$temporary_directory/jdtls-missing-$missing_configuration" @@ -114,6 +146,9 @@ required_resources=( "$app_path/Contents/Resources/LanguageServers/jdtls/lombok/lombok.jar" "$app_path/Contents/Resources/LanguageServers/jdtls/java-debug/$java_debug_plugin_name" "$app_path/Contents/Resources/LanguageServers/jdtls/java-debug/LICENSE-EPL-1.0.txt" + "$app_path/Contents/Resources/LanguageServers/jdtls/java-test/extensions/$java_test_plugin_name" + "$app_path/Contents/Resources/LanguageServers/jdtls/java-test/runner/$java_test_runner_name" + "$app_path/Contents/Resources/LanguageServers/jdtls/java-test/LICENSE-MIT.txt" "$app_path/Contents/Resources/LanguageServers/jdk-arm64/bin/java" "$app_path/Contents/Resources/LanguageServers/jdk-arm64/lib" "$app_path/Contents/Resources/LanguageServers/jdk-x86_64/bin/java" diff --git a/shared/contracts/application-boundary.md b/shared/contracts/application-boundary.md index 7c8b0938..2abc1675 100644 --- a/shared/contracts/application-boundary.md +++ b/shared/contracts/application-boundary.md @@ -143,12 +143,14 @@ preparing, ready, failure, and timeout notifications; a navigation command while preparing ends after the notice and is never replayed later. macOS and Windows adapters discover the selected JDT LS installation's Equinox -launcher JAR, platform configuration directory, Lombok agent, and bundled Java -executable. They submit those paths as structured launch resources; Rust Core -owns the JVM flags and directly starts `java`/`java.exe` with array arguments. -Packaged JDT LS therefore has no runtime dependency on shell wrappers, -PowerShell, or the user's `PATH`. Legacy wrappers are an external-plan -compatibility fallback and are not the packaged execution path. +launcher JAR, platform configuration directory, Lombok agent, Java Debug +Server, and bundled Java executable. Java Test-capable adapters additionally +submit ordered extension bundles; Rust Core owns their ordering and +de-duplication, the JVM flags, and direct `java`/`java.exe` startup with array +arguments. The macOS TestNG runner remains a packaged native resource used only +when a TestNG session starts. Packaged JDT LS therefore has no runtime dependency +on shell wrappers, PowerShell, or the user's `PATH`. Legacy wrappers are an +external-plan compatibility fallback and are not the packaged execution path. Platforms observe JDT LS version and non-recursive build-file metadata, while Rust Core alone validates and reduces those observations to the opaque workspace diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 46792a64..ca61a61e 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -532,9 +532,11 @@ provider such as JDT LS that has a later readiness signal, progress and `serviceReadyAbsoluteTimeoutMilliseconds` is the final safety cap. The defaults are 45 seconds idle and 10 minutes absolute; duplicate progress does not refresh the idle deadline. `jdtlsLaunchResources`, when present, -contains `launcherJarPath`, `configurationDirectory`, `lombokAgentPath`, and the -optional `javaDebugBundlePath`; it -is valid only for the Java provider and requires `runtimeExecutablePath`. Rust +contains `launcherJarPath`, `configurationDirectory`, `lombokAgentPath`, the +legacy optional `javaDebugBundlePath`, and ordered +`javaExtensionBundlePaths`. It is valid only for the Java provider and requires +`runtimeExecutablePath`. Rust loads the legacy Debug bundle first when present, +then appends the extension bundle paths with stable de-duplication. Rust then uses `runtimeExecutablePath` as the process executable and constructs the complete deterministic JDT LS JVM argument list. When the structured object is absent, the selected `executablePath` and legacy wrapper arguments remain the @@ -554,10 +556,11 @@ diagnostic snapshot in `underlyingMessage`. Platform adapters own filesystem discovery and validate that packaged JDT LS contains the Equinox launcher, platform configuration directory, Lombok agent, -and bundled Java. They do not construct JVM commands. Packaged macOS and Windows -plans always use structured direct launch, so runtime startup has no shell, -PowerShell, or user-`PATH` dependency. Wrapper launch remains optional only for -external or older plans. +Java Debug Server, and bundled Java. Java Test-capable hosts additionally +validate their extension bundles and runner. They do not construct JVM commands. +Packaged macOS and Windows plans always use structured direct launch, so runtime +startup has no shell, PowerShell, or user-`PATH` dependency. Wrapper launch +remains optional only for external or older plans. For JDT LS, platform adapters observe root Maven/Gradle descriptor timestamps and sizes, names of direct Maven module directories, and the selected JDT LS diff --git a/shared/fixtures/lsp/jdt-direct-launch-v1.json b/shared/fixtures/lsp/jdt-direct-launch-v1.json index 4f8581a2..90959256 100644 --- a/shared/fixtures/lsp/jdt-direct-launch-v1.json +++ b/shared/fixtures/lsp/jdt-direct-launch-v1.json @@ -17,7 +17,11 @@ "launcherJarPath": "/opt/lithe/jdtls/plugins/org.eclipse.equinox.launcher_1.7.0.jar", "configurationDirectory": "/opt/lithe/jdtls/config_mac", "lombokAgentPath": "/opt/lithe/jdtls/lombok/lombok.jar", - "javaDebugBundlePath": "/opt/lithe/jdtls/java-debug/com.microsoft.java.debug.plugin-0.53.1.jar" + "javaDebugBundlePath": "/opt/lithe/jdtls/java-debug/com.microsoft.java.debug.plugin-0.53.1.jar", + "javaExtensionBundlePaths": [ + "/opt/lithe/jdtls/java-debug/com.microsoft.java.debug.plugin-0.53.1.jar", + "/opt/lithe/jdtls/java-test/extensions/com.microsoft.java.test.plugin-0.42.0.jar" + ] }, "cacheDirectory": "/var/cache/lithe/language-servers", "workspaceFingerprint": "build=|modules=|jdtls=1.55.0", diff --git a/third_party/jdtls/manifest.json b/third_party/jdtls/manifest.json index b469ab60..819eb592 100644 --- a/third_party/jdtls/manifest.json +++ b/third_party/jdtls/manifest.json @@ -16,5 +16,12 @@ "javaDebugPluginSHA256": "daaaa5f63f527dc1e9bfa7bae1aca006b69fb29fa0525d7e284d3420ec7b9c44", "javaDebugLicenseURL": "https://raw.githubusercontent.com/microsoft/java-debug/0.53.1/LICENSE.txt", "javaDebugLicenseSHA256": "f494326c16bc95ebb14874ea5fa2c16a963eb36d1f2ab6fe99490073709771c1", + "javaTestExtensionVersion": "0.42.0", + "javaTestArchiveURL": "https://open-vsx.org/api/vscjava/vscode-java-test/0.42.0/file/vscjava.vscode-java-test-0.42.0.vsix", + "javaTestArchiveSHA256": "6293167533595b812d0490c5e2b649f920bf61eb331f17de6046364fe3894bea", + "javaTestPluginSHA256": "3f8a5af986b0440223845f34e70860ee50a096375692dd5ebe69ebc8a75ab99f", + "javaTestRunnerSHA256": "f7f3298c28ae0a01f69e45744648a2a81867228e48027374669e4ef7bfc15bc7", + "javaTestLicenseURL": "https://raw.githubusercontent.com/microsoft/vscode-java-test/0.42.0/LICENSE.txt", + "javaTestLicenseSHA256": "8314299543336aa4fe5c1d1d6cf278d538b387aad74cba37e50f5c4d34add9f3", "minimumJavaVersion": 17 } From 4e8dadf161779918afbcc6455ac3ca6a367e74a0 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sun, 30 Aug 2026 08:00:30 +0800 Subject: [PATCH 38/66] feat(debug): add Java test debugging workflow --- docs/architecture/language-tooling.md | 8 + .../Application/Composition/AppServices.swift | 6 + .../Core/Debug/JavaTestResultServer.swift | 8 + .../Core/Rust/RustDebugProtocolCore.swift | 49 ++ .../AppModel/AppModel+Development.swift | 226 +++++++++- .../AppModel/AppModel+ExecutionModules.swift | 5 + .../Lithe/Models/AppModel/AppModel.swift | 6 +- .../MacOS/Debug/MacJavaTestResultServer.swift | 228 ++++++++++ .../Platform/MacOS/MacServiceContainer.swift | 5 + .../MacJDTLSLaunchResourceResolver.swift | 10 +- .../DebugLaunchConfigurationResolver.swift | 23 +- .../Debug/JavaTestDebugLaunchService.swift | 62 +++ .../Views/Language/LanguageTestsView.swift | 56 ++- .../Debug/DebugAdapterContracts.swift | 60 +++ .../Debug/DebugProtocolCore.swift | 9 + .../Execution/ExecutionContracts.swift | 16 +- .../LanguageServerRuntimeContracts.swift | 6 +- .../Language/LanguageToolingContracts.swift | 3 + .../Services/LanguageTestService.swift | 11 + .../StandardLanguageTestProvider.swift | 11 +- .../Runtime/LanguageServerSession.swift | 1 + .../LanguageToolingSessionManager.swift | 329 +++++++++++++- .../LanguageIntelligenceModuleTests.swift | 421 +++++++++++++++++- .../JavaLanguageServerRuntimeTests.swift | 20 +- .../JavaTestDebugLaunchServiceTests.swift | 257 +++++++++++ .../MacJavaTestResultServerTests.swift | 69 +++ .../RealJavaDebugIntegrationTests.swift | 298 ++++++++++++- .../RunConfigurationIntegrationTests.swift | 76 +++- rust/lithe-core/src/debug/java_test.rs | 291 ++++++++++++ rust/lithe-core/src/debug/mod.rs | 2 + rust/lithe-core/src/debug/types.rs | 4 +- rust/lithe-core/src/protocol/command.rs | 4 + rust/lithe-core/src/runtime/dispatcher.rs | 19 + rust/lithe-core/src/tests/protocol.rs | 29 ++ shared/contracts/application-boundary.md | 18 + shared/contracts/rust-core-api.md | 12 + .../fixtures/debug/java-test-launch-v1.json | 70 +++ 37 files changed, 2664 insertions(+), 64 deletions(-) create mode 100644 macos/Sources/Lithe/Core/Debug/JavaTestResultServer.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/Debug/MacJavaTestResultServer.swift create mode 100644 macos/Sources/Lithe/Services/Debug/JavaTestDebugLaunchService.swift create mode 100644 macos/Tests/LitheTests/JavaTestDebugLaunchServiceTests.swift create mode 100644 macos/Tests/LitheTests/MacJavaTestResultServerTests.swift create mode 100644 rust/lithe-core/src/debug/java_test.rs create mode 100644 shared/fixtures/debug/java-test-launch-v1.json diff --git a/docs/architecture/language-tooling.md b/docs/architecture/language-tooling.md index 27db574f..05eee24a 100644 --- a/docs/architecture/language-tooling.md +++ b/docs/architecture/language-tooling.md @@ -195,6 +195,14 @@ marker 结果。新版本会取消旧批次并使缓存失效,因此频繁输 CodeLens、implementation 或 `java/findLinks` 请求。没有语义目标的声明不显示 图标;一个目标直接跳转,多个目标由平台 UI 显示选择列表。 +Java 测试类与方法同样不能由 UI 猜测。Tests 面板打开或刷新时, +`LanguageToolingSessionManager` 直接调用 JDT LS 已注册的 Java Test 扩展命令 +`vscode.java.test.findTestTypesAndMethods`,将 JDT 返回的类、方法、框架和 +全限定标识投影为平台无关的测试项。UI 只展示并回传稳定标识;JUnit/TestNG 的 +Debug 启动继续由 JDT 生成项目参数,再交给 Rust Debug Core 归一化。测试发现本身 +不会创建 Debug 会话、回环 socket 或目标 JVM,关闭面板、切换项目和重载 Java +runtime 都会取消尚未完成的发现任务并丢弃晚到结果。 + ### 当前限制 - 只支持 stdio transport,尚无 socket/TCP 或服务器自定义握手 adapter。 diff --git a/macos/Sources/Lithe/Application/Composition/AppServices.swift b/macos/Sources/Lithe/Application/Composition/AppServices.swift index d537ddfd..ceee972a 100644 --- a/macos/Sources/Lithe/Application/Composition/AppServices.swift +++ b/macos/Sources/Lithe/Application/Composition/AppServices.swift @@ -20,6 +20,7 @@ final class AppServices { /// Metadata-only provider catalog; providers are activated on demand. let languageProviderCatalog: LanguageProviderCatalog let debugLaunchConfigurationResolver: DebugLaunchConfigurationResolver + let javaTestDebugLaunchService: JavaTestDebugLaunchService let debugBreakpointPersistence: (any DebugBreakpointPersisting)? let workspaceOperations: any WorkspaceOperations let documentLifecycleDecider: any DocumentLifecycleDeciding @@ -54,6 +55,7 @@ final class AppServices { languageProviderCatalogSource: any LanguageProviderCatalogSource, languageProviderCatalogSnapshot: LanguageProviderCatalogSnapshot? = nil, debugLaunchConfigurationResolver: DebugLaunchConfigurationResolver? = nil, + javaTestResultServerFactory: @escaping @MainActor () -> any JavaTestResultServing, debugBreakpointPersistence: (any DebugBreakpointPersisting)? = nil, workspaceOperations: any WorkspaceOperations, documentLifecycleDecider: any DocumentLifecycleDeciding, @@ -91,6 +93,10 @@ final class AppServices { self.languageProviderCatalog = resolvedCatalog self.debugLaunchConfigurationResolver = debugLaunchConfigurationResolver ?? DebugLaunchConfigurationResolver(fileStorage: fileStorage) + self.javaTestDebugLaunchService = JavaTestDebugLaunchService( + configurationResolver: self.debugLaunchConfigurationResolver, + resultServerFactory: javaTestResultServerFactory + ) self.debugBreakpointPersistence = debugBreakpointPersistence self.workspaceOperations = workspaceOperations self.documentLifecycleDecider = documentLifecycleDecider diff --git a/macos/Sources/Lithe/Core/Debug/JavaTestResultServer.swift b/macos/Sources/Lithe/Core/Debug/JavaTestResultServer.swift new file mode 100644 index 00000000..1ffd45cc --- /dev/null +++ b/macos/Sources/Lithe/Core/Debug/JavaTestResultServer.swift @@ -0,0 +1,8 @@ +import Foundation + +/// Platform port for the short-lived loopback server required by Java test runners. +@MainActor +protocol JavaTestResultServing: AnyObject { + func start() async throws -> UInt16 + func stop() +} diff --git a/macos/Sources/Lithe/Core/Rust/RustDebugProtocolCore.swift b/macos/Sources/Lithe/Core/Rust/RustDebugProtocolCore.swift index 277e4ea9..05e5bc83 100644 --- a/macos/Sources/Lithe/Core/Rust/RustDebugProtocolCore.swift +++ b/macos/Sources/Lithe/Core/Rust/RustDebugProtocolCore.swift @@ -1,6 +1,18 @@ import Foundation import LitheCoreContracts +extension RustCoreBridge: JavaTestDebugLaunchResolving { + func resolveJavaTestDebugLaunch( + target: JavaTestDebugLaunchTarget, + resultPort: UInt16 + ) throws -> DebugLaunchConfiguration { + try executeResult( + command: "debug.javaTestLaunch", + payload: JavaTestDebugLaunchPayload(target: target, resultPort: resultPort) + ).get() + } +} + extension RustCoreBridge: DebugProtocolCore { func resolveDebugSteppingFilters( adapterID: String, @@ -237,6 +249,43 @@ extension RustCoreBridge: DebugProtocolCore { } } +private struct JavaTestDebugLaunchPayload: Encodable { + let name: String + let framework: JavaTestDebugFramework + let workingDirectory: String + let mainClass: String + let projectName: String? + let classPaths: [String] + let modulePaths: [String] + let vmArguments: [String] + let programArguments: [String] + let resultPort: UInt16 + let testNGRunnerPath: String? + let testNGTestNames: [String] + + init(target: JavaTestDebugLaunchTarget, resultPort: UInt16) { + name = target.name + framework = target.framework + workingDirectory = target.workingDirectory + mainClass = target.mainClass + projectName = target.projectName + classPaths = target.classPaths + modulePaths = target.modulePaths + vmArguments = target.vmArguments + programArguments = target.programArguments + self.resultPort = resultPort + testNGRunnerPath = target.testNGRunnerPath + testNGTestNames = target.testNGTestNames + } + + private enum CodingKeys: String, CodingKey { + case name, framework, workingDirectory, mainClass, projectName + case classPaths, modulePaths, vmArguments, programArguments, resultPort + case testNGRunnerPath = "testngRunnerPath" + case testNGTestNames = "testngTestNames" + } +} + private struct DebugCreateSessionPayload: Encodable { let sessionID: String let adapterID: String diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift index bcc46fd4..00ae753f 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift @@ -4,6 +4,15 @@ import LitheDebugModule import LitheExecutionModule import LitheModuleAPI +@MainActor +final class JavaTestWorkflowState { + var resultServer: (any JavaTestResultServing)? + var debugLaunchTask: Task? + var debugLaunchOperationID: UUID? + var discoveryTask: Task? + var discoveryOperationID: UUID? +} + @MainActor extension AppModel { func toggleSpringEndpoints() { @@ -383,8 +392,10 @@ extension AppModel { func toggleTests() { isTestsVisible.toggle() - guard isTestsVisible else { return } - Task { [weak self] in _ = await self?.activateExecutionModule() } + guard isTestsVisible else { + cancelLanguageTestDiscovery() + return + } isGitLogVisible = false isTerminalVisible = false isReferencesVisible = false @@ -393,30 +404,88 @@ extension AppModel { isRunVisible = false isDebugVisible = false guard let workspaceURL else { return } - Task { [weak self] in - guard let self, - let execution = await activateExecutionModule(), - await activateLanguageTestExtensionsIfNeeded( - for: projectFiles, - testService: execution.tests - ) else { return } - execution.tests.discover(workspaceURL: workspaceURL, files: projectFiles) - } + startLanguageTestDiscovery(workspaceURL: workspaceURL) } func refreshTests() { guard let workspaceURL else { return } - Task { [weak self] in - guard let self, - let execution = await activateExecutionModule(), + startLanguageTestDiscovery(workspaceURL: workspaceURL) + } + + private func startLanguageTestDiscovery(workspaceURL: URL) { + cancelLanguageTestDiscovery() + let operationID = UUID() + javaTestWorkflowState.discoveryOperationID = operationID + javaTestWorkflowState.discoveryTask = Task { [weak self] in + guard let self else { return } + defer { finishLanguageTestDiscovery(operationID) } + guard let execution = await activateExecutionModule(), await activateLanguageTestExtensionsIfNeeded( for: projectFiles, testService: execution.tests - ) else { return } + ), + isCurrentLanguageTestDiscovery(operationID) else { return } execution.tests.discover(workspaceURL: workspaceURL, files: projectFiles) + + let baseItems = execution.tests.itemsByProviderID["java"] ?? [] + let javaFiles = baseItems.filter { $0.kind == .file && $0.fileURL != nil } + guard !javaFiles.isEmpty else { return } + do { + let sessions = try await languageSessionsForWorkspaceMaintenance() + var projected = baseItems.filter { $0.kind == .workspace } + var completedFileCount = 0 + for fileItem in javaFiles { + try Task.checkCancellation() + guard isCurrentLanguageTestDiscovery(operationID), + let fileURL = fileItem.fileURL else { return } + do { + let details = try await sessions.discoverJavaTestItems( + fileURL: fileURL, + rootURL: workspaceURL + ) + completedFileCount += 1 + if !details.isEmpty { + projected.append(fileItem) + projected.append(contentsOf: details) + } + } catch is CancellationError { + throw CancellationError() + } catch { + // Preserve the cheap file-level fallback when semantic + // discovery fails for only one source file. + projected.append(fileItem) + } + } + guard isCurrentLanguageTestDiscovery(operationID) else { return } + if projected.allSatisfy({ $0.kind == .workspace }), completedFileCount > 0 { + projected = [] + } + execution.tests.replaceDiscoveredItems(projected, providerID: "java") + } catch is CancellationError { + return + } catch { + guard isCurrentLanguageTestDiscovery(operationID) else { return } + showNotification(error.localizedDescription) + } } } + func cancelLanguageTestDiscovery() { + javaTestWorkflowState.discoveryOperationID = nil + javaTestWorkflowState.discoveryTask?.cancel() + javaTestWorkflowState.discoveryTask = nil + } + + private func isCurrentLanguageTestDiscovery(_ operationID: UUID) -> Bool { + javaTestWorkflowState.discoveryOperationID == operationID && !Task.isCancelled + } + + private func finishLanguageTestDiscovery(_ operationID: UUID) { + guard javaTestWorkflowState.discoveryOperationID == operationID else { return } + javaTestWorkflowState.discoveryOperationID = nil + javaTestWorkflowState.discoveryTask = nil + } + func runTest(providerID: String, scope: LanguageTestScope) { guard let workspaceURL else { return } isTestsVisible = true @@ -445,6 +514,89 @@ extension AppModel { } } + func debugTest(providerID: String, scope: LanguageTestScope) { + guard providerID == "java", let workspaceURL else { + showNotification("Java test debugging is currently available for Java projects only") + return + } + let fileURL: URL + let testIdentifier: String? + switch scope { + case .workspace: + showNotification("Select a Java test file or test case to debug") + return + case .file(let url): + fileURL = url.standardizedFileURL + testIdentifier = nil + case .testCase(let identifier, let url): + guard let url else { + showNotification("The selected Java test has no source file") + return + } + fileURL = url.standardizedFileURL + testIdentifier = identifier + } + cancelJavaTestDebugLaunch() + let operationID = UUID() + javaTestWorkflowState.debugLaunchOperationID = operationID + javaTestWorkflowState.debugLaunchTask = Task { [weak self] in + guard let self else { return } + defer { finishJavaTestDebugLaunch(operationID) } + guard let runFeature = await activateExecutionModule()?.runFeature, + let genericDebugFeature = await activateDebugModule()?.genericFeature, + isCurrentJavaTestDebugLaunch(operationID) else { return } + if let selectedConfiguration = runFeature.selectedConfiguration { + runFeature.select(selectedConfiguration) + } + if let document = openDocuments.first(where: { + $0.url.standardizedFileURL == fileURL + }), + document.isDirty { + do { + let previousText = document.savedText + try saveDocument(document) + recordSave(document, previousText: previousText) + } catch { + showNotification("Could not save \(document.url.lastPathComponent)") + return + } + } + do { + let sessions = try await languageSessionsForWorkspaceMaintenance() + let prepared = try await services.javaTestDebugLaunchService.prepare( + fileURL: fileURL, + testIdentifier: testIdentifier, + rootURL: workspaceURL, + targetResolver: sessions + ) + guard isCurrentJavaTestDebugLaunch(operationID) else { + prepared.stop() + return + } + stopJavaTestResultServer() + javaTestWorkflowState.resultServer = prepared.resultServer + guard genericDebugFeature.start( + fileURL: prepared.target.fileURL, + rootURL: workspaceURL, + configuration: prepared.configuration + ) else { + let message = genericDebugFeature.errorMessage + ?? "Could not debug the Java test" + stopJavaTestResultServer() + showNotification(message) + return + } + showDebugToolWindow() + } catch is CancellationError { + finishJavaTestDebugLaunch(operationID, stopResultServer: true) + } catch { + guard isCurrentJavaTestDebugLaunch(operationID) else { return } + finishJavaTestDebugLaunch(operationID, stopResultServer: true) + showNotification(error.localizedDescription) + } + } + } + private func activateLanguageTestExtensionsIfNeeded( for files: [URL], testService: LanguageTestService @@ -490,9 +642,53 @@ extension AppModel { } func stopDebugging() { + cancelJavaTestDebugLaunch() genericDebugFeatureIfActive?.stop() } + func cancelJavaTestDebugLaunch() { + javaTestWorkflowState.debugLaunchOperationID = nil + javaTestWorkflowState.debugLaunchTask?.cancel() + javaTestWorkflowState.debugLaunchTask = nil + stopJavaTestResultServer() + } + + func stopJavaTestResultServer() { + javaTestWorkflowState.resultServer?.stop() + javaTestWorkflowState.resultServer = nil + } + + func cancelJavaTestWorkflows() { + cancelLanguageTestDiscovery() + cancelJavaTestDebugLaunch() + } + + func cancelJavaWorkspaceWorkflows() { + cancelJavaLanguageServerPreparation() + cancelJavaTestWorkflows() + } + + func handleDebugSessionStateChange(_ state: DebugAdapterState) { + guard state == .terminated || state == .failed else { return } + stopJavaTestResultServer() + } + + private func isCurrentJavaTestDebugLaunch(_ operationID: UUID) -> Bool { + javaTestWorkflowState.debugLaunchOperationID == operationID && !Task.isCancelled + } + + private func finishJavaTestDebugLaunch( + _ operationID: UUID, + stopResultServer: Bool = false + ) { + guard javaTestWorkflowState.debugLaunchOperationID == operationID else { return } + javaTestWorkflowState.debugLaunchOperationID = nil + javaTestWorkflowState.debugLaunchTask = nil + if stopResultServer { + stopJavaTestResultServer() + } + } + func resumeDebugging() { guard let feature = genericDebugFeatureIfActive, feature.state == .paused else { return } feature.execute(.continueExecution) diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift index 9cbf72eb..ed3fbf3d 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift @@ -66,6 +66,11 @@ extension AppModel { observeModuleFeature(.debug, observation: genericFeature.objectWillChange.sink { [weak self] _ in self?.scheduleObjectWillChangeRelay() }) + observeModuleFeature(.debug, observation: genericFeature.$state + .removeDuplicates() + .sink { [weak self] state in + self?.handleDebugSessionStateChange(state) + }) return DebugFeatureAccess(genericFeature: genericFeature) } catch { showNotification(error.localizedDescription) diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 7d15f297..9cb875ba 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -150,6 +150,7 @@ final class AppModel: ObservableObject, Identifiable { private var requestProjectOpen: ((URL) -> Void)? private var didCloseProject: (() -> Void)? private var securityScopedWorkspaceURL: URL? + let javaTestWorkflowState = JavaTestWorkflowState() let services: AppServices let platformUI: any PlatformUI let settings: AppSettings @@ -720,6 +721,7 @@ final class AppModel: ObservableObject, Identifiable { Task { [weak self] in await self?.services.moduleRuntime.shutdownAll() } + cancelJavaTestWorkflows() languageToolingSessionsIfActive?.stopAll() languageTestServiceIfActive?.stop() stopTerminalSessions() @@ -731,6 +733,7 @@ final class AppModel: ObservableObject, Identifiable { } private func reloadJavaRuntimeServices() { + cancelJavaTestWorkflows() genericDebugFeatureIfActive?.stop() mavenFeatureIfActive?.stop() languageToolingSessionsIfActive?.stopLanguageServer(providerID: "java") @@ -923,7 +926,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. - cancelJavaLanguageServerPreparation() + cancelJavaWorkspaceWorkflows() languageToolingSessionsIfActive?.stopAll() reloadLanguageProviderCatalog(for: normalizedURL) stopTerminalSessions() @@ -1039,6 +1042,7 @@ final class AppModel: ObservableObject, Identifiable { isTestsVisible = false isDebugVisible = false stopTerminalSessions() + cancelJavaWorkspaceWorkflows() languageToolingSessionsIfActive?.stopAll() languageTestServiceIfActive?.reset() runtimeFeature.closeProject() diff --git a/macos/Sources/Lithe/Platform/MacOS/Debug/MacJavaTestResultServer.swift b/macos/Sources/Lithe/Platform/MacOS/Debug/MacJavaTestResultServer.swift new file mode 100644 index 00000000..6fc712dd --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Debug/MacJavaTestResultServer.swift @@ -0,0 +1,228 @@ +import Foundation +import Network + +enum MacJavaTestResultListenerState { + case ready(port: UInt16) + case failed(message: String) + case cancelled +} + +protocol MacJavaTestResultListening: AnyObject { + var onStateChange: ((MacJavaTestResultListenerState) -> Void)? { get set } + var onConnection: ((NWConnection) -> Void)? { get set } + + func start(queue: DispatchQueue) + func cancel() +} + +final class MacJavaTestResultNetworkListener: MacJavaTestResultListening { + var onStateChange: ((MacJavaTestResultListenerState) -> Void)? + var onConnection: ((NWConnection) -> Void)? + + private let listener: NWListener + + init() throws { + let parameters = NWParameters.tcp + parameters.requiredLocalEndpoint = .hostPort( + host: NWEndpoint.Host("127.0.0.1"), + port: .any + ) + listener = try NWListener(using: parameters) + listener.stateUpdateHandler = { [weak self] state in + guard let self else { return } + switch state { + case .ready: + guard let port = listener.port?.rawValue else { + onStateChange?(.failed(message: "No listening port was assigned.")) + return + } + onStateChange?(.ready(port: port)) + case .failed(let error): + onStateChange?(.failed(message: error.localizedDescription)) + case .cancelled: + onStateChange?(.cancelled) + default: + break + } + } + listener.newConnectionHandler = { [weak self] connection in + self?.onConnection?(connection) + } + } + + func start(queue: DispatchQueue) { + listener.start(queue: queue) + } + + func cancel() { + listener.cancel() + } +} + +/// Owns the loopback listener used by one Java test debug launch. The listener +/// is created on demand, accepts one runner connection, drains it, and releases +/// all native resources when the runner exits or the launch is cancelled. +@MainActor +final class MacJavaTestResultServer: JavaTestResultServing { + enum ServerError: LocalizedError { + case startupFailed(String) + case startupTimedOut + + var errorDescription: String? { + switch self { + case .startupFailed(let message): + "Could not start the Java test result listener: \(message)" + case .startupTimedOut: + "The Java test result listener did not become ready in time." + } + } + } + + private let queue = DispatchQueue(label: "app.lithe.debug.java-test-results") + private let startupTimeout: Duration + private let listenerFactory: () throws -> any MacJavaTestResultListening + private var listener: (any MacJavaTestResultListening)? + private var connections: [ObjectIdentifier: NWConnection] = [:] + private var startupContinuation: CheckedContinuation? + private var startupDeadlineTask: Task? + private var generation = UUID() + + init( + startupTimeout: Duration = .seconds(5), + listenerFactory: @escaping () throws -> any MacJavaTestResultListening = { + try MacJavaTestResultNetworkListener() + } + ) { + self.startupTimeout = startupTimeout + self.listenerFactory = listenerFactory + } + + func start() async throws -> UInt16 { + stop() + let listener: any MacJavaTestResultListening + do { + listener = try listenerFactory() + } catch { + throw ServerError.startupFailed(error.localizedDescription) + } + self.listener = listener + generation = UUID() + let currentGeneration = generation + listener.onStateChange = { [weak self] state in + Task { @MainActor [weak self] in + self?.consume(state, generation: currentGeneration) + } + } + listener.onConnection = { [weak self] connection in + Task { @MainActor [weak self] in + self?.accept(connection, generation: currentGeneration) + } + } + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + startupContinuation = continuation + listener.start(queue: queue) + startDeadline(generation: currentGeneration) + } + } onCancel: { [weak self] in + Task { @MainActor [weak self] in self?.stop() } + } + } + + func stop() { + generation = UUID() + startupDeadlineTask?.cancel() + startupDeadlineTask = nil + listener?.cancel() + listener = nil + for connection in connections.values { connection.cancel() } + connections = [:] + startupContinuation?.resume(throwing: CancellationError()) + startupContinuation = nil + } + + private func startDeadline(generation: UUID) { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: startupTimeout) + startupDeadlineTask = Task { [weak self] in + do { + try await clock.sleep(until: deadline) + } catch { + return + } + guard !Task.isCancelled else { return } + await MainActor.run { [weak self] in + guard let self, self.generation == generation else { return } + self.failStartup(ServerError.startupTimedOut) + } + } + } + + private func consume(_ state: MacJavaTestResultListenerState, generation: UUID) { + guard self.generation == generation else { return } + switch state { + case .ready(let port): + startupDeadlineTask?.cancel() + startupDeadlineTask = nil + startupContinuation?.resume(returning: port) + startupContinuation = nil + case .failed(let message): + failStartup(ServerError.startupFailed(message)) + case .cancelled: + break + } + } + + private func failStartup(_ error: Error) { + startupDeadlineTask?.cancel() + startupDeadlineTask = nil + listener?.cancel() + listener = nil + startupContinuation?.resume(throwing: error) + startupContinuation = nil + } + + private func accept(_ connection: NWConnection, generation: UUID) { + guard self.generation == generation else { + connection.cancel() + return + } + // One Java test process owns the result channel. Stop accepting new + // peers after it connects, but keep the accepted socket alive. + listener?.cancel() + listener = nil + let identifier = ObjectIdentifier(connection) + connections[identifier] = connection + connection.stateUpdateHandler = { [weak self, weak connection] state in + guard let connection else { return } + switch state { + case .failed, .cancelled: + Task { @MainActor [weak self] in self?.remove(connection) } + default: + break + } + } + connection.start(queue: queue) + receiveNext(from: connection) + } + + private func receiveNext(from connection: NWConnection) { + connection.receive(minimumIncompleteLength: 1, maximumLength: 1_048_576) { + [weak self, weak connection] _, _, isComplete, error in + guard let self, let connection else { return } + Task { @MainActor [weak self] in + guard let self else { return } + if error != nil || isComplete { + self.remove(connection) + } else { + self.receiveNext(from: connection) + } + } + } + } + + private func remove(_ connection: NWConnection) { + connections[ObjectIdentifier(connection)] = nil + connection.cancel() + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index e966f57d..50643569 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -500,6 +500,11 @@ final class MacServiceContainer { pluginCatalog: pluginCatalog, languageProviderCatalogSource: languageProviderCatalogSource, languageProviderCatalogSnapshot: languageProviderCatalogSnapshot, + debugLaunchConfigurationResolver: DebugLaunchConfigurationResolver( + fileStorage: fileStorage, + javaTestLaunchResolver: rustCore + ), + javaTestResultServerFactory: { MacJavaTestResultServer() }, debugBreakpointPersistence: debugBreakpointStore, workspaceOperations: workspaceOperations, documentLifecycleDecider: RustDocumentLifecycleDecider(core: rustCore), diff --git a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacJDTLSLaunchResourceResolver.swift b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacJDTLSLaunchResourceResolver.swift index 68ed0d13..fb2f9059 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacJDTLSLaunchResourceResolver.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacJDTLSLaunchResourceResolver.swift @@ -11,6 +11,7 @@ struct MacJDTLSLaunchResourceResolver { private static let equinoxLauncherPrefix = "org.eclipse.equinox.launcher_" private static let javaDebugBundlePrefix = "com.microsoft.java.debug.plugin-" private static let javaTestBundlePrefix = "com.microsoft.java.test.plugin-" + private static let javaTestRunnerName = "com.microsoft.java.test.runner-jar-with-dependencies.jar" private let bundledJdtlsRootURL: URL? private let fileManager: FileManager @@ -47,12 +48,16 @@ struct MacJDTLSLaunchResourceResolver { let javaTestBundleURLs = try javaTestExtensionBundles( in: rootURL.appendingPathComponent("java-test/extensions", isDirectory: true) ) + let javaTestRunnerURL = rootURL + .appendingPathComponent("java-test/runner", isDirectory: true) + .appendingPathComponent(Self.javaTestRunnerName) guard let launcherURL = try firstEquinoxLauncher(in: pluginsURL), let configurationURL, let javaDebugURL, javaTestBundleURLs.contains(where: { $0.lastPathComponent.hasPrefix(Self.javaTestBundlePrefix) }), + fileManager.fileExists(atPath: javaTestRunnerURL.path), fileManager.fileExists(atPath: lombokURL.path) else { continue } @@ -61,7 +66,8 @@ struct MacJDTLSLaunchResourceResolver { configurationDirectoryURL: configurationURL, lombokAgentURL: lombokURL, javaDebugBundleURL: javaDebugURL, - javaExtensionBundleURLs: javaTestBundleURLs + javaExtensionBundleURLs: javaTestBundleURLs, + javaTestRunnerURL: javaTestRunnerURL ) } throw ResolutionError.incompleteInstallation @@ -164,7 +170,7 @@ struct MacJDTLSLaunchResourceResolver { var errorDescription: String? { "Expected an Equinox launcher JAR, a macOS configuration directory, " - + "lombok/lombok.jar, and the Java Debug and Java Test extension bundles " + + "lombok/lombok.jar, Java Debug and Java Test extension bundles, and the TestNG runner " + "in the selected JDTLS installation." } } diff --git a/macos/Sources/Lithe/Services/Debug/DebugLaunchConfigurationResolver.swift b/macos/Sources/Lithe/Services/Debug/DebugLaunchConfigurationResolver.swift index a27348f5..b8234b06 100644 --- a/macos/Sources/Lithe/Services/Debug/DebugLaunchConfigurationResolver.swift +++ b/macos/Sources/Lithe/Services/Debug/DebugLaunchConfigurationResolver.swift @@ -33,21 +33,40 @@ enum DebugLaunchConfigurationResolutionError: LocalizedError, Equatable { struct DebugLaunchConfigurationResolver { private let fileExists: (URL) -> Bool private let executableSuffix: String + private let javaTestLaunchResolver: (any JavaTestDebugLaunchResolving)? init( fileStorage: any FileStorage, - executableSuffix: String = "" + executableSuffix: String = "", + javaTestLaunchResolver: (any JavaTestDebugLaunchResolving)? = nil ) { self.fileExists = { fileStorage.fileExists(at: $0) } self.executableSuffix = executableSuffix + self.javaTestLaunchResolver = javaTestLaunchResolver } init( executableSuffix: String = "", - fileExists: @escaping (URL) -> Bool + fileExists: @escaping (URL) -> Bool, + javaTestLaunchResolver: (any JavaTestDebugLaunchResolving)? = nil ) { self.fileExists = fileExists self.executableSuffix = executableSuffix + self.javaTestLaunchResolver = javaTestLaunchResolver + } + + @MainActor + func resolveJavaTest( + target: JavaTestDebugLaunchTarget, + resultPort: UInt16 + ) throws -> DebugLaunchConfiguration { + guard let javaTestLaunchResolver else { + throw DebugLaunchConfigurationResolutionError.javaLaunchTargetUnavailable + } + return try javaTestLaunchResolver.resolveJavaTestDebugLaunch( + target: target, + resultPort: resultPort + ) } func resolve( diff --git a/macos/Sources/Lithe/Services/Debug/JavaTestDebugLaunchService.swift b/macos/Sources/Lithe/Services/Debug/JavaTestDebugLaunchService.swift new file mode 100644 index 00000000..a483e2ab --- /dev/null +++ b/macos/Sources/Lithe/Services/Debug/JavaTestDebugLaunchService.swift @@ -0,0 +1,62 @@ +import Foundation +import LitheCoreContracts + +/// Prepared Java test launch plus the short-lived result channel it owns. +@MainActor +struct PreparedJavaTestDebugLaunch { + let target: JavaTestDebugLaunchTarget + let configuration: DebugLaunchConfiguration + let resultServer: any JavaTestResultServing + + func stop() { + resultServer.stop() + } +} + +/// Coordinates Java test target resolution with the native result listener and +/// shared Rust launch configuration without owning UI or Debug Adapter state. +@MainActor +final class JavaTestDebugLaunchService { + private let configurationResolver: DebugLaunchConfigurationResolver + private let resultServerFactory: @MainActor () -> any JavaTestResultServing + + init( + configurationResolver: DebugLaunchConfigurationResolver, + resultServerFactory: @escaping @MainActor () -> any JavaTestResultServing + ) { + self.configurationResolver = configurationResolver + self.resultServerFactory = resultServerFactory + } + + func prepare( + fileURL: URL, + testIdentifier: String?, + rootURL: URL, + targetResolver: any JavaTestDebugLaunchTargetResolving + ) async throws -> PreparedJavaTestDebugLaunch { + let target = try await targetResolver.resolveJavaTestDebugLaunchTarget( + fileURL: fileURL, + testIdentifier: testIdentifier, + rootURL: rootURL + ) + try Task.checkCancellation() + + let resultServer = resultServerFactory() + do { + let resultPort = try await resultServer.start() + try Task.checkCancellation() + let configuration = try configurationResolver.resolveJavaTest( + target: target, + resultPort: resultPort + ) + return PreparedJavaTestDebugLaunch( + target: target, + configuration: configuration, + resultServer: resultServer + ) + } catch { + resultServer.stop() + throw error + } + } +} diff --git a/macos/Sources/Lithe/Views/Language/LanguageTestsView.swift b/macos/Sources/Lithe/Views/Language/LanguageTestsView.swift index 6a126e80..1d6f8460 100644 --- a/macos/Sources/Lithe/Views/Language/LanguageTestsView.swift +++ b/macos/Sources/Lithe/Views/Language/LanguageTestsView.swift @@ -139,7 +139,9 @@ struct LanguageTestsView: View { } private var testCount: Int { - service.itemsByProviderID.values.reduce(0) { $0 + $1.count } + service.itemsByProviderID.values.reduce(0) { count, items in + count + itemCount(items) + } } private var selectedItem: LanguageTestItem? { @@ -206,7 +208,7 @@ struct LanguageTestsView: View { Text(descriptor.displayName) .lineLimit(1) Spacer(minLength: 0) - Text(String(items.count)) + Text(String(itemCount(items))) .font(.system(size: 10)) .foregroundStyle(LitheTheme.secondaryText) } @@ -233,16 +235,16 @@ struct LanguageTestsView: View { selectedItemID = item.id } label: { HStack(spacing: 7) { - Image(systemName: item.kind == .workspace ? "square.stack.3d.up" : "doc.text.magnifyingglass") + Image(systemName: testItemIcon(item)) .font(.system(size: 11)) - .foregroundStyle(item.kind == .workspace ? LitheTheme.accent : LitheTheme.secondaryText) + .foregroundStyle(item.depth > 0 ? LitheTheme.accent : LitheTheme.secondaryText) .frame(width: 16) Text(item.label) .font(.system(size: 11.5)) .lineLimit(1) Spacer(minLength: 0) } - .padding(.leading, 25) + .padding(.leading, 25 + CGFloat(item.depth * 14)) .padding(.trailing, 6) .frame(height: 26) .contentShape(Rectangle()) @@ -301,7 +303,7 @@ struct LanguageTestsView: View { private func testDetail(_ item: LanguageTestItem) -> some View { VStack(alignment: .leading, spacing: 12) { HStack(spacing: 10) { - Image(systemName: item.kind == .workspace ? "square.stack.3d.up" : "doc.text.magnifyingglass") + Image(systemName: testItemIcon(item)) .font(.system(size: 20)) .foregroundStyle(LitheTheme.accent) .frame(width: 34, height: 34) @@ -324,6 +326,17 @@ struct LanguageTestsView: View { .buttonStyle(.borderedProminent) .controlSize(.small) .disabled(service.isRunning) + + if item.providerID == "java", item.kind != .workspace { + Button { + model.debugTest(providerID: item.providerID, scope: scope(for: item)) + } label: { + Label("Debug", systemImage: "ladybug.fill") + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(service.isRunning) + } } Rectangle() @@ -335,7 +348,7 @@ struct LanguageTestsView: View { .font(.system(size: 10.5, weight: .medium)) .foregroundStyle(LitheTheme.secondaryText) .frame(width: 90, alignment: .trailing) - Text(item.kind == .workspace ? "Workspace" : (item.fileURL?.path ?? item.label)) + Text(scopeDescription(item)) .font(.system(size: 11.5, design: .monospaced)) .textSelection(.enabled) .lineLimit(1) @@ -368,7 +381,34 @@ struct LanguageTestsView: View { case .file: return .file(item.fileURL ?? model.workspaceURL ?? URL(fileURLWithPath: ".")) case .testCase: - return .testCase(identifier: item.label, fileURL: item.fileURL) + return .testCase( + identifier: item.testIdentifier ?? item.label, + fileURL: item.fileURL + ) + } + } + + private func itemCount(_ items: [LanguageTestItem]) -> Int { + let exactTests = items.filter { $0.kind == .testCase }.count + return exactTests > 0 ? exactTests : items.filter { $0.kind != .workspace }.count + } + + private func testItemIcon(_ item: LanguageTestItem) -> String { + switch item.kind { + case .workspace: "square.stack.3d.up" + case .file: "doc.text.magnifyingglass" + case .testCase: item.depth > 1 ? "function" : "cube" + } + } + + private func scopeDescription(_ item: LanguageTestItem) -> String { + switch item.kind { + case .workspace: + return "Workspace" + case .file: + return item.fileURL?.path ?? item.label + case .testCase: + return item.testIdentifier ?? item.label } } diff --git a/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift b/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift index 2e23e590..f6ee8e38 100644 --- a/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift +++ b/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift @@ -120,6 +120,66 @@ public struct JavaDebugLaunchTarget: Equatable, Sendable { } } +/// Java test runner family reported by the JDT LS Java Test extension. +public enum JavaTestDebugFramework: String, Codable, Equatable, Sendable { + case junit + case testng +} + +/// JDT LS-owned metadata required to launch one Java test selection through DAP. +public struct JavaTestDebugLaunchTarget: Equatable, Sendable { + public let fileURL: URL + public let name: String + public let framework: JavaTestDebugFramework + public let workingDirectory: String + public let mainClass: String + public let projectName: String? + public let classPaths: [String] + public let modulePaths: [String] + public let vmArguments: [String] + public let programArguments: [String] + public let testNGRunnerPath: String? + public let testNGTestNames: [String] + + public init( + fileURL: URL, + name: String, + framework: JavaTestDebugFramework, + workingDirectory: String, + mainClass: String, + projectName: String?, + classPaths: [String], + modulePaths: [String], + vmArguments: [String], + programArguments: [String], + testNGRunnerPath: String? = nil, + testNGTestNames: [String] = [] + ) { + self.fileURL = fileURL.standardizedFileURL + self.name = name + self.framework = framework + self.workingDirectory = workingDirectory + self.mainClass = mainClass + self.projectName = projectName + self.classPaths = classPaths + self.modulePaths = modulePaths + self.vmArguments = vmArguments + self.programArguments = programArguments + self.testNGRunnerPath = testNGRunnerPath + self.testNGTestNames = testNGTestNames + } +} + +/// Resolves a Java test selection through the active language-service project model. +@MainActor +public protocol JavaTestDebugLaunchTargetResolving: AnyObject { + func resolveJavaTestDebugLaunchTarget( + fileURL: URL, + testIdentifier: String?, + rootURL: URL + ) async throws -> JavaTestDebugLaunchTarget +} + public struct DebugSourceBreakpoint: Codable, Hashable, Sendable { public let line: Int public let column: Int? diff --git a/macos/Sources/LitheCoreContracts/Debug/DebugProtocolCore.swift b/macos/Sources/LitheCoreContracts/Debug/DebugProtocolCore.swift index 8fe37b6b..823d6c7b 100644 --- a/macos/Sources/LitheCoreContracts/Debug/DebugProtocolCore.swift +++ b/macos/Sources/LitheCoreContracts/Debug/DebugProtocolCore.swift @@ -249,6 +249,15 @@ public protocol DebugSteppingFilterResolving: Sendable { ) throws -> DebugSteppingFilters } +/// Focused shared-Core boundary for deterministic Java test launch arguments. +@MainActor +public protocol JavaTestDebugLaunchResolving: Sendable { + func resolveJavaTestDebugLaunch( + target: JavaTestDebugLaunchTarget, + resultPort: UInt16 + ) throws -> DebugLaunchConfiguration +} + /// Transport-neutral Debug Core boundary. Native products own processes and /// sockets; this contract owns DAP framing, state, sequencing, and normalized data. @MainActor diff --git a/macos/Sources/LitheCoreContracts/Execution/ExecutionContracts.swift b/macos/Sources/LitheCoreContracts/Execution/ExecutionContracts.swift index b0efa306..1e6ff2db 100644 --- a/macos/Sources/LitheCoreContracts/Execution/ExecutionContracts.swift +++ b/macos/Sources/LitheCoreContracts/Execution/ExecutionContracts.swift @@ -221,13 +221,27 @@ package struct LanguageTestItem: Identifiable, Equatable, Sendable { package let label: String package let kind: LanguageTestItemKind package let fileURL: URL? + /// Stable provider identifier used to run or debug this exact test item. + package let testIdentifier: String? + /// Visual nesting below the provider section; source files start at zero. + package let depth: Int - package init(id: String, providerID: String, label: String, kind: LanguageTestItemKind, fileURL: URL?) { + package init( + id: String, + providerID: String, + label: String, + kind: LanguageTestItemKind, + fileURL: URL?, + testIdentifier: String? = nil, + depth: Int = 0 + ) { self.id = id self.providerID = providerID self.label = label self.kind = kind self.fileURL = fileURL + self.testIdentifier = testIdentifier + self.depth = max(0, depth) } } diff --git a/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift b/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift index 594c08d2..2e3e7b2b 100644 --- a/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift +++ b/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift @@ -37,13 +37,16 @@ package struct JDTLSLaunchResources: Equatable, Sendable { /// Ordered OSGi bundles contributed by Java tooling extensions. The Java /// Debug Server remains first for compatibility with older Rust cores. package let javaExtensionBundleURLs: [URL] + /// Standalone TestNG runner used only when a TestNG debug launch is requested. + package let javaTestRunnerURL: URL? package init( launcherJarURL: URL, configurationDirectoryURL: URL, lombokAgentURL: URL, javaDebugBundleURL: URL? = nil, - javaExtensionBundleURLs: [URL] = [] + javaExtensionBundleURLs: [URL] = [], + javaTestRunnerURL: URL? = nil ) { self.launcherJarURL = launcherJarURL.standardizedFileURL self.configurationDirectoryURL = configurationDirectoryURL.standardizedFileURL @@ -52,6 +55,7 @@ package struct JDTLSLaunchResources: Equatable, Sendable { self.javaExtensionBundleURLs = ([javaDebugBundleURL].compactMap { $0 } + javaExtensionBundleURLs) .map(\.standardizedFileURL) .filter { seen.insert($0.path).inserted } + self.javaTestRunnerURL = javaTestRunnerURL?.standardizedFileURL } package var javaDebugBundleURL: URL? { diff --git a/macos/Sources/LitheCoreContracts/Language/LanguageToolingContracts.swift b/macos/Sources/LitheCoreContracts/Language/LanguageToolingContracts.swift index 4df6c765..2f9c8b4e 100644 --- a/macos/Sources/LitheCoreContracts/Language/LanguageToolingContracts.swift +++ b/macos/Sources/LitheCoreContracts/Language/LanguageToolingContracts.swift @@ -526,6 +526,8 @@ package struct LanguageServerCodeAction: Identifiable, Equatable, Sendable { @MainActor package protocol LanguageServerSession: AnyObject { var isRunning: Bool { get } + /// Packaged Java Test runner, if this JDT LS session was launched with one. + var javaTestRunnerURL: URL? { get } var onDiagnostics: ((URL, [LanguageServerDiagnostic]) -> Void)? { get set } var onLog: ((LanguageServerLogLevel, String, String?, String?) -> Void)? { get set } var onStateChange: ((LanguageServerSessionState) -> Void)? { get set } @@ -611,6 +613,7 @@ package protocol LanguageServerSession: AnyObject { } package extension LanguageServerSession { + var javaTestRunnerURL: URL? { nil } var features: LanguageServerFeatureSet { [] } var onFeaturesChange: ((LanguageServerFeatureSet) -> Void)? { get { nil } diff --git a/macos/Sources/LitheExecutionModule/Services/LanguageTestService.swift b/macos/Sources/LitheExecutionModule/Services/LanguageTestService.swift index 724ff4e1..6d90e9d8 100644 --- a/macos/Sources/LitheExecutionModule/Services/LanguageTestService.swift +++ b/macos/Sources/LitheExecutionModule/Services/LanguageTestService.swift @@ -101,6 +101,17 @@ package final class LanguageTestService: ObservableObject { itemsByProviderID = discovered } + package func replaceDiscoveredItems( + _ items: [LanguageTestItem], + providerID: String + ) { + if items.isEmpty { + itemsByProviderID[providerID] = nil + } else { + itemsByProviderID[providerID] = items + } + } + @discardableResult package func run( providerID: String, diff --git a/macos/Sources/LitheExecutionModule/Services/StandardLanguageTestProvider.swift b/macos/Sources/LitheExecutionModule/Services/StandardLanguageTestProvider.swift index 9eb6ae4d..fc09df5f 100644 --- a/macos/Sources/LitheExecutionModule/Services/StandardLanguageTestProvider.swift +++ b/macos/Sources/LitheExecutionModule/Services/StandardLanguageTestProvider.swift @@ -200,7 +200,7 @@ package struct StandardLanguageTestProvider: LanguageTestProvider { case .file(let url): return ["-Dtest=" + url.deletingPathExtension().lastPathComponent, "test"] case .testCase(let identifier, _): - return ["-Dtest=" + identifier, "test"] + return ["-Dtest=" + normalizedJavaTestIdentifier(identifier), "test"] } } @@ -212,11 +212,18 @@ package struct StandardLanguageTestProvider: LanguageTestProvider { case .file(let url): arguments.append(contentsOf: ["--tests", try gradleSelector(for: url, root: root)]) case .testCase(let identifier, _): - arguments.append(contentsOf: ["--tests", identifier]) + arguments.append(contentsOf: [ + "--tests", + normalizedJavaTestIdentifier(identifier).replacingOccurrences(of: "#", with: "."), + ]) } return arguments } + private func normalizedJavaTestIdentifier(_ identifier: String) -> String { + identifier.hasSuffix("()") ? String(identifier.dropLast(2)) : identifier + } + private func gradleSelector(for url: URL, root: URL) throws -> String { _ = try checkedRelativePath(url, root: root) return url.deletingPathExtension().lastPathComponent diff --git a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift index ec155fdd..ad1445e5 100644 --- a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift +++ b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift @@ -45,6 +45,7 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { package var onFeaturesChange: ((LanguageServerFeatureSet) -> Void)? package private(set) var serverInfo: LanguageServerInfo? package var onServerInfoChange: ((LanguageServerInfo?) -> Void)? + package var javaTestRunnerURL: URL? { jdtlsLaunchResources?.javaTestRunnerURL } package init( providerID: String, diff --git a/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift b/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift index dfe44077..6f4ad7d9 100644 --- a/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift +++ b/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift @@ -29,7 +29,9 @@ package enum LanguageToolingSessionError: LocalizedError, Equatable, Sendable { /// UI-facing façade that routes language features across active LSP sessions /// and lightweight local providers without exposing either implementation. @MainActor -package final class LanguageToolingSessionManager: ObservableObject { +package final class LanguageToolingSessionManager: ObservableObject, + JavaTestDebugLaunchTargetResolving +{ @Published package private(set) var diagnostics: [URL: [LanguageServerDiagnostic]] = [:] @Published package private(set) var languageServerFeatures: [String: LanguageServerFeatureSet] = [:] @Published package private(set) var languageServerLogs: [LanguageServerLogEntry] = [] @@ -360,6 +362,145 @@ package final class LanguageToolingSessionManager: ObservableObject { ) } + /// Resolves one Java source file or discovered test item through the Java + /// Test extension and returns the metadata required by shared Debug Core. + package func resolveJavaTestDebugLaunchTarget( + fileURL: URL, + testIdentifier: String? = nil, + rootURL: URL + ) async throws -> JavaTestDebugLaunchTarget { + let normalizedFile = fileURL.standardizedFileURL + let normalizedRoot = rootURL.standardizedFileURL + let discovered = try await resolvedJavaTestItems( + fileURL: normalizedFile, + rootURL: normalizedRoot + ) + let selected: [ResolvedJavaTestItem] + if let testIdentifier, !testIdentifier.isEmpty { + selected = Self.flattenJavaTestItems(discovered).filter { + $0.matches(identifier: testIdentifier) + } + } else { + selected = discovered.filter { $0.level == 5 } + } + guard !selected.isEmpty else { + let target = testIdentifier ?? normalizedFile.lastPathComponent + throw LanguageToolingSessionError.toolingUnavailable( + "No Java test was found for \(target)." + ) + } + guard Set(selected.map(\.projectName)).count == 1, + Set(selected.map(\.kind)).count == 1, + Set(selected.map(\.level)).count == 1, + let projectName = selected.first?.projectName, + let kind = selected.first?.kind, + let level = selected.first?.level else { + throw LanguageToolingSessionError.toolingUnavailable( + "Debug one Java test framework and project at a time." + ) + } + let framework: JavaTestDebugFramework + switch kind { + case 0, 1: framework = .junit + case 2: framework = .testng + default: + throw LanguageToolingSessionError.toolingUnavailable( + "The selected Java test framework is not supported." + ) + } + let launchTestNames: [String] + if framework == .junit, level == 6 { + launchTestNames = selected.compactMap(\.jdtHandler) + } else { + launchTestNames = selected.map(\.fullName) + } + guard launchTestNames.count == selected.count else { + throw LanguageToolingSessionError.toolingUnavailable( + "The Java language service returned incomplete test identifiers." + ) + } + let launchRequest = ToolingJSONValue.object([ + "projectName": .string(projectName), + "testLevel": .integer(level), + "testKind": .integer(kind), + "testNames": .array(launchTestNames.map(ToolingJSONValue.string)), + ]) + let requestData = try JSONSerialization.data( + withJSONObject: launchRequest.foundationObject, + options: [.sortedKeys] + ) + guard let requestJSON = String(data: requestData, encoding: .utf8) else { + throw LanguageToolingSessionError.toolingUnavailable( + "Could not encode the Java test launch request." + ) + } + let launchValue = try await executeJavaTestCommand( + "vscode.java.test.junit.argument", + arguments: [.string(requestJSON)], + rootURL: normalizedRoot + ) + let launch = try Self.javaTestLaunchArguments(launchValue) + let testNGTestNames = framework == .testng + ? selected.flatMap(Self.javaTestNGMethodNames) + : [] + let testNGRunnerPath: String? + let mainClass: String + if framework == .testng { + guard let runnerURL = languageServers["java"]?.javaTestRunnerURL else { + throw LanguageToolingSessionError.toolingUnavailable( + "The packaged Java TestNG runner is unavailable. Reinstall Lithe." + ) + } + guard !testNGTestNames.isEmpty else { + throw LanguageToolingSessionError.toolingUnavailable( + "No TestNG test method was found in \(normalizedFile.lastPathComponent)." + ) + } + testNGRunnerPath = runnerURL.standardizedFileURL.path + mainClass = "com.microsoft.java.test.runner.Launcher" + } else { + guard let resolvedMainClass = launch.mainClass else { + throw LanguageToolingSessionError.toolingUnavailable( + "The Java language service returned no JUnit runner main class." + ) + } + testNGRunnerPath = nil + mainClass = resolvedMainClass + } + return JavaTestDebugLaunchTarget( + fileURL: normalizedFile, + name: selected.count == 1 ? selected[0].label : normalizedFile.lastPathComponent, + framework: framework, + workingDirectory: launch.workingDirectory, + mainClass: mainClass, + projectName: launch.projectName, + classPaths: launch.classPaths, + modulePaths: launch.modulePaths, + vmArguments: launch.vmArguments, + programArguments: launch.programArguments, + testNGRunnerPath: testNGRunnerPath, + testNGTestNames: testNGTestNames + ) + } + + /// Discovers the Java test classes and methods in one source file for the + /// native Tests tree. This reuses the same identifiers accepted by Debug. + package func discoverJavaTestItems( + fileURL: URL, + rootURL: URL + ) async throws -> [LanguageTestItem] { + let normalizedFile = fileURL.standardizedFileURL + let discovered = try await resolvedJavaTestItems( + fileURL: normalizedFile, + rootURL: rootURL.standardizedFileURL + ) + return Self.projectJavaTestItems( + discovered, + fileURL: normalizedFile, + depth: 1 + ) + } + private func executeJavaCommand( _ commandID: String, arguments: [ToolingJSONValue], @@ -385,6 +526,33 @@ package final class LanguageToolingSessionManager: ObservableObject { } } + private func executeJavaTestCommand( + _ commandID: String, + arguments: [ToolingJSONValue], + rootURL: URL + ) async throws -> ToolingJSONValue { + try await executeJavaCommand(commandID, arguments: arguments, rootURL: rootURL) + } + + private func resolvedJavaTestItems( + fileURL: URL, + rootURL: URL + ) async throws -> [ResolvedJavaTestItem] { + _ = try startLanguageServer(providerID: "java", rootURL: rootURL) + try await waitUntilLanguageServerReady(providerID: "java", rootURL: rootURL) + let discoveredValue = try await executeJavaTestCommand( + "vscode.java.test.findTestTypesAndMethods", + arguments: [.string(fileURL.absoluteString)], + rootURL: rootURL + ) + guard case .array(let values) = discoveredValue else { + throw LanguageToolingSessionError.toolingUnavailable( + "The Java language service returned invalid test metadata." + ) + } + return values.compactMap(Self.javaTestItem) + } + private static func javaDebugLaunchTarget( _ value: ToolingJSONValue ) -> ResolvedJavaDebugLaunchTarget? { @@ -417,6 +585,136 @@ package final class LanguageToolingSessionManager: ObservableObject { } } + private static func javaTestItem(_ value: ToolingJSONValue) -> ResolvedJavaTestItem? { + guard case .object(let object) = value, + case .string(let id)? = object["id"], + case .string(let label)? = object["label"], + case .string(let fullName)? = object["fullName"], + case .string(let projectName)? = object["projectName"], + let kind = integerValue(object["testKind"]), + let level = integerValue(object["testLevel"]) else { return nil } + let jdtHandler: String? + if case .string(let value)? = object["jdtHandler"], !value.isEmpty { + jdtHandler = value + } else { + jdtHandler = nil + } + let children: [ResolvedJavaTestItem] + if case .array(let values)? = object["children"] { + children = values.compactMap(javaTestItem) + } else { + children = [] + } + let sortText: String? + if case .string(let value)? = object["sortText"], !value.isEmpty { + sortText = value + } else { + sortText = nil + } + return ResolvedJavaTestItem( + id: id, + label: label, + fullName: fullName, + projectName: projectName, + kind: kind, + level: level, + jdtHandler: jdtHandler, + sortText: sortText, + children: children + ) + } + + private static func integerValue(_ value: ToolingJSONValue?) -> Int? { + switch value { + case .integer(let value): value + case .string(let value): Int(value) + default: nil + } + } + + private static func flattenJavaTestItems( + _ items: [ResolvedJavaTestItem] + ) -> [ResolvedJavaTestItem] { + items.flatMap { [$0] + flattenJavaTestItems($0.children) } + } + + private static func projectJavaTestItems( + _ items: [ResolvedJavaTestItem], + fileURL: URL, + depth: Int + ) -> [LanguageTestItem] { + sortedJavaTestItems(items).flatMap { item in + [LanguageTestItem( + id: item.id, + providerID: "java", + label: item.label, + kind: .testCase, + fileURL: fileURL, + testIdentifier: item.fullName, + depth: depth + )] + projectJavaTestItems( + item.children, + fileURL: fileURL, + depth: depth + 1 + ) + } + } + + private static func sortedJavaTestItems( + _ items: [ResolvedJavaTestItem] + ) -> [ResolvedJavaTestItem] { + items.sorted { + ($0.sortText ?? $0.label, $0.label, $0.id) + < ($1.sortText ?? $1.label, $1.label, $1.id) + } + } + + private static func javaTestNGMethodNames(_ item: ResolvedJavaTestItem) -> [String] { + if item.level == 6 { return [item.fullName] } + return item.children.flatMap(javaTestNGMethodNames) + } + + private static func javaTestLaunchArguments( + _ value: ToolingJSONValue + ) throws -> ResolvedJavaTestLaunchArguments { + guard case .object(let response) = value else { + throw LanguageToolingSessionError.toolingUnavailable( + "The Java language service returned invalid test launch arguments." + ) + } + if case .string(let message)? = response["errorMessage"], !message.isEmpty { + throw LanguageToolingSessionError.toolingUnavailable(message) + } + guard case .object(let body)? = response["body"], + case .string(let workingDirectory)? = body["workingDirectory"], + !workingDirectory.isEmpty else { + throw LanguageToolingSessionError.toolingUnavailable( + "The Java language service returned incomplete test launch arguments." + ) + } + let mainClass: String? + if case .string(let value)? = body["mainClass"], !value.isEmpty { + mainClass = value + } else { + mainClass = nil + } + let projectName: String? + if case .string(let value)? = body["projectName"], !value.isEmpty { + projectName = value + } else { + projectName = nil + } + return ResolvedJavaTestLaunchArguments( + workingDirectory: workingDirectory, + mainClass: mainClass, + projectName: projectName, + classPaths: stringValues(body["classpath"] ?? .array([])), + modulePaths: stringValues(body["modulepath"] ?? .array([])), + vmArguments: stringValues(body["vmArguments"] ?? .array([])), + programArguments: stringValues(body["programArguments"] ?? .array([])) + ) + } + package func notifyWorkspaceFilesChanged( providerID: String, changes: [LanguageServerWorkspaceFileChange] @@ -1423,6 +1721,35 @@ package final class LanguageToolingSessionManager: ObservableObject { let filePath: String? } + private struct ResolvedJavaTestItem { + let id: String + let label: String + let fullName: String + let projectName: String + let kind: Int + let level: Int + let jdtHandler: String? + let sortText: String? + let children: [ResolvedJavaTestItem] + + func matches(identifier: String) -> Bool { + id == identifier + || label == identifier + || fullName == identifier + || jdtHandler == identifier + } + } + + private struct ResolvedJavaTestLaunchArguments { + let workingDirectory: String + let mainClass: String? + let projectName: String? + let classPaths: [String] + let modulePaths: [String] + let vmArguments: [String] + let programArguments: [String] + } + private func replaceDiagnostics( _ updatedDiagnostics: [LanguageServerDiagnostic], for fileURL: URL, diff --git a/macos/Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift b/macos/Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift index 52b4a676..8c45b469 100644 --- a/macos/Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift +++ b/macos/Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift @@ -207,10 +207,10 @@ struct LanguageIntelligenceModuleTests { let task = Task { try await manager.startJavaDebugServer(rootURL: root) } defer { task.cancel() } - await session.waitUntilStarted() + try await session.waitUntilStarted() #expect(session.executedCommands.isEmpty) session.publish(.ready) - let command = await session.waitForExecuteCommand() + let command = try await session.waitForExecuteCommand() #expect(command.command == "vscode.java.startDebugSession") #expect(command.arguments.isEmpty) session.completeExecuteReturningValue(.success(.integer(5005))) @@ -238,9 +238,9 @@ struct LanguageIntelligenceModuleTests { } defer { task.cancel() } - await session.waitUntilStarted() + try await session.waitUntilStarted() session.publish(.ready) - let command = await session.waitForExecuteCommand() + let command = try await session.waitForExecuteCommand() #expect(command.command == "vscode.java.resolveMainClass") #expect(command.arguments.isEmpty) session.completeExecuteReturningValue(.success(.array([ @@ -256,7 +256,7 @@ struct LanguageIntelligenceModuleTests { ]), ]))) - let classpathCommand = await session.waitForExecuteCommand(number: 2) + let classpathCommand = try await session.waitForExecuteCommand(number: 2) #expect(classpathCommand.command == "vscode.java.resolveClasspath") #expect(classpathCommand.arguments == [ .string("service/example.Main"), @@ -276,6 +276,277 @@ struct LanguageIntelligenceModuleTests { )) } + @Test + func javaTestDiscoveryProjectsSortedClassesAndMethodsForTheTestsTree() async throws { + let root = URL(fileURLWithPath: "/workspace/java-tests", isDirectory: true) + let source = root.appendingPathComponent( + "service/src/test/java/example/UserServiceTest.java" + ) + let descriptor = try #require( + LanguageProviderCatalog.compatibilityFallback.provider(for: source) + ) + let session = WorkspaceStateLanguageServerSession() + let manager = LanguageToolingSessionManager( + catalog: .compatibilityFallback, + runtimes: [WorkspaceStateLanguageProviderRuntime( + descriptor: descriptor, + session: session + )] + ) + let task = Task { + try await manager.discoverJavaTestItems(fileURL: source, rootURL: root) + } + defer { task.cancel() } + + try await session.waitUntilStarted() + session.publish(.ready) + let discovery = try await session.waitForExecuteCommand() + #expect(discovery.command == "vscode.java.test.findTestTypesAndMethods") + #expect(discovery.arguments == [.string(source.standardizedFileURL.absoluteString)]) + session.completeExecuteReturningValue(.success(.array([ + .object([ + "id": .string("service@example.UserServiceTest"), + "label": .string("UserServiceTest"), + "fullName": .string("example.UserServiceTest"), + "projectName": .string("service"), + "testKind": .integer(0), + "testLevel": .integer(5), + "jdtHandler": .string("class-handler"), + "sortText": .string("002"), + "children": .array([ + .object([ + "id": .string("service@example.UserServiceTest#logsOut"), + "label": .string("logsOut()"), + "fullName": .string("example.UserServiceTest#logsOut"), + "projectName": .string("service"), + "testKind": .integer(0), + "testLevel": .integer(6), + "jdtHandler": .string("logout-handler"), + "sortText": .string("002"), + "children": .array([]), + ]), + .object([ + "id": .string("service@example.UserServiceTest#logsIn"), + "label": .string("logsIn()"), + "fullName": .string("example.UserServiceTest#logsIn"), + "projectName": .string("service"), + "testKind": .integer(0), + "testLevel": .integer(6), + "jdtHandler": .string("login-handler"), + "sortText": .string("001"), + "children": .array([]), + ]), + ]), + ]), + .object([ + "id": .string("service@example.AccountTest"), + "label": .string("AccountTest"), + "fullName": .string("example.AccountTest"), + "projectName": .string("service"), + "testKind": .integer(0), + "testLevel": .integer(5), + "jdtHandler": .string("account-handler"), + "sortText": .string("001"), + "children": .array([]), + ]), + ]))) + + let items = try await task.value + #expect(items.map(\.label) == [ + "AccountTest", "UserServiceTest", "logsIn()", "logsOut()", + ]) + #expect(items.map(\.depth) == [1, 1, 2, 2]) + #expect(items.map(\.kind) == [.testCase, .testCase, .testCase, .testCase]) + #expect(items.map(\.fileURL) == Array(repeating: source.standardizedFileURL, count: 4)) + #expect(items.map(\.testIdentifier) == [ + "example.AccountTest", + "example.UserServiceTest", + "example.UserServiceTest#logsIn", + "example.UserServiceTest#logsOut", + ]) + } + + @Test + func junitDebugTargetUsesJavaTestDiscoveryAndLaunchArguments() async throws { + let root = URL(fileURLWithPath: "/workspace/java-tests", isDirectory: true) + let source = root.appendingPathComponent( + "service/src/test/java/example/UserServiceTest.java" + ) + let descriptor = try #require( + LanguageProviderCatalog.compatibilityFallback.provider(for: source) + ) + let session = WorkspaceStateLanguageServerSession() + let manager = LanguageToolingSessionManager( + catalog: .compatibilityFallback, + runtimes: [WorkspaceStateLanguageProviderRuntime( + descriptor: descriptor, + session: session + )] + ) + let task = Task { + try await manager.resolveJavaTestDebugLaunchTarget( + fileURL: source, + rootURL: root + ) + } + defer { task.cancel() } + + try await session.waitUntilStarted() + session.publish(.ready) + let discovery = try await session.waitForExecuteCommand() + #expect(discovery.command == "vscode.java.test.findTestTypesAndMethods") + #expect(discovery.arguments == [.string(source.standardizedFileURL.absoluteString)]) + session.completeExecuteReturningValue(.success(.array([ + .object([ + "id": .string("service@example.UserServiceTest"), + "label": .string("UserServiceTest"), + "fullName": .string("example.UserServiceTest"), + "projectName": .string("service"), + "testKind": .integer(0), + "testLevel": .integer(5), + "jdtHandler": .string("=service/src Void)? var onLog: ((LanguageServerLogLevel, String, String?, String?) -> Void)? var onStateChange: ((LanguageServerSessionState) -> Void)? @@ -596,38 +868,98 @@ private final class WorkspaceStateLanguageServerSession: LanguageServerSession { private(set) var stopCallCount = 0 var startError: Error? private(set) var executedCommands: [LanguageServerCommand] = [] - private var startWaiters: [CheckedContinuation] = [] - private var executeWaiters: [( + private var startWaiters: [UUID: CheckedContinuation] = [:] + private var startTimeoutTasks: [UUID: Task] = [:] + private var executeWaiters: [UUID: ( number: Int, - continuation: CheckedContinuation - )] = [] + continuation: CheckedContinuation + )] = [:] + private var executeTimeoutTasks: [UUID: Task] = [:] private var executeValueCompletion: ((Result) -> Void)? func start(rootURL _: URL, workspaceFingerprint: String?) throws { if let startError { throw startError } startedFingerprint = workspaceFingerprint isRunning = true - let waiters = startWaiters - startWaiters = [] - waiters.forEach { $0.resume() } + let waiterIDs = Array(startWaiters.keys) + waiterIDs.forEach { finishStartWaiter($0, result: .success(())) } } func publish(_ state: LanguageServerSessionState) { onStateChange?(state) } - func waitUntilStarted() async { + func waitUntilStarted(timeout: Duration = .seconds(2)) async throws { if isRunning { return } - await withCheckedContinuation { continuation in - startWaiters.append(continuation) + let waiterID = UUID() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + guard !isRunning else { + continuation.resume() + return + } + startWaiters[waiterID] = continuation + let timeoutTask = Task { @MainActor [weak self] in + // test-stability: allow(swift-real-sleep) reason: this watchdog bounds a failed continuation wait while successful synchronization remains event-driven. + try? await Task.sleep(for: timeout) + guard !Task.isCancelled else { return } + self?.finishStartWaiter( + waiterID, + result: .failure(WorkspaceStateSessionError.timedOut) + ) + } + if startWaiters[waiterID] == nil { + timeoutTask.cancel() + } else { + startTimeoutTasks[waiterID] = timeoutTask + } + if Task.isCancelled { + finishStartWaiter(waiterID, result: .failure(CancellationError())) + } + } + } onCancel: { + Task { @MainActor [weak self] in + self?.finishStartWaiter(waiterID, result: .failure(CancellationError())) + } } } - func waitForExecuteCommand(number: Int = 1) async -> LanguageServerCommand { + func waitForExecuteCommand( + number: Int = 1, + timeout: Duration = .seconds(2) + ) async throws -> LanguageServerCommand { precondition(number > 0) if executedCommands.count >= number { return executedCommands[number - 1] } - return await withCheckedContinuation { continuation in - executeWaiters.append((number, continuation)) + let waiterID = UUID() + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + guard executedCommands.count < number else { + continuation.resume(returning: executedCommands[number - 1]) + return + } + executeWaiters[waiterID] = (number, continuation) + let timeoutTask = Task { @MainActor [weak self] in + // test-stability: allow(swift-real-sleep) reason: this watchdog bounds a failed continuation wait while successful synchronization remains event-driven. + try? await Task.sleep(for: timeout) + guard !Task.isCancelled else { return } + self?.finishExecuteWaiter( + waiterID, + result: .failure(WorkspaceStateSessionError.timedOut) + ) + } + if executeWaiters[waiterID] == nil { + timeoutTask.cancel() + } else { + executeTimeoutTasks[waiterID] = timeoutTask + } + if Task.isCancelled { + finishExecuteWaiter(waiterID, result: .failure(CancellationError())) + } + } + } onCancel: { + Task { @MainActor [weak self] in + self?.finishExecuteWaiter(waiterID, result: .failure(CancellationError())) + } } } @@ -721,10 +1053,15 @@ private final class WorkspaceStateLanguageServerSession: LanguageServerSession { ) throws { executedCommands.append(command) executeValueCompletion = completion - let readyWaiters = executeWaiters.filter { $0.number <= executedCommands.count } - executeWaiters.removeAll { $0.number <= executedCommands.count } - readyWaiters.forEach { - $0.continuation.resume(returning: executedCommands[$0.number - 1]) + let readyWaiterIDs = executeWaiters.compactMap { waiterID, waiter in + waiter.number <= executedCommands.count ? waiterID : nil + } + readyWaiterIDs.forEach { waiterID in + guard let waiter = executeWaiters[waiterID] else { return } + finishExecuteWaiter( + waiterID, + result: .success(executedCommands[waiter.number - 1]) + ) } } @@ -738,7 +1075,45 @@ private final class WorkspaceStateLanguageServerSession: LanguageServerSession { func stop() { stopCallCount += 1 isRunning = false + Array(startWaiters.keys).forEach { + finishStartWaiter($0, result: .failure(CancellationError())) + } + Array(executeWaiters.keys).forEach { + finishExecuteWaiter($0, result: .failure(CancellationError())) + } + let completion = executeValueCompletion + executeValueCompletion = nil + completion?(.failure(CancellationError())) + } + + private func finishStartWaiter(_ waiterID: UUID, result: Result) { + let continuation = startWaiters.removeValue(forKey: waiterID) + let timeoutTask = startTimeoutTasks.removeValue(forKey: waiterID) + timeoutTask?.cancel() + continuation?.resume(with: result) + } + + private func finishExecuteWaiter( + _ waiterID: UUID, + result: Result + ) { + let waiter = executeWaiters.removeValue(forKey: waiterID) + let timeoutTask = executeTimeoutTasks.removeValue(forKey: waiterID) + timeoutTask?.cancel() + waiter?.continuation.resume(with: result) + } +} + +private func javaTestLaunchRequest( + from command: LanguageServerCommand +) throws -> [String: Any] { + guard command.arguments.count == 1, + case .string(let value) = command.arguments[0], + let data = value.data(using: .utf8), + let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw WorkspaceStateSessionError.unexpectedOperation } + return object } private enum WorkspaceStateSessionError: LocalizedError { diff --git a/macos/Tests/LitheTests/JavaLanguageServerRuntimeTests.swift b/macos/Tests/LitheTests/JavaLanguageServerRuntimeTests.swift index 67e86d60..68faa2ab 100644 --- a/macos/Tests/LitheTests/JavaLanguageServerRuntimeTests.swift +++ b/macos/Tests/LitheTests/JavaLanguageServerRuntimeTests.swift @@ -96,7 +96,7 @@ struct JavaLanguageServerRuntimeTests { defer { try? fileManager.removeItem(at: root) } for directory in [ "bin", "plugins", "config_mac", "config_mac_arm", "lombok", "java-debug", - "java-test/extensions" + "java-test/extensions", "java-test/runner" ] { try fileManager.createDirectory( at: root.appendingPathComponent(directory, isDirectory: true), @@ -114,7 +114,10 @@ struct JavaLanguageServerRuntimeTests { root.appendingPathComponent("lombok/lombok.jar"), root.appendingPathComponent("java-debug/com.microsoft.java.debug.plugin-0.53.1.jar"), root.appendingPathComponent("java-test/extensions/org.opentest4j_1.2.0.jar"), - root.appendingPathComponent("java-test/extensions/com.microsoft.java.test.plugin-0.42.0.jar") + root.appendingPathComponent("java-test/extensions/com.microsoft.java.test.plugin-0.42.0.jar"), + root.appendingPathComponent( + "java-test/runner/com.microsoft.java.test.runner-jar-with-dependencies.jar" + ) ] { try Data().write(to: file) } @@ -140,6 +143,10 @@ struct JavaLanguageServerRuntimeTests { "com.microsoft.java.test.plugin-0.42.0.jar", "org.opentest4j_1.2.0.jar" ]) + #expect( + resources.javaTestRunnerURL?.lastPathComponent + == "com.microsoft.java.test.runner-jar-with-dependencies.jar" + ) } @Test @@ -168,7 +175,9 @@ struct JavaLanguageServerRuntimeTests { let root = fileManager.temporaryDirectory .appendingPathComponent("lithe-jdtls-architecture-\(UUID().uuidString)", isDirectory: true) defer { try? fileManager.removeItem(at: root) } - for directory in ["bin", "plugins", "lombok", "java-debug", "java-test/extensions"] { + for directory in [ + "bin", "plugins", "lombok", "java-debug", "java-test/extensions", "java-test/runner" + ] { try fileManager.createDirectory( at: root.appendingPathComponent(directory, isDirectory: true), withIntermediateDirectories: true @@ -191,7 +200,10 @@ struct JavaLanguageServerRuntimeTests { root.appendingPathComponent("plugins/org.eclipse.equinox.launcher_1.0.0.jar"), root.appendingPathComponent("lombok/lombok.jar"), root.appendingPathComponent("java-debug/com.microsoft.java.debug.plugin-0.53.1.jar"), - root.appendingPathComponent("java-test/extensions/com.microsoft.java.test.plugin-0.42.0.jar") + root.appendingPathComponent("java-test/extensions/com.microsoft.java.test.plugin-0.42.0.jar"), + root.appendingPathComponent( + "java-test/runner/com.microsoft.java.test.runner-jar-with-dependencies.jar" + ) ] { try Data().write(to: file) } diff --git a/macos/Tests/LitheTests/JavaTestDebugLaunchServiceTests.swift b/macos/Tests/LitheTests/JavaTestDebugLaunchServiceTests.swift new file mode 100644 index 00000000..79c4034d --- /dev/null +++ b/macos/Tests/LitheTests/JavaTestDebugLaunchServiceTests.swift @@ -0,0 +1,257 @@ +import Foundation +import LitheCoreContracts +import LitheExecutionModule +import Testing +@testable import Lithe + +@Suite("Java test debug launch workflow") +@MainActor +struct JavaTestDebugLaunchServiceTests { + @Test + func fileSelectionResolvesTargetStartsResultsAndBuildsSharedConfiguration() async throws { + let root = URL(fileURLWithPath: "/workspace/java-tests", isDirectory: true) + let source = root.appendingPathComponent( + "src/test/java/example/UserServiceTest.java" + ) + let target = javaTestTarget(fileURL: source) + let targetResolver = TestJavaTestTargetResolver(target: target) + let resultServer = TestJavaTestResultServer(port: 43_128) + let expectedConfiguration = DebugLaunchConfiguration( + name: "UserServiceTest", + request: .launch, + arguments: ["mainClass": .string(target.mainClass)] + ) + let core = TestJavaTestLaunchCore(result: .success(expectedConfiguration)) + let service = JavaTestDebugLaunchService( + configurationResolver: DebugLaunchConfigurationResolver( + fileExists: { _ in true }, + javaTestLaunchResolver: core + ), + resultServerFactory: { resultServer } + ) + + let prepared = try await service.prepare( + fileURL: source, + testIdentifier: "service@example.UserServiceTest#logsIn", + rootURL: root, + targetResolver: targetResolver + ) + + #expect(targetResolver.requests == [TestJavaTestTargetResolver.Request( + fileURL: source, + testIdentifier: "service@example.UserServiceTest#logsIn", + rootURL: root + )]) + #expect(resultServer.startCount == 1) + #expect(resultServer.stopCount == 0) + #expect(core.requests == [TestJavaTestLaunchCore.Request( + target: target, + resultPort: 43_128 + )]) + #expect(prepared.target == target) + #expect(prepared.configuration == expectedConfiguration) + + prepared.stop() + #expect(resultServer.stopCount == 1) + } + + @Test + func sharedConfigurationFailureStopsTheResultServer() async { + let source = URL(fileURLWithPath: "/workspace/UserServiceTest.java") + let resultServer = TestJavaTestResultServer(port: 43_128) + let service = JavaTestDebugLaunchService( + configurationResolver: DebugLaunchConfigurationResolver( + fileExists: { _ in true }, + javaTestLaunchResolver: TestJavaTestLaunchCore( + result: .failure(TestJavaTestDebugError.configurationFailed) + ) + ), + resultServerFactory: { resultServer } + ) + + await #expect(throws: TestJavaTestDebugError.configurationFailed) { + _ = try await service.prepare( + fileURL: source, + testIdentifier: nil, + rootURL: source.deletingLastPathComponent(), + targetResolver: TestJavaTestTargetResolver( + target: javaTestTarget(fileURL: source) + ) + ) + } + #expect(resultServer.startCount == 1) + #expect(resultServer.stopCount == 1) + } + + @Test + func workspaceSelectionIsRejectedBeforeStartingAnAsyncLaunch() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-java-test-debug-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = JavaTestDebugStore() + let settings = AppSettings(store: store) + let model = AppModel( + settings: settings, + services: MacServiceContainer( + store: store, + settings: settings, + moduleLaunchMode: .safeMode + ).services + ) + model.openProjectDirectly(root) + model.clearNotifications() + + model.debugTest(providerID: "java", scope: .workspace) + + #expect(model.notificationMessage == "Select a Java test file or test case to debug") + #expect(model.javaTestWorkflowState.debugLaunchTask == nil) + #expect(model.javaTestWorkflowState.debugLaunchOperationID == nil) + } + + @Test + func stoppingDebuggingReleasesTheJavaTestResultServer() { + let store = JavaTestDebugStore() + let settings = AppSettings(store: store) + let model = AppModel( + settings: settings, + services: MacServiceContainer( + store: store, + settings: settings, + moduleLaunchMode: .safeMode + ).services + ) + let resultServer = TestJavaTestResultServer(port: 43_128) + model.javaTestWorkflowState.resultServer = resultServer + + model.stopDebugging() + + #expect(resultServer.stopCount == 1) + #expect(model.javaTestWorkflowState.resultServer == nil) + } + + @Test + func terminalDebugStateReleasesTheJavaTestResultServer() { + let store = JavaTestDebugStore() + let settings = AppSettings(store: store) + let model = AppModel( + settings: settings, + services: MacServiceContainer( + store: store, + settings: settings, + moduleLaunchMode: .safeMode + ).services + ) + let resultServer = TestJavaTestResultServer(port: 43_128) + model.javaTestWorkflowState.resultServer = resultServer + + model.handleDebugSessionStateChange(.running) + #expect(resultServer.stopCount == 0) + #expect(model.javaTestWorkflowState.resultServer != nil) + + model.handleDebugSessionStateChange(.terminated) + #expect(resultServer.stopCount == 1) + #expect(model.javaTestWorkflowState.resultServer == nil) + } + + private func javaTestTarget(fileURL: URL) -> JavaTestDebugLaunchTarget { + JavaTestDebugLaunchTarget( + fileURL: fileURL, + name: "UserServiceTest", + framework: .junit, + workingDirectory: fileURL.deletingLastPathComponent().path, + mainClass: "org.eclipse.jdt.internal.junit.runner.RemoteTestRunner", + projectName: "service", + classPaths: ["/workspace/classes"], + modulePaths: [], + vmArguments: [], + programArguments: ["-port", "-1"] + ) + } +} + +@MainActor +private final class TestJavaTestTargetResolver: JavaTestDebugLaunchTargetResolving { + struct Request: Equatable { + let fileURL: URL + let testIdentifier: String? + let rootURL: URL + } + + private let target: JavaTestDebugLaunchTarget + private(set) var requests: [Request] = [] + + init(target: JavaTestDebugLaunchTarget) { + self.target = target + } + + func resolveJavaTestDebugLaunchTarget( + fileURL: URL, + testIdentifier: String?, + rootURL: URL + ) async throws -> JavaTestDebugLaunchTarget { + requests.append(Request( + fileURL: fileURL.standardizedFileURL, + testIdentifier: testIdentifier, + rootURL: rootURL.standardizedFileURL + )) + return target + } +} + +@MainActor +private final class TestJavaTestResultServer: JavaTestResultServing { + private let port: UInt16 + private(set) var startCount = 0 + private(set) var stopCount = 0 + + init(port: UInt16) { + self.port = port + } + + func start() async throws -> UInt16 { + startCount += 1 + return port + } + + func stop() { + stopCount += 1 + } +} + +@MainActor +private final class TestJavaTestLaunchCore: JavaTestDebugLaunchResolving, @unchecked Sendable { + struct Request: Equatable { + let target: JavaTestDebugLaunchTarget + let resultPort: UInt16 + } + + private let result: Result + private(set) var requests: [Request] = [] + + init(result: Result) { + self.result = result + } + + func resolveJavaTestDebugLaunch( + target: JavaTestDebugLaunchTarget, + resultPort: UInt16 + ) throws -> DebugLaunchConfiguration { + requests.append(Request(target: target, resultPort: resultPort)) + return try result.get() + } +} + +private enum TestJavaTestDebugError: Error, Equatable { + case configurationFailed +} + +private final class JavaTestDebugStore: KeyValueStore, @unchecked Sendable { + private var values: [String: Any] = [:] + + func data(forKey key: String) -> Data? { values[key] as? Data } + func object(forKey key: String) -> Any? { values[key] } + func string(forKey key: String) -> String? { values[key] as? String } + func stringArray(forKey key: String) -> [String]? { values[key] as? [String] } + func set(_ value: Any?, forKey key: String) { values[key] = value } +} diff --git a/macos/Tests/LitheTests/MacJavaTestResultServerTests.swift b/macos/Tests/LitheTests/MacJavaTestResultServerTests.swift new file mode 100644 index 00000000..ce8acbf4 --- /dev/null +++ b/macos/Tests/LitheTests/MacJavaTestResultServerTests.swift @@ -0,0 +1,69 @@ +import Foundation +import Network +import Testing +@testable import Lithe + +@Suite("Java test result server") +@MainActor +struct MacJavaTestResultServerTests { + @Test + func startReturnsTheListenerPortAndStopCancelsIt() async throws { + let listener = TestJavaTestResultListener(readyPort: 43_128) + let server = MacJavaTestResultServer(listenerFactory: { listener }) + + #expect(try await server.start() == 43_128) + #expect(listener.startCount == 1) + + server.stop() + + #expect(listener.cancelCount == 1) + } + + @Test + func cancellingStartupStopsThePendingListener() async { + let started = TestGate() + let listener = TestJavaTestResultListener(onStart: started.open) + let server = MacJavaTestResultServer(listenerFactory: { listener }) + let startTask = Task { try await server.start() } + defer { + startTask.cancel() + server.stop() + } + + #expect(await started.waitUntilOpen()) + startTask.cancel() + + await #expect(throws: CancellationError.self) { + try await startTask.value + } + #expect(listener.cancelCount == 1) + } +} + +private final class TestJavaTestResultListener: MacJavaTestResultListening { + var onStateChange: ((MacJavaTestResultListenerState) -> Void)? + var onConnection: ((NWConnection) -> Void)? + private(set) var startCount = 0 + private(set) var cancelCount = 0 + + private let readyPort: UInt16? + private let onStart: (() -> Void)? + + init(readyPort: UInt16? = nil, onStart: (() -> Void)? = nil) { + self.readyPort = readyPort + self.onStart = onStart + } + + func start(queue _: DispatchQueue) { + startCount += 1 + onStart?() + if let readyPort { + onStateChange?(.ready(port: readyPort)) + } + } + + func cancel() { + cancelCount += 1 + onStateChange?(.cancelled) + } +} diff --git a/macos/Tests/LitheTests/RealJavaDebugIntegrationTests.swift b/macos/Tests/LitheTests/RealJavaDebugIntegrationTests.swift index 7464a887..0d29fbfb 100644 --- a/macos/Tests/LitheTests/RealJavaDebugIntegrationTests.swift +++ b/macos/Tests/LitheTests/RealJavaDebugIntegrationTests.swift @@ -5,7 +5,7 @@ import LitheLanguageIntelligenceModule import Testing @testable import Lithe -@Suite("Real Java Debug integration") +@Suite("Real Java Debug integration", .serialized) @MainActor struct RealJavaDebugIntegrationTests { @Test @@ -222,6 +222,195 @@ struct RealJavaDebugIntegrationTests { #expect(body.contains("Grace Hopper")) } + @Test + func junitMethodHitsBreakpointAndResumes() async throws { + try await Self.runJavaTestDebug(.junit) + } + + @Test + func testngMethodHitsBreakpointAndResumes() async throws { + try await Self.runJavaTestDebug(.testng) + } + + private static func runJavaTestDebug(_ scenario: RealJavaTestDebugScenario) async throws { + let environment = ProcessInfo.processInfo.environment + guard environment["LITHE_RUN_JAVA_TEST_DEBUG_INTEGRATION"] == "1" else { return } + + let repositoryRoot = Self.repositoryRoot + let jdtlsRoot = URL( + fileURLWithPath: environment["LITHE_JDTLS_ROOT"] + ?? repositoryRoot.appendingPathComponent(".artifacts/jdtls").path, + isDirectory: true + ) + let javaURL = URL( + fileURLWithPath: environment["LITHE_JAVA_PATH"] + ?? repositoryRoot.appendingPathComponent(".artifacts/jdk-arm64/bin/java").path + ) + let jdtlsURL = jdtlsRoot.appendingPathComponent("bin/jdtls") + let fileManager = FileManager.default + #expect(fileManager.isExecutableFile(atPath: javaURL.path)) + #expect(fileManager.isExecutableFile(atPath: jdtlsURL.path)) + guard fileManager.isExecutableFile(atPath: javaURL.path), + fileManager.isExecutableFile(atPath: jdtlsURL.path) else { return } + + let rootURL = fileManager.temporaryDirectory.appendingPathComponent( + "lithe-real-java-test-debug-\(scenario.rawValue)-\(UUID().uuidString)", + isDirectory: true + ) + let cacheURL = fileManager.temporaryDirectory.appendingPathComponent( + "\(rootURL.lastPathComponent)-jdtls-cache", + isDirectory: true + ) + let fixtureURL = repositoryRoot.appendingPathComponent( + "shared/fixtures/projects/lithe-spring-boot-git-graph", + isDirectory: true + ) + try fileManager.copyItem(at: fixtureURL, to: rootURL) + defer { + try? fileManager.removeItem(at: rootURL) + try? fileManager.removeItem(at: cacheURL) + } + try scenario.prepareProject(at: rootURL, fileManager: fileManager) + let testURL = rootURL.appendingPathComponent( + "src/test/java/com/example/demo/user/UserServiceTest.java" + ) + let testSource = try String(contentsOf: testURL, encoding: .utf8) + let breakpointLine = try #require(line( + containing: scenario.breakpointNeedle, + in: testSource + )) + + let core = RustCoreBridge() + #expect(core.isAvailable) + guard core.isAvailable else { return } + let resources: JDTLSLaunchResources + switch MacJDTLSLaunchResourceResolver( + bundledJdtlsRootURL: jdtlsRoot + ).resolve(for: jdtlsURL) { + case .direct(let value): + resources = value + case .wrapperFallback: + Issue.record("The real Java test Debug test requires direct JDT LS resources.") + return + case .unavailable(let message): + Issue.record("JDT LS resources are unavailable: \(message)") + return + } + + let descriptor = try #require(LanguageProviderCatalog.standard.provider(for: testURL)) + let launch = try #require(descriptor.languageServerLaunch) + let languageSession = LanguageServerRuntimeSession( + providerID: descriptor.id, + executableURL: jdtlsURL, + arguments: launch.arguments, + environment: environment, + initializationOptions: launch.initializationOptions, + runtimeExecutableURL: javaURL, + jdtlsLaunchResources: resources, + cacheDirectoryURL: cacheURL, + initializeTimeout: 120, + requestTimeout: 120, + shutdownTimeout: 5, + core: core + ) + let languageRuntime = RealJavaDebugLanguageRuntime( + descriptor: descriptor, + session: languageSession + ) + let languageManager = LanguageToolingSessionManager( + catalog: LanguageProviderCatalog(descriptors: [descriptor]), + runtimes: [languageRuntime], + builtinCore: core + ) + let protocolTrace = RealJavaDebugProtocolTrace() + let debugManager = DebugAdapterSessionManager( + providers: [DebugProviderDescriptor( + id: "java", + displayName: "Java", + fileExtensions: ["java"] + )] + ) { _, _ in + CoreDebugAdapterProtocolSession( + adapterID: "java", + transport: RealJavaDebugRecordingTransport( + wrapping: MacJavaDebugAdapterTransport( + portResolver: { rootURL in + try await languageManager.startJavaDebugServer(rootURL: rootURL) + } + ), + trace: protocolTrace + ), + core: core, + deadlineScheduler: MacDebugOperationDeadlineScheduler() + ) + } + let feature = GenericDebugFeatureModel(sessions: debugManager) + let launchService = JavaTestDebugLaunchService( + configurationResolver: DebugLaunchConfigurationResolver( + fileExists: { fileManager.fileExists(atPath: $0.path) }, + javaTestLaunchResolver: core + ), + resultServerFactory: { MacJavaTestResultServer() } + ) + defer { + feature.stop() + languageManager.stopAll() + } + + let prepared: PreparedJavaTestDebugLaunch + do { + prepared = try await launchService.prepare( + fileURL: testURL, + testIdentifier: scenario.testIdentifier, + rootURL: rootURL, + targetResolver: languageManager + ) + } catch { + throw RealJavaDebugIntegrationError.languageToolingFailed( + message: String(describing: error), + logs: languageServerLogSummary(languageManager.languageServerLogs) + ) + } + defer { prepared.stop() } + + #expect(prepared.target.framework == scenario.framework) + feature.toggleBreakpoint(fileURL: testURL, line: breakpointLine) + #expect(feature.start( + fileURL: testURL, + rootURL: rootURL, + configuration: prepared.configuration + )) + + guard await waitUntil(timeout: .seconds(120), condition: { + feature.state == .paused + }) else { + throw RealJavaDebugIntegrationError.debuggerDidNotPause( + debugSnapshot(feature, protocolTrace: protocolTrace) + ) + } + #expect( + feature.breakpoints.first?.verified == true, + "The Java test breakpoint was not verified.\n\(debugSnapshot(feature, protocolTrace: protocolTrace))" + ) + guard await waitUntil(timeout: .seconds(30), condition: { + feature.selectedFrame?.sourceURL?.standardizedFileURL + == testURL.standardizedFileURL + && feature.selectedFrame?.line == breakpointLine + }) else { + throw RealJavaDebugIntegrationError.stoppedFrameUnavailable( + debugSnapshot(feature, protocolTrace: protocolTrace) + ) + } + #expect(await waitUntil(timeout: .seconds(30)) { + feature.variables.contains { $0.name == scenario.expectedVariable } + }, "The stopped Java test frame did not expose \(scenario.expectedVariable).") + + feature.execute(.continueExecution) + #expect(await waitUntil(timeout: .seconds(60)) { + feature.state == .terminated + }, "Java test Debug did not terminate.\n\(debugSnapshot(feature, protocolTrace: protocolTrace))") + } + private static var repositoryRoot: URL { URL(fileURLWithPath: #filePath) .deletingLastPathComponent() @@ -320,6 +509,112 @@ struct RealJavaDebugIntegrationTests { } } +private enum RealJavaTestDebugScenario: String { + case junit + case testng + + var framework: JavaTestDebugFramework { + switch self { + case .junit: .junit + case .testng: .testng + } + } + + var testIdentifier: String { + switch self { + case .junit: "com.example.demo.user.UserServiceTest#addsNumbers()" + case .testng: "com.example.demo.user.UserServiceTest#multipliesNumbers()" + } + } + + var breakpointNeedle: String { + switch self { + case .junit: "int total = left + right;" + case .testng: "int product = left * right;" + } + } + + var expectedVariable: String { + switch self { + case .junit: "left" + case .testng: "left" + } + } + + func prepareProject(at rootURL: URL, fileManager: FileManager) throws { + let testDirectory = rootURL.appendingPathComponent( + "src/test/java/com/example/demo/user", + isDirectory: true + ) + try fileManager.createDirectory( + at: testDirectory, + withIntermediateDirectories: true + ) + let sourceURL = testDirectory.appendingPathComponent("UserServiceTest.java") + try source.write(to: sourceURL, atomically: true, encoding: .utf8) + guard self == .testng else { return } + + let pomURL = rootURL.appendingPathComponent("pom.xml") + var pom = try String(contentsOf: pomURL, encoding: .utf8) + guard let insertion = pom.range(of: "") else { + throw RealJavaDebugIntegrationError.invalidFixture("Missing Maven dependencies section") + } + pom.insert(contentsOf: testNGDependency, at: insertion.lowerBound) + try pom.write(to: pomURL, atomically: true, encoding: .utf8) + } + + private var source: String { + switch self { + case .junit: + """ + package com.example.demo.user; + + import static org.junit.jupiter.api.Assertions.assertEquals; + + import org.junit.jupiter.api.Test; + + class UserServiceTest { + @Test + void addsNumbers() { + int left = 20; + int right = 22; + int total = left + right; + assertEquals(42, total); + } + } + """ + case .testng: + """ + package com.example.demo.user; + + import org.testng.Assert; + import org.testng.annotations.Test; + + public class UserServiceTest { + @Test + public void multipliesNumbers() { + int left = 6; + int right = 7; + int product = left * right; + Assert.assertEquals(product, 42); + } + } + """ + } + } + + private var testNGDependency: String { + """ + + org.testng + testng + 7.10.2 + test + + """ + } +} + @MainActor private final class RealJavaDebugProtocolTrace { private(set) var entries: [String] = [] @@ -394,4 +689,5 @@ private enum RealJavaDebugIntegrationError: Error { case languageToolingFailed(message: String, logs: String) case debuggerDidNotPause(String) case stoppedFrameUnavailable(String) + case invalidFixture(String) } diff --git a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift index ebef2255..4d68fa8f 100644 --- a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -372,6 +372,47 @@ struct RunConfigurationIntegrationTests { #expect(configuration.arguments["env"] == .object(["APP_ENV": .string("dev")])) } + @Test + func javaTestDebugLaunchUsesTheSharedRustConfiguration() throws { + let core = RustCoreBridge() + guard core.isAvailable else { return } + let target = JavaTestDebugLaunchTarget( + fileURL: URL(fileURLWithPath: "/workspace/UserServiceTest.java"), + name: "UserServiceTest", + framework: .testng, + workingDirectory: "/workspace", + mainClass: "com.microsoft.java.test.runner.Launcher", + projectName: "service", + classPaths: ["/workspace/classes"], + modulePaths: [], + vmArguments: [], + programArguments: [], + testNGRunnerPath: "/lithe/java-test-runner.jar", + testNGTestNames: ["example.UserServiceTest#logsIn"] + ) + let resolver = DebugLaunchConfigurationResolver( + fileExists: { _ in true }, + javaTestLaunchResolver: core + ) + + let configuration = try resolver.resolveJavaTest(target: target, resultPort: 43_128) + + #expect(configuration.name == "UserServiceTest") + #expect(configuration.request == .launch) + #expect( + configuration.arguments["mainClass"] + == .string("com.microsoft.java.test.runner.Launcher") + ) + #expect(configuration.arguments["classPaths"] == .array([ + .string("/workspace/classes"), + .string("/lithe/java-test-runner.jar"), + ])) + #expect( + configuration.arguments["args"] + == .string("43128 testng example.UserServiceTest#logsIn") + ) + } + @Test func javaAttachUsesTheSharedDAPSessionWithValidatedEndpointArguments() throws { let resolver = DebugLaunchConfigurationResolver(fileExists: { _ in true }) @@ -519,6 +560,34 @@ struct RunConfigurationIntegrationTests { #expect(javaPlan.launchPlan.toolchainID == "project-maven") #expect(javaPlan.launchPlan.arguments == ["-Dtest=UserServiceTest", "test"]) + let gradleMethodPlan = try javaProvider.testPlan( + scope: .testCase( + identifier: "example.UserServiceTest#logsIn()", + fileURL: files[0] + ), + context: LanguageTestContext( + workspaceURL: root, + projectFiles: [root.appendingPathComponent("build.gradle.kts"), files[0]] + ) + ) + #expect(gradleMethodPlan.launchPlan.arguments == [ + "test", "--tests", "example.UserServiceTest.logsIn", + ]) + + let mavenMethodPlan = try javaProvider.testPlan( + scope: .testCase( + identifier: "example.UserServiceTest#logsIn()", + fileURL: files[0] + ), + context: LanguageTestContext( + workspaceURL: root, + projectFiles: [root.appendingPathComponent("pom.xml"), files[0]] + ) + ) + #expect(mavenMethodPlan.launchPlan.arguments == [ + "-Dtest=example.UserServiceTest#logsIn", "test", + ]) + let gradlePlan = try javaProvider.testPlan( scope: .workspace, context: LanguageTestContext( @@ -1368,7 +1437,11 @@ struct RunConfigurationIntegrationTests { fileURLWithPath: "/jdtls/java-test/extensions/com.microsoft.java.test.plugin-0.42.0.jar" ) - ] + ], + javaTestRunnerURL: URL( + fileURLWithPath: + "/jdtls/java-test/runner/com.microsoft.java.test.runner-jar-with-dependencies.jar" + ) ) let runtime = StdioLanguageProviderRuntime( descriptor: descriptor, @@ -1391,6 +1464,7 @@ struct RunConfigurationIntegrationTests { let start = try #require(core.startCalls.last) #expect(start.runtimeExecutableURL?.path == "/jdk/bin/java") #expect(start.jdtlsLaunchResources == resources) + #expect(session.javaTestRunnerURL == resources.javaTestRunnerURL) session.stop() } diff --git a/rust/lithe-core/src/debug/java_test.rs b/rust/lithe-core/src/debug/java_test.rs new file mode 100644 index 00000000..f119e467 --- /dev/null +++ b/rust/lithe-core/src/debug/java_test.rs @@ -0,0 +1,291 @@ +//! Deterministic Java test launch configuration shared by native products. + +use std::collections::HashSet; + +use serde::Deserialize; +use serde_json::{Map, Value}; + +use crate::protocol::{CoreError, ErrorCode}; + +use super::{DebugLaunchConfiguration, DebugRequestKind}; + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Java test framework whose runner arguments must be projected into DAP. +pub enum JavaTestFramework { + Junit, + Testng, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Platform-observed JDT LS launch metadata plus one loopback result port. +pub struct JavaTestLaunchRequest { + pub name: String, + pub framework: JavaTestFramework, + pub working_directory: String, + pub main_class: String, + #[serde(default)] + pub project_name: Option, + #[serde(default)] + pub class_paths: Vec, + #[serde(default)] + pub module_paths: Vec, + #[serde(default)] + pub vm_arguments: Vec, + #[serde(default)] + pub program_arguments: Vec, + pub result_port: u16, + #[serde(default)] + pub testng_runner_path: Option, + #[serde(default)] + pub testng_test_names: Vec, +} + +/// Creates the provider arguments consumed by Java Debug Server. +pub fn java_test_launch( + request: JavaTestLaunchRequest, +) -> Result { + let name = required(request.name, "Java test launch name is required.")?; + let working_directory = required( + request.working_directory, + "Java test working directory is required.", + )?; + let main_class = required(request.main_class, "Java test main class is required.")?; + if request.result_port == 0 { + return Err(invalid_request( + "Java test result port must be between 1 and 65535.", + )); + } + + let mut class_paths = non_empty_unique(request.class_paths); + let module_paths = non_empty_unique(request.module_paths); + let vm_arguments = non_empty(request.vm_arguments); + let mut arguments = Map::new(); + arguments.insert("mainClass".to_string(), Value::String(main_class)); + arguments.insert("cwd".to_string(), Value::String(working_directory)); + arguments.insert( + "console".to_string(), + Value::String("internalConsole".to_string()), + ); + if let Some(project_name) = optional_non_empty(request.project_name) { + arguments.insert("projectName".to_string(), Value::String(project_name)); + } + + let program_arguments = match request.framework { + JavaTestFramework::Junit => junit_arguments(request.program_arguments, request.result_port), + JavaTestFramework::Testng => { + let runner_path = required( + request.testng_runner_path.unwrap_or_default(), + "The Java TestNG runner is unavailable.", + )?; + if !class_paths.iter().any(|path| path == &runner_path) { + class_paths.push(runner_path); + } + let test_names = non_empty_unique(request.testng_test_names); + if test_names.is_empty() { + return Err(invalid_request( + "At least one TestNG test method is required.", + )); + } + std::iter::once(request.result_port.to_string()) + .chain(std::iter::once("testng".to_string())) + .chain(test_names) + .collect() + } + }; + + insert_string_array(&mut arguments, "classPaths", class_paths); + insert_string_array(&mut arguments, "modulePaths", module_paths); + insert_java_debug_arguments(&mut arguments, "args", program_arguments); + insert_java_debug_arguments(&mut arguments, "vmArgs", vm_arguments); + + Ok(DebugLaunchConfiguration { + name, + request: DebugRequestKind::Launch, + arguments, + stepping_filters: None, + }) +} + +fn junit_arguments(arguments: Vec, result_port: u16) -> Vec { + let mut arguments = arguments; + let port = result_port.to_string(); + if let Some(index) = arguments.iter().rposition(|value| value == "-port") { + if index + 1 < arguments.len() { + arguments[index + 1] = port; + return arguments; + } + } + arguments.push("-port".to_string()); + arguments.push(port); + arguments +} + +fn required(value: String, message: &str) -> Result { + let value = value.trim().to_string(); + if value.is_empty() { + Err(invalid_request(message)) + } else { + Ok(value) + } +} + +fn optional_non_empty(value: Option) -> Option { + value.and_then(|value| { + let value = value.trim().to_string(); + (!value.is_empty()).then_some(value) + }) +} + +fn non_empty(values: Vec) -> Vec { + values + .into_iter() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .collect() +} + +fn non_empty_unique(values: Vec) -> Vec { + let mut seen = HashSet::new(); + non_empty(values) + .into_iter() + .filter(|value| seen.insert(value.clone())) + .collect() +} + +fn insert_string_array(arguments: &mut Map, key: &str, values: Vec) { + if values.is_empty() { + return; + } + arguments.insert( + key.to_string(), + Value::Array(values.into_iter().map(Value::String).collect()), + ); +} + +fn insert_java_debug_arguments(arguments: &mut Map, key: &str, values: Vec) { + let value = java_debug_argument_string(values); + if !value.is_empty() { + arguments.insert(key.to_string(), Value::String(value)); + } +} + +fn java_debug_argument_string(values: Vec) -> String { + // Java Test exposes arrays to VS Code, but Java Debug Server's DAP model + // accepts one command-line string. Mirror the upstream extension's + // serialization so the adapter can reconstruct spaces, quotes, and paths. + non_empty(values) + .into_iter() + .map(|value| { + if value + .chars() + .any(|character| character == '"' || character.is_whitespace()) + { + let escaped = value.chars().fold(String::new(), |mut result, character| { + if matches!(character, '"' | '\\') { + result.push('\\'); + } + result.push(character); + result + }); + format!("\"{escaped}\"") + } else { + value + } + }) + .collect::>() + .join(" ") +} + +fn invalid_request(message: &str) -> CoreError { + CoreError::new(ErrorCode::InvalidRequest, message) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn junit_launch_replaces_the_server_placeholder_port() { + let configuration = java_test_launch(JavaTestLaunchRequest { + name: "UserServiceTest".to_string(), + framework: JavaTestFramework::Junit, + working_directory: "/workspace".to_string(), + main_class: "org.eclipse.jdt.internal.junit.runner.RemoteTestRunner".to_string(), + project_name: Some("service".to_string()), + class_paths: vec!["/workspace/classes".to_string()], + module_paths: Vec::new(), + vm_arguments: vec!["--enable-preview".to_string()], + program_arguments: vec![ + "-version".to_string(), + "3".to_string(), + "-port".to_string(), + "-1".to_string(), + ], + result_port: 43127, + testng_runner_path: None, + testng_test_names: Vec::new(), + }) + .expect("JUnit launch should resolve"); + + assert_eq!( + configuration.arguments["args"], + json!("-version 3 -port 43127") + ); + assert_eq!( + configuration.arguments["classPaths"], + json!(["/workspace/classes"]) + ); + assert_eq!(configuration.arguments["vmArgs"], json!("--enable-preview")); + } + + #[test] + fn testng_launch_appends_the_packaged_runner_once() { + let configuration = java_test_launch(JavaTestLaunchRequest { + name: "UserServiceTest".to_string(), + framework: JavaTestFramework::Testng, + working_directory: "/workspace".to_string(), + main_class: "com.microsoft.java.test.runner.Launcher".to_string(), + project_name: Some("service".to_string()), + class_paths: vec![ + "/workspace/classes".to_string(), + "/lithe/java-test-runner.jar".to_string(), + ], + module_paths: Vec::new(), + vm_arguments: Vec::new(), + program_arguments: Vec::new(), + result_port: 43128, + testng_runner_path: Some("/lithe/java-test-runner.jar".to_string()), + testng_test_names: vec![ + "example.UserServiceTest#logsIn".to_string(), + "example.UserServiceTest#logsIn".to_string(), + ], + }) + .expect("TestNG launch should resolve"); + + assert_eq!( + configuration.arguments["classPaths"], + json!(["/workspace/classes", "/lithe/java-test-runner.jar"]) + ); + assert_eq!( + configuration.arguments["args"], + json!("43128 testng example.UserServiceTest#logsIn") + ); + } + + #[test] + fn java_debug_arguments_match_the_adapter_command_line_contract() { + assert_eq!( + java_debug_argument_string(vec![ + "-Dlabel=hello world".to_string(), + "say\"hello".to_string(), + r"C:\plain".to_string(), + r"C:\Program Files\Java".to_string(), + ]), + r#""-Dlabel=hello world" "say\"hello" C:\plain "C:\\Program Files\\Java""# + ); + } +} diff --git a/rust/lithe-core/src/debug/mod.rs b/rust/lithe-core/src/debug/mod.rs index a3d3370b..0cf8f677 100644 --- a/rust/lithe-core/src/debug/mod.rs +++ b/rust/lithe-core/src/debug/mod.rs @@ -1,8 +1,10 @@ //! Transport-neutral Debug Adapter Protocol state and normalized debugger models. mod engine; +mod java_test; mod protocol; mod types; pub(crate) use engine::*; +pub(crate) use java_test::*; pub(crate) use types::*; diff --git a/rust/lithe-core/src/debug/types.rs b/rust/lithe-core/src/debug/types.rs index f441052e..5e94386d 100644 --- a/rust/lithe-core/src/debug/types.rs +++ b/rust/lithe-core/src/debug/types.rs @@ -34,7 +34,7 @@ pub struct SessionRequest { pub session_id: String, } -#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq)] +#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] /// DAP request used to begin a debuggee session. pub enum DebugRequestKind { @@ -51,7 +51,7 @@ impl DebugRequestKind { } } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] /// Provider-specific launch arguments wrapped in a language-neutral contract. pub struct DebugLaunchConfiguration { diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index 954a60a8..dd3abf76 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -87,6 +87,8 @@ pub enum CoreCommand { DebugCreateSession, /// Queues a launch or attach request for a debug session (`debug.launch`). DebugLaunch, + /// Creates a Java test launch configuration from JDT LS metadata (`debug.javaTestLaunch`). + DebugJavaTestLaunch, /// Returns or normalizes portable stepping filters (`debug.steppingFilters`). DebugSteppingFilters, /// Replaces breakpoints for one source file (`debug.setBreakpoints`). @@ -261,6 +263,7 @@ impl CoreCommand { "markdown.render" => Some(Self::MarkdownRender), "debug.createSession" => Some(Self::DebugCreateSession), "debug.launch" => Some(Self::DebugLaunch), + "debug.javaTestLaunch" => Some(Self::DebugJavaTestLaunch), "debug.steppingFilters" => Some(Self::DebugSteppingFilters), "debug.setBreakpoints" => Some(Self::DebugSetBreakpoints), "debug.setExceptionBreakpoints" => Some(Self::DebugSetExceptionBreakpoints), @@ -382,6 +385,7 @@ mod tests { for command in [ "debug.createSession", "debug.launch", + "debug.javaTestLaunch", "debug.steppingFilters", "debug.setBreakpoints", "debug.setExceptionBreakpoints", diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index 0df3de8f..661fda12 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -515,6 +515,25 @@ fn execute(request: &str) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::DebugJavaTestLaunch => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Java test debug launch request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::debug::java_test_launch) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data) + .expect("Java test debug launch configuration should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::DebugSteppingFilters => { match serde_json::from_value::( parsed.payload, diff --git a/rust/lithe-core/src/tests/protocol.rs b/rust/lithe-core/src/tests/protocol.rs index eb961d29..9d4fcc40 100644 --- a/rust/lithe-core/src/tests/protocol.rs +++ b/rust/lithe-core/src/tests/protocol.rs @@ -88,3 +88,32 @@ fn debug_stepping_filters_match_the_shared_contract_fixture() { ); } } + +#[test] +fn java_test_debug_launch_matches_the_shared_contract_fixture() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/debug/java-test-launch-v1.json" + ))) + .expect("Java test debug fixture should be valid JSON"); + + for case in fixture["cases"] + .as_array() + .expect("Java test debug fixture should contain cases") + { + let request = serde_json::json!({ + "id": case["name"], + "command": "debug.javaTestLaunch", + "payload": case["payload"] + }); + let response: Value = serde_json::from_str(&execute_json(&request.to_string())) + .expect("Java test debug response should be JSON"); + + assert_eq!(response["ok"], true, "fixture case {}", case["name"]); + assert_eq!( + response["data"], case["expected"], + "fixture case {}", + case["name"] + ); + } +} diff --git a/shared/contracts/application-boundary.md b/shared/contracts/application-boundary.md index 2abc1675..c0652c12 100644 --- a/shared/contracts/application-boundary.md +++ b/shared/contracts/application-boundary.md @@ -152,6 +152,24 @@ when a TestNG session starts. Packaged JDT LS therefore has no runtime dependenc on shell wrappers, PowerShell, or the user's `PATH`. Legacy wrappers are an external-plan compatibility fallback and are not the packaged execution path. +Java test discovery remains a language-service workflow rather than a UI or +Debug Core parser. When the Tests tool window is opened or refreshed, the +language facade asks the Java Test extension for each candidate source file's +class and method tree, then projects stable fully qualified identifiers into +the native list. Closing the tool window, changing workspace, or reloading the +Java runtime cancels the owning discovery operation; late results cannot replace +the current workspace's tree. Discovery does not create a Debug session, result +socket, adapter connection, or target JVM. + +Starting one JUnit or TestNG file, class, or method creates a short-lived native +loopback result listener on demand. JDT LS owns project/test metadata, Rust Core +owns deterministic DAP launch argument projection, and the Debug module owns the +adapter session. Repeated launch, stop, project close, runtime reload, and launch +failure all cancel the active operation and release the listener. The selected +Run configuration remains the source of project-scoped Java runtime selection; +JDT LS remains authoritative for the test runner classpath, working directory, +and test-specific VM and program arguments. + Platforms observe JDT LS version and non-recursive build-file metadata, while Rust Core alone validates and reduces those observations to the opaque workspace fingerprint. macOS and Windows adapters must not duplicate its ordering, diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index ca61a61e..1d11d065 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -83,6 +83,7 @@ stable error code and a user-facing message: | `maven.diagnostics` | Parse stable Maven compiler diagnostics from build output | | `debug.createSession` | Create a transport-neutral DAP session and return its initialize frame | | `debug.launch` | Queue a launch or attach request, including during initialization | +| `debug.javaTestLaunch` | Normalize JUnit or TestNG launch metadata into Java DAP arguments | | `debug.steppingFilters` | Return adapter defaults or normalize portable stepping filters | | `debug.setBreakpoints` | Replace and deterministically order one source's DAP breakpoints | | `debug.setExceptionBreakpoints` | Replace and deterministically order one session's exception filters | @@ -378,6 +379,17 @@ consecutive messages are buffered and reduced in Rust. `debug.launch` accepts an `operationId` and a language-neutral configuration containing `name`, request kind (`launch` or `attach`), provider arguments, and optional portable `steppingFilters`. +`debug.javaTestLaunch` accepts JDT LS-owned working directory, main class, +project, classpath, module path, VM arguments, program arguments, Java test +framework, and a platform-owned loopback result port. JUnit placeholder ports +are replaced deterministically. TestNG appends the packaged runner once and +uses its selected method names. Core serializes JDT's VM and program argument +arrays into the string fields required by Java Debug Server's DAP launch model. +JDT LS remains responsible for resolving file, +class, and method selections to this metadata; Core does not parse Java source +or infer a test framework in this command. The command creates no process, +socket, timer, or persistent session; compatibility cases live in +`shared/fixtures/debug/java-test-launch-v1.json`. Launch submitted during initialization is retained until the initialize response. For Java, Core projects those filters into the adapter's `stepFilters` launch object unless the provider arguments already contain an explicit value. diff --git a/shared/fixtures/debug/java-test-launch-v1.json b/shared/fixtures/debug/java-test-launch-v1.json new file mode 100644 index 00000000..de29eced --- /dev/null +++ b/shared/fixtures/debug/java-test-launch-v1.json @@ -0,0 +1,70 @@ +{ + "version": 1, + "cases": [ + { + "name": "junit replaces the result port", + "payload": { + "name": "UserServiceTest", + "framework": "junit", + "workingDirectory": "/workspace/service", + "mainClass": "org.eclipse.jdt.internal.junit.runner.RemoteTestRunner", + "projectName": "service", + "classPaths": ["/workspace/service/classes", "/workspace/service/classes", ""], + "modulePaths": [], + "vmArguments": ["--enable-preview"], + "programArguments": ["-version", "3", "-port", "-1"], + "resultPort": 43127, + "testngRunnerPath": null, + "testngTestNames": [] + }, + "expected": { + "name": "UserServiceTest", + "request": "launch", + "arguments": { + "mainClass": "org.eclipse.jdt.internal.junit.runner.RemoteTestRunner", + "cwd": "/workspace/service", + "console": "internalConsole", + "projectName": "service", + "classPaths": ["/workspace/service/classes"], + "args": "-version 3 -port 43127", + "vmArgs": "--enable-preview" + }, + "steppingFilters": null + } + }, + { + "name": "testng appends the packaged runner", + "payload": { + "name": "UserServiceTest", + "framework": "testng", + "workingDirectory": "/workspace/service", + "mainClass": "com.microsoft.java.test.runner.Launcher", + "projectName": "service", + "classPaths": ["/workspace/service/classes"], + "modulePaths": ["/workspace/service/modules"], + "vmArguments": [], + "programArguments": [], + "resultPort": 43128, + "testngRunnerPath": "/lithe/java-test-runner.jar", + "testngTestNames": [ + "example.UserServiceTest#logsIn", + "example.UserServiceTest#logsIn" + ] + }, + "expected": { + "name": "UserServiceTest", + "request": "launch", + "arguments": { + "mainClass": "com.microsoft.java.test.runner.Launcher", + "cwd": "/workspace/service", + "console": "internalConsole", + "projectName": "service", + "classPaths": ["/workspace/service/classes", "/lithe/java-test-runner.jar"], + "modulePaths": ["/workspace/service/modules"], + "args": "43128 testng example.UserServiceTest#logsIn" + }, + "steppingFilters": null + } + } + ] +} From dd5e43574b26abe1e4bb2d501371c67821c896bc Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sun, 30 Aug 2026 08:28:38 +0800 Subject: [PATCH 39/66] feat(debug): relocate breakpoints with source edits --- .../Composition/DebugFeatureGraph.swift | 2 + .../Core/Rust/RustDebugProtocolCore.swift | 28 ++ .../AppModel/AppModel+Development.swift | 21 ++ .../Platform/MacOS/MacServiceContainer.swift | 1 + .../Lithe/Views/Editor/CodeEditorView.swift | 12 + .../Debug/DebugAdapterContracts.swift | 19 + .../Debug/DebugProtocolCore.swift | 10 + .../GenericDebugFeatureModel.swift | 60 +++ .../DebugModuleTests.swift | 135 ++++++- .../RunConfigurationIntegrationTests.swift | 27 ++ .../src/debug/breakpoint_relocation.rs | 353 ++++++++++++++++++ rust/lithe-core/src/debug/mod.rs | 2 + rust/lithe-core/src/debug/types.rs | 2 +- rust/lithe-core/src/protocol/command.rs | 4 + rust/lithe-core/src/runtime/dispatcher.rs | 18 + rust/lithe-core/src/tests/protocol.rs | 29 ++ shared/contracts/application-boundary.md | 2 +- shared/contracts/rust-core-api.md | 1 + .../debug/breakpoint-relocation-v1.json | 38 ++ 19 files changed, 761 insertions(+), 3 deletions(-) create mode 100644 rust/lithe-core/src/debug/breakpoint_relocation.rs create mode 100644 shared/fixtures/debug/breakpoint-relocation-v1.json diff --git a/macos/Sources/Lithe/Application/Composition/DebugFeatureGraph.swift b/macos/Sources/Lithe/Application/Composition/DebugFeatureGraph.swift index 1aad622e..1e207610 100644 --- a/macos/Sources/Lithe/Application/Composition/DebugFeatureGraph.swift +++ b/macos/Sources/Lithe/Application/Composition/DebugFeatureGraph.swift @@ -14,6 +14,7 @@ final class DebugFeatureGraph: NSObject, DebugServiceGraph { init( adapterSessions: DebugAdapterSessionManager, breakpointPersistence: (any DebugBreakpointPersisting)? = nil, + breakpointRelocator: (any DebugBreakpointRelocating)? = nil, steppingFilterResolver: (any DebugSteppingFilterResolving)? = nil, steppingFilterPersistence: (any DebugSteppingFilterPersisting)? = nil ) { @@ -21,6 +22,7 @@ final class DebugFeatureGraph: NSObject, DebugServiceGraph { genericFeature = GenericDebugFeatureModel( sessions: adapterSessions, breakpointPersistence: breakpointPersistence, + breakpointRelocator: breakpointRelocator, steppingFilterResolver: steppingFilterResolver, steppingFilterPersistence: steppingFilterPersistence ) diff --git a/macos/Sources/Lithe/Core/Rust/RustDebugProtocolCore.swift b/macos/Sources/Lithe/Core/Rust/RustDebugProtocolCore.swift index 05e5bc83..a643274d 100644 --- a/macos/Sources/Lithe/Core/Rust/RustDebugProtocolCore.swift +++ b/macos/Sources/Lithe/Core/Rust/RustDebugProtocolCore.swift @@ -13,6 +13,24 @@ extension RustCoreBridge: JavaTestDebugLaunchResolving { } } +extension RustCoreBridge: DebugBreakpointRelocating { + func relocateDebugBreakpoints( + source: String, + edit: DebugSourceEdit, + breakpoints: [DebugSourceBreakpoint] + ) throws -> [DebugSourceBreakpoint] { + let result: DebugBreakpointRelocationResult = try executeResult( + command: "debug.relocateBreakpoints", + payload: DebugBreakpointRelocationPayload( + source: source, + edit: edit, + breakpoints: breakpoints + ) + ).get() + return result.breakpoints + } +} + extension RustCoreBridge: DebugProtocolCore { func resolveDebugSteppingFilters( adapterID: String, @@ -286,6 +304,16 @@ private struct JavaTestDebugLaunchPayload: Encodable { } } +private struct DebugBreakpointRelocationPayload: Encodable { + let source: String + let edit: DebugSourceEdit + let breakpoints: [DebugSourceBreakpoint] +} + +private struct DebugBreakpointRelocationResult: Decodable { + let breakpoints: [DebugSourceBreakpoint] +} + private struct DebugCreateSessionPayload: Encodable { let sessionID: String let adapterID: String diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift index 00ae753f..c985c3b0 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift @@ -742,6 +742,27 @@ extension AppModel { } } + func applyDebugSourceEdit( + fileURL: URL, + previousSource: String, + replacedRange: NSRange, + replacement: String + ) { + guard replacedRange.location != NSNotFound, + replacedRange.location >= 0, + replacedRange.length >= 0, + NSMaxRange(replacedRange) <= previousSource.utf16.count else { return } + genericDebugFeatureIfActive?.applySourceEdit( + fileURL: fileURL, + source: previousSource, + edit: DebugSourceEdit( + startUTF16Offset: replacedRange.location, + endUTF16Offset: NSMaxRange(replacedRange), + replacement: replacement + ) + ) + } + func editDebugBreakpoint(fileURL: URL, line: Int) { let normalizedURL = fileURL.standardizedFileURL pendingDebugBreakpointEditor = genericDebugFeatureIfActive?.breakpoints diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 50643569..007f129e 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -413,6 +413,7 @@ final class MacServiceContainer { let graph = DebugFeatureGraph( adapterSessions: adapterSessions, breakpointPersistence: debugBreakpointStore, + breakpointRelocator: rustCore, steppingFilterResolver: rustCore, steppingFilterPersistence: debugSteppingFilterStore ) diff --git a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift index 8ee1fa9d..68962653 100644 --- a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift +++ b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift @@ -1035,6 +1035,7 @@ struct CodeEditorView: NSViewRepresentable { guard let textView else { return } guard document?.isReadOnly != true else { return } let codeTextView = textView as? CodeTextView + let previousSource = document?.text if let replacedRange = pendingReplacedRange, let replacement = pendingReplacement { codeTextView?.applyLineIndexEdit(replacedRange: replacedRange, replacement: replacement) } else { @@ -1043,6 +1044,17 @@ struct CodeEditorView: NSViewRepresentable { gutter?.refreshLineNumberLayout() isApplyingEditorChange = true document?.applyLiveEditorText(textView.string) + if let document, + let previousSource, + let replacedRange = pendingReplacedRange, + let replacement = pendingReplacement { + model?.applyDebugSourceEdit( + fileURL: document.url, + previousSource: previousSource, + replacedRange: replacedRange, + replacement: replacement + ) + } if let document { scheduleDocumentChange(document) } diff --git a/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift b/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift index f6ee8e38..0519181f 100644 --- a/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift +++ b/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift @@ -180,6 +180,25 @@ public protocol JavaTestDebugLaunchTargetResolving: AnyObject { ) async throws -> JavaTestDebugLaunchTarget } +/// One exact editor replacement using zero-based, document-relative UTF-16 offsets. +public struct DebugSourceEdit: Codable, Equatable, Sendable { + public let startUTF16Offset: Int + public let endUTF16Offset: Int + public let replacement: String + + public init(startUTF16Offset: Int, endUTF16Offset: Int, replacement: String) { + self.startUTF16Offset = startUTF16Offset + self.endUTF16Offset = endUTF16Offset + self.replacement = replacement + } + + private enum CodingKeys: String, CodingKey { + case startUTF16Offset = "startUtf16Offset" + case endUTF16Offset = "endUtf16Offset" + case replacement + } +} + public struct DebugSourceBreakpoint: Codable, Hashable, Sendable { public let line: Int public let column: Int? diff --git a/macos/Sources/LitheCoreContracts/Debug/DebugProtocolCore.swift b/macos/Sources/LitheCoreContracts/Debug/DebugProtocolCore.swift index 823d6c7b..fc117b14 100644 --- a/macos/Sources/LitheCoreContracts/Debug/DebugProtocolCore.swift +++ b/macos/Sources/LitheCoreContracts/Debug/DebugProtocolCore.swift @@ -258,6 +258,16 @@ public protocol JavaTestDebugLaunchResolving: Sendable { ) throws -> DebugLaunchConfiguration } +/// Focused shared-Core boundary for moving source breakpoints with editor text. +@MainActor +public protocol DebugBreakpointRelocating: Sendable { + func relocateDebugBreakpoints( + source: String, + edit: DebugSourceEdit, + breakpoints: [DebugSourceBreakpoint] + ) throws -> [DebugSourceBreakpoint] +} + /// Transport-neutral Debug Core boundary. Native products own processes and /// sockets; this contract owns DAP framing, state, sequencing, and normalized data. @MainActor diff --git a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift index 0cc83443..b7e87e3f 100644 --- a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift +++ b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift @@ -159,6 +159,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu private let sessions: DebugAdapterSessionManager private let breakpointPersistence: (any DebugBreakpointPersisting)? + private let breakpointRelocator: (any DebugBreakpointRelocating)? private let steppingFilterResolver: (any DebugSteppingFilterResolving)? private let steppingFilterPersistence: (any DebugSteppingFilterPersisting)? private var requestedBreakpointsByFile: [URL: [Int: DebugSourceBreakpoint]] = [:] @@ -173,11 +174,13 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu public init( sessions: DebugAdapterSessionManager, breakpointPersistence: (any DebugBreakpointPersisting)? = nil, + breakpointRelocator: (any DebugBreakpointRelocating)? = nil, steppingFilterResolver: (any DebugSteppingFilterResolving)? = nil, steppingFilterPersistence: (any DebugSteppingFilterPersisting)? = nil ) { self.sessions = sessions self.breakpointPersistence = breakpointPersistence + self.breakpointRelocator = breakpointRelocator self.steppingFilterResolver = steppingFilterResolver self.steppingFilterPersistence = steppingFilterPersistence sessions.onStateChange = { [weak self] providerID, state in @@ -452,6 +455,63 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } } + public func applySourceEdit( + fileURL: URL, + source: String, + edit: DebugSourceEdit + ) { + let normalizedURL = fileURL.standardizedFileURL + guard let breakpointRelocator, + let values = requestedBreakpointsByFile[normalizedURL], + !values.isEmpty else { return } + let current = values.values.sorted { + ($0.line, $0.column ?? 0) < ($1.line, $1.column ?? 0) + } + guard sourceEditMayRelocateBreakpoints(source: source, edit: edit, breakpoints: current) + else { return } + do { + let relocated = try breakpointRelocator.relocateDebugBreakpoints( + source: source, + edit: edit, + breakpoints: current + ) + guard relocated != current else { return } + requestedBreakpointsByFile[normalizedURL] = Dictionary( + uniqueKeysWithValues: relocated.map { ($0.line, $0) } + ) + reconcileBreakpoints() + persistBreakpoints() + synchronizeBreakpoints(for: normalizedURL) + } catch { + record(error) + } + } + + private func sourceEditMayRelocateBreakpoints( + source: String, + edit: DebugSourceEdit, + breakpoints: [DebugSourceBreakpoint] + ) -> Bool { + if breakpoints.contains(where: { $0.column != nil }) + || edit.replacement.utf16.contains(10) { + return true + } + guard edit.startUTF16Offset >= 0, + edit.endUTF16Offset >= edit.startUTF16Offset else { return true } + guard edit.startUTF16Offset != edit.endUTF16Offset else { return false } + let sourceUTF16 = source.utf16 + guard let start = sourceUTF16.index( + sourceUTF16.startIndex, + offsetBy: edit.startUTF16Offset, + limitedBy: sourceUTF16.endIndex + ), let end = sourceUTF16.index( + sourceUTF16.startIndex, + offsetBy: edit.endUTF16Offset, + limitedBy: sourceUTF16.endIndex + ) else { return true } + return sourceUTF16[start.. [DebugSourceBreakpoint] { + requests.append(RecordingBreakpointRelocationRequest( + source: source, + edit: edit, + breakpoints: breakpoints + )) + return result + } +} + private struct RecordingDebugSteppingFilterResolution: Equatable { let adapterID: String let filters: DebugSteppingFilters? @@ -2004,6 +2134,7 @@ private final class DeferredInspectionDebugSession: DebugAdapterControllingSessi let capabilities: DebugAdapterCapabilities private(set) var isRunning = false private(set) var state: DebugAdapterState = .idle + private(set) var breakpointUpdates: [[DebugSourceBreakpoint]] = [] var onStateChange: ((DebugAdapterState) -> Void)? var onEvent: ((DebugAdapterEvent) -> Void)? @@ -2061,7 +2192,9 @@ private final class DeferredInspectionDebugSession: DebugAdapterControllingSessi onStateChange?(.paused) } - func setBreakpoints(_: [DebugSourceBreakpoint], in _: URL) {} + func setBreakpoints(_ breakpoints: [DebugSourceBreakpoint], in _: URL) { + breakpointUpdates.append(breakpoints) + } func execute(_: DebugExecutionCommand, threadID _: Int?) {} func requestThreads(_: @escaping (Result<[DebugThread], Error>) -> Void) {} diff --git a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift index 4d68fa8f..68785dfc 100644 --- a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -413,6 +413,33 @@ struct RunConfigurationIntegrationTests { ) } + @Test + func sourceBreakpointRelocationUsesTheSharedRustCore() throws { + let core = RustCoreBridge() + guard core.isAvailable else { return } + let source = "class Main {\n void run() {}\n}\n" + + let breakpoints = try core.relocateDebugBreakpoints( + source: source, + edit: DebugSourceEdit( + startUTF16Offset: 13, + endUTF16Offset: 13, + replacement: "\n" + ), + breakpoints: [DebugSourceBreakpoint( + line: 2, + condition: "ready", + hitCondition: "3" + )] + ) + + #expect(breakpoints == [DebugSourceBreakpoint( + line: 3, + condition: "ready", + hitCondition: "3" + )]) + } + @Test func javaAttachUsesTheSharedDAPSessionWithValidatedEndpointArguments() throws { let resolver = DebugLaunchConfigurationResolver(fileExists: { _ in true }) diff --git a/rust/lithe-core/src/debug/breakpoint_relocation.rs b/rust/lithe-core/src/debug/breakpoint_relocation.rs new file mode 100644 index 00000000..618bced4 --- /dev/null +++ b/rust/lithe-core/src/debug/breakpoint_relocation.rs @@ -0,0 +1,353 @@ +//! Deterministic source-breakpoint relocation across UTF-16 editor edits. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use crate::protocol::{CoreError, ErrorCode}; + +use super::SourceBreakpoint; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +/// One native-editor replacement expressed in document-relative UTF-16 offsets. +pub struct DebugSourceEdit { + /// Inclusive start offset in the source text before the edit. + pub start_utf16_offset: usize, + /// Exclusive end offset in the source text before the edit. + pub end_utf16_offset: usize, + /// Text inserted in place of the edited range. + pub replacement: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Relocates requested breakpoints after one exact editor mutation. +pub struct RelocateBreakpointsRequest { + /// Complete source text before the mutation. + pub source: String, + pub edit: DebugSourceEdit, + #[serde(default)] + pub breakpoints: Vec, +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +/// Stable breakpoint set after applying one source edit. +pub struct RelocateBreakpointsResult { + pub breakpoints: Vec, +} + +/// Moves breakpoint anchors with inserted or removed source text. +pub fn relocate_breakpoints( + request: RelocateBreakpointsRequest, +) -> Result { + let source_utf16_length = request.source.encode_utf16().count(); + if request.edit.start_utf16_offset > request.edit.end_utf16_offset + || request.edit.end_utf16_offset > source_utf16_length + { + return Err(invalid_request( + "Debug source edit offsets must form a valid UTF-16 range.", + )); + } + + let start_byte = byte_index_at_utf16_offset(&request.source, request.edit.start_utf16_offset)?; + let end_byte = byte_index_at_utf16_offset(&request.source, request.edit.end_utf16_offset)?; + let mut updated_source = String::with_capacity( + request.source.len() - (end_byte - start_byte) + request.edit.replacement.len(), + ); + updated_source.push_str(&request.source[..start_byte]); + updated_source.push_str(&request.edit.replacement); + updated_source.push_str(&request.source[end_byte..]); + + let replacement_utf16_length = request.edit.replacement.encode_utf16().count(); + let mut relocated = BTreeMap::new(); + for breakpoint in request.breakpoints { + let anchor = breakpoint_anchor_utf16_offset(&request.source, &breakpoint)?; + let anchor_was_replaced = request.edit.start_utf16_offset < request.edit.end_utf16_offset + && anchor >= request.edit.start_utf16_offset + && anchor < request.edit.end_utf16_offset; + let relocated_anchor = relocate_anchor( + anchor, + request.edit.start_utf16_offset, + request.edit.end_utf16_offset, + replacement_utf16_length, + ); + let (line, column) = position_at_utf16_offset(&updated_source, relocated_anchor)?; + let relocated_breakpoint = SourceBreakpoint { + line, + column: breakpoint.column.map(|_| column), + enabled: breakpoint.enabled, + condition: breakpoint.condition, + hit_condition: breakpoint.hit_condition, + log_message: breakpoint.log_message, + }; + let identity = ( + relocated_breakpoint.line, + relocated_breakpoint.column.unwrap_or(0), + ); + match relocated.get(&identity) { + Some((existing_anchor_was_replaced, _)) + if !existing_anchor_was_replaced || anchor_was_replaced => {} + _ => { + // Preserve conditions and log settings from code that survived the edit + // when a removed anchor collapses onto the same resulting position. + relocated.insert(identity, (anchor_was_replaced, relocated_breakpoint)); + } + } + } + Ok(RelocateBreakpointsResult { + breakpoints: relocated + .into_values() + .map(|(_, breakpoint)| breakpoint) + .collect(), + }) +} + +fn breakpoint_anchor_utf16_offset( + source: &str, + breakpoint: &SourceBreakpoint, +) -> Result { + if breakpoint.line < 1 || breakpoint.column.is_some_and(|column| column < 1) { + return Err(invalid_request( + "Debug breakpoint line and column values must be one-based.", + )); + } + let line_index = usize::try_from(breakpoint.line - 1) + .map_err(|_| invalid_request("Debug breakpoint line is out of range."))?; + let line_starts = line_start_utf16_offsets(source); + let Some(&line_start) = line_starts.get(line_index) else { + return Err(invalid_request("Debug breakpoint line is out of range.")); + }; + let line_end = line_starts + .get(line_index + 1) + .copied() + .map(|offset| offset.saturating_sub(1)) + .unwrap_or_else(|| source.encode_utf16().count()); + let column = match breakpoint.column { + Some(column) => usize::try_from(column - 1) + .map_err(|_| invalid_request("Debug breakpoint column is out of range."))?, + None => first_non_whitespace_utf16_column(source, line_start, line_end)?, + }; + let anchor = line_start.saturating_add(column); + if anchor > line_end { + return Err(invalid_request("Debug breakpoint column is out of range.")); + } + Ok(anchor) +} + +fn first_non_whitespace_utf16_column( + source: &str, + line_start: usize, + line_end: usize, +) -> Result { + let start_byte = byte_index_at_utf16_offset(source, line_start)?; + let end_byte = byte_index_at_utf16_offset(source, line_end)?; + let mut column = 0; + for character in source[start_byte..end_byte].chars() { + if !character.is_whitespace() { + return Ok(column); + } + column += character.len_utf16(); + } + Ok(0) +} + +fn relocate_anchor( + anchor: usize, + edit_start: usize, + edit_end: usize, + replacement_length: usize, +) -> usize { + if anchor < edit_start { + return anchor; + } + if anchor > edit_end || (anchor == edit_end && edit_start != edit_end) { + return anchor - (edit_end - edit_start) + replacement_length; + } + edit_start + replacement_length +} + +fn line_start_utf16_offsets(source: &str) -> Vec { + let mut values = vec![0]; + let mut offset = 0; + for character in source.chars() { + offset += character.len_utf16(); + if character == '\n' { + values.push(offset); + } + } + values +} + +fn position_at_utf16_offset(source: &str, offset: usize) -> Result<(i64, i64), CoreError> { + let source_length = source.encode_utf16().count(); + if offset > source_length { + return Err(invalid_request( + "Relocated debug breakpoint offset is outside the edited source.", + )); + } + let line_starts = line_start_utf16_offsets(source); + let line_index = line_starts.partition_point(|line_start| *line_start <= offset) - 1; + let column = offset - line_starts[line_index]; + Ok(((line_index + 1) as i64, (column + 1) as i64)) +} + +fn byte_index_at_utf16_offset(source: &str, target: usize) -> Result { + let mut utf16_offset = 0; + for (byte_offset, character) in source.char_indices() { + if utf16_offset == target { + return Ok(byte_offset); + } + utf16_offset += character.len_utf16(); + if utf16_offset > target { + return Err(invalid_request( + "Debug source edit offsets cannot split a UTF-16 surrogate pair.", + )); + } + } + if utf16_offset == target { + return Ok(source.len()); + } + Err(invalid_request( + "Debug source edit offset is outside the source text.", + )) +} + +fn invalid_request(message: &str) -> CoreError { + CoreError::new(ErrorCode::InvalidRequest, message) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn breakpoint(line: i64) -> SourceBreakpoint { + SourceBreakpoint { + line, + column: None, + enabled: true, + condition: Some("ready".to_string()), + hit_condition: None, + log_message: None, + } + } + + #[test] + fn insertion_before_statement_moves_line_breakpoint_with_its_code() { + let source = "class Main {\n void run() {}\n}\n"; + let line_start = source.find(" void").unwrap(); + let result = relocate_breakpoints(RelocateBreakpointsRequest { + source: source.to_string(), + edit: DebugSourceEdit { + start_utf16_offset: line_start, + end_utf16_offset: line_start, + replacement: "\n".to_string(), + }, + breakpoints: vec![breakpoint(2)], + }) + .unwrap(); + + assert_eq!(result.breakpoints, vec![breakpoint(3)]); + } + + #[test] + fn editing_after_statement_anchor_keeps_breakpoint_on_its_line() { + let source = "class Main {\n void run() {}\n}\n"; + let edit_offset = source.find("run").unwrap() + "run".len(); + let result = relocate_breakpoints(RelocateBreakpointsRequest { + source: source.to_string(), + edit: DebugSourceEdit { + start_utf16_offset: edit_offset, + end_utf16_offset: edit_offset, + replacement: "Now".to_string(), + }, + breakpoints: vec![breakpoint(2)], + }) + .unwrap(); + + assert_eq!(result.breakpoints, vec![breakpoint(2)]); + } + + #[test] + fn deleting_preceding_lines_moves_breakpoint_up() { + let source = "class Main {\n int value;\n void run() {}\n}\n"; + let deletion_start = source.find(" int").unwrap(); + let deletion_end = source.find(" void").unwrap(); + let result = relocate_breakpoints(RelocateBreakpointsRequest { + source: source.to_string(), + edit: DebugSourceEdit { + start_utf16_offset: deletion_start, + end_utf16_offset: deletion_end, + replacement: String::new(), + }, + breakpoints: vec![breakpoint(3)], + }) + .unwrap(); + + assert_eq!(result.breakpoints, vec![breakpoint(2)]); + } + + #[test] + fn utf16_offsets_preserve_columns_after_non_bmp_text() { + let source = "class Main {\n String icon = \"🚀\"; run();\n}\n"; + let anchor_byte = source.find("run").unwrap(); + let anchor_utf16 = source[..anchor_byte].encode_utf16().count(); + let mut expected = breakpoint(2); + expected.column = Some(29); + let result = relocate_breakpoints(RelocateBreakpointsRequest { + source: source.to_string(), + edit: DebugSourceEdit { + start_utf16_offset: anchor_utf16, + end_utf16_offset: anchor_utf16, + replacement: "next".to_string(), + }, + breakpoints: vec![SourceBreakpoint { + line: 2, + column: Some(25), + ..breakpoint(2) + }], + }) + .unwrap(); + + assert_eq!(result.breakpoints, vec![expected]); + } + + #[test] + fn breakpoints_that_collapse_to_one_location_are_deduplicated() { + let source = "first();\nsecond();\n"; + let mut deleted_breakpoint = breakpoint(1); + deleted_breakpoint.condition = Some("deleted".to_string()); + let mut surviving_breakpoint = breakpoint(2); + surviving_breakpoint.condition = Some("surviving".to_string()); + let result = relocate_breakpoints(RelocateBreakpointsRequest { + source: source.to_string(), + edit: DebugSourceEdit { + start_utf16_offset: 0, + end_utf16_offset: "first();\n".len(), + replacement: String::new(), + }, + breakpoints: vec![deleted_breakpoint, surviving_breakpoint.clone()], + }) + .unwrap(); + + surviving_breakpoint.line = 1; + assert_eq!(result.breakpoints, vec![surviving_breakpoint]); + } + + #[test] + fn edit_cannot_split_a_utf16_surrogate_pair() { + let error = relocate_breakpoints(RelocateBreakpointsRequest { + source: "🚀".to_string(), + edit: DebugSourceEdit { + start_utf16_offset: 1, + end_utf16_offset: 1, + replacement: String::new(), + }, + breakpoints: vec![], + }) + .unwrap_err(); + + assert!(matches!(error.code, ErrorCode::InvalidRequest)); + } +} diff --git a/rust/lithe-core/src/debug/mod.rs b/rust/lithe-core/src/debug/mod.rs index 0cf8f677..ccbc648b 100644 --- a/rust/lithe-core/src/debug/mod.rs +++ b/rust/lithe-core/src/debug/mod.rs @@ -1,10 +1,12 @@ //! Transport-neutral Debug Adapter Protocol state and normalized debugger models. +mod breakpoint_relocation; mod engine; mod java_test; mod protocol; mod types; +pub(crate) use breakpoint_relocation::*; pub(crate) use engine::*; pub(crate) use java_test::*; pub(crate) use types::*; diff --git a/rust/lithe-core/src/debug/types.rs b/rust/lithe-core/src/debug/types.rs index 5e94386d..828d7284 100644 --- a/rust/lithe-core/src/debug/types.rs +++ b/rust/lithe-core/src/debug/types.rs @@ -162,7 +162,7 @@ pub struct LaunchRequest { pub configuration: DebugLaunchConfiguration, } -#[derive(Debug, Clone, Deserialize, Eq, PartialEq)] +#[derive(Debug, Clone, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] /// One requested source breakpoint using one-based DAP coordinates. pub struct SourceBreakpoint { diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index dd3abf76..0547face 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -91,6 +91,8 @@ pub enum CoreCommand { DebugJavaTestLaunch, /// Returns or normalizes portable stepping filters (`debug.steppingFilters`). DebugSteppingFilters, + /// Moves source breakpoints across one UTF-16 editor mutation (`debug.relocateBreakpoints`). + DebugRelocateBreakpoints, /// Replaces breakpoints for one source file (`debug.setBreakpoints`). DebugSetBreakpoints, /// Replaces exception filters for one debug session (`debug.setExceptionBreakpoints`). @@ -265,6 +267,7 @@ impl CoreCommand { "debug.launch" => Some(Self::DebugLaunch), "debug.javaTestLaunch" => Some(Self::DebugJavaTestLaunch), "debug.steppingFilters" => Some(Self::DebugSteppingFilters), + "debug.relocateBreakpoints" => Some(Self::DebugRelocateBreakpoints), "debug.setBreakpoints" => Some(Self::DebugSetBreakpoints), "debug.setExceptionBreakpoints" => Some(Self::DebugSetExceptionBreakpoints), "debug.setFunctionBreakpoints" => Some(Self::DebugSetFunctionBreakpoints), @@ -387,6 +390,7 @@ mod tests { "debug.launch", "debug.javaTestLaunch", "debug.steppingFilters", + "debug.relocateBreakpoints", "debug.setBreakpoints", "debug.setExceptionBreakpoints", "debug.setFunctionBreakpoints", diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index 661fda12..1819a85d 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -554,6 +554,24 @@ fn execute(request: &str) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::DebugRelocateBreakpoints => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid debug relocate-breakpoints request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::debug::relocate_breakpoints) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Debug breakpoint relocation should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::DebugSetBreakpoints => { match serde_json::from_value::(parsed.payload) .map_err(|error| { diff --git a/rust/lithe-core/src/tests/protocol.rs b/rust/lithe-core/src/tests/protocol.rs index 9d4fcc40..f35d5059 100644 --- a/rust/lithe-core/src/tests/protocol.rs +++ b/rust/lithe-core/src/tests/protocol.rs @@ -89,6 +89,35 @@ fn debug_stepping_filters_match_the_shared_contract_fixture() { } } +#[test] +fn debug_breakpoint_relocation_matches_the_shared_contract_fixture() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/debug/breakpoint-relocation-v1.json" + ))) + .expect("debug breakpoint-relocation fixture should be valid JSON"); + + for case in fixture["cases"] + .as_array() + .expect("debug breakpoint-relocation fixture should contain cases") + { + let request = serde_json::json!({ + "id": case["name"], + "command": "debug.relocateBreakpoints", + "payload": case["payload"] + }); + let response: Value = serde_json::from_str(&execute_json(&request.to_string())) + .expect("debug breakpoint-relocation response should be JSON"); + + assert_eq!(response["ok"], true, "fixture case {}", case["name"]); + assert_eq!( + response["data"], case["expected"], + "fixture case {}", + case["name"] + ); + } +} + #[test] fn java_test_debug_launch_matches_the_shared_contract_fixture() { let fixture: Value = serde_json::from_str(include_str!(concat!( diff --git a/shared/contracts/application-boundary.md b/shared/contracts/application-boundary.md index c0652c12..928a38a9 100644 --- a/shared/contracts/application-boundary.md +++ b/shared/contracts/application-boundary.md @@ -30,7 +30,7 @@ verification scripts are the executable source of boundary checks. | Runtime | Java/Maven requirements, normalized candidates, and effective toolchain references | JDK/Maven probing and executable paths | | Language tooling | provider catalog, local fallback results, complete LSP process/session runtime, capabilities, diagnostics, UTF-16 edits, and normalized feature results | executable/environment discovery and UI provider routing | | Java/Maven/Spring | deterministic Maven-root selection, project structure, modules and profiles; compiler diagnostic parsing; Java source structure, symbols, code vision, run-configuration detection, Spring configuration/bean/endpoint indexing, and JDTLS/Java Debug adapter policy | JDK/Maven discovery, local dependency-repository selection, Java/Maven child processes, and sockets | -| Run/Debug | versioned configuration documents, three-layer resolution, diagnostics, platform-neutral launch plans, DAP framing/state, breakpoints, stepping filters, threads, stacks, variables, and events | project and preference persistence, adapter discovery, child processes, sockets, native termination, and UI | +| Run/Debug | versioned configuration documents, three-layer resolution, diagnostics, platform-neutral launch plans, DAP framing/state, breakpoint relocation, stepping filters, threads, stacks, variables, and events | project and preference persistence, native edit reporting, adapter discovery, child processes, sockets, native termination, and UI | | Terminal | input bytes, output bytes, lifecycle | PTY/ConPTY, shell and environment | | Workbench background | versioned source (`none`, bundled slot `01`–`10`, or `custom`) and opacity | UI, image rendering, bundled-resource packaging, local-image access permission and persistence | | Local History | revision metadata, text content, restore result | persistence location and file operations | diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 1d11d065..c03bf22a 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -85,6 +85,7 @@ stable error code and a user-facing message: | `debug.launch` | Queue a launch or attach request, including during initialization | | `debug.javaTestLaunch` | Normalize JUnit or TestNG launch metadata into Java DAP arguments | | `debug.steppingFilters` | Return adapter defaults or normalize portable stepping filters | +| `debug.relocateBreakpoints` | Move source breakpoints across one exact UTF-16 editor replacement | | `debug.setBreakpoints` | Replace and deterministically order one source's DAP breakpoints | | `debug.setExceptionBreakpoints` | Replace and deterministically order one session's exception filters | | `debug.setFunctionBreakpoints` | Replace and deterministically order one session's named function breakpoints | diff --git a/shared/fixtures/debug/breakpoint-relocation-v1.json b/shared/fixtures/debug/breakpoint-relocation-v1.json new file mode 100644 index 00000000..f9e6fdc9 --- /dev/null +++ b/shared/fixtures/debug/breakpoint-relocation-v1.json @@ -0,0 +1,38 @@ +{ + "version": 1, + "cases": [ + { + "name": "inserted line moves breakpoint with statement", + "payload": { + "source": "class Main {\n void run() {}\n}\n", + "edit": { + "startUtf16Offset": 13, + "endUtf16Offset": 13, + "replacement": "\n" + }, + "breakpoints": [ + { + "line": 2, + "column": null, + "enabled": true, + "condition": "ready", + "hitCondition": "3", + "logMessage": null + } + ] + }, + "expected": { + "breakpoints": [ + { + "line": 3, + "column": null, + "enabled": true, + "condition": "ready", + "hitCondition": "3", + "logMessage": null + } + ] + } + } + ] +} From 8c66f60abcdda5c1600bab8f2bc26726c48faaf2 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sun, 30 Aug 2026 09:25:35 +0800 Subject: [PATCH 40/66] feat(debug): add project breakpoint manager --- .../zh-Hans.lproj/Localizable.strings | 40 + .../AppModel/AppModel+Development.swift | 14 +- .../AppModel/AppModel+FeatureState.swift | 2 +- .../Lithe/Models/AppModel/AppModel.swift | 6 +- .../AppModel/AppModelSupportTypes.swift | 11 + .../Models/Keymap/LitheCommandCatalog.swift | 1 + macos/Sources/Lithe/Models/LitheAction.swift | 1 + .../Lithe/Views/Debug/GenericDebugView.swift | 917 ++++++++++-------- .../Lithe/Views/Workbench/WorkbenchView.swift | 10 +- .../LitheTests/AppLocalizationTests.swift | 38 + ...ugBreakpointManagerPresentationTests.swift | 62 ++ .../LitheTests/KeyboardShortcutTests.swift | 12 +- 12 files changed, 702 insertions(+), 412 deletions(-) create mode 100644 macos/Tests/LitheTests/DebugBreakpointManagerPresentationTests.swift diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 930a7f92..1d031025 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -879,6 +879,46 @@ "Enter the next function call" = "进入下一个函数调用"; "Debug: Step Out" = "调试:步出"; "Return from the current function" = "从当前函数返回"; +"View Breakpoints" = "查看断点"; +"Manage all project breakpoints" = "管理项目中的所有断点"; +"View breakpoints (⌘⇧F8)" = "查看断点(⌘⇧F8)"; +"View breakpoints" = "查看断点"; +"Loading breakpoints…" = "正在加载断点…"; +"Manage project breakpoints without starting a debug session" = "无需启动调试会话即可管理项目断点"; +"Line Breakpoints" = "行断点"; +"Exception Breakpoints" = "异常断点"; +"Method Breakpoints" = "方法断点"; +"Field Breakpoints" = "字段断点"; +"Mute Line Breakpoints" = "暂停行断点"; +"Unmute Line Breakpoints" = "恢复行断点"; +"Click the editor gutter to add a breakpoint" = "点击编辑器左侧行号区域添加断点"; +"Add a class or method name" = "添加类名或方法名"; +"Right-click a field while paused to add a breakpoint" = "暂停时右键单击字段以添加断点"; +"Remove All" = "全部移除"; +"Disable breakpoint" = "禁用断点"; +"Enable breakpoint" = "启用断点"; +"Edit…" = "编辑…"; +"Edit exception breakpoint" = "编辑异常断点"; +"Add method breakpoint" = "添加方法断点"; +"Breakpoint actions" = "断点操作"; +"Line breakpoint actions" = "行断点操作"; +"Open %@" = "打开 %@"; +"Actions for %@" = "%@ 的操作"; +"Disable %@ exception breakpoint" = "禁用异常断点 %@"; +"Enable %@ exception breakpoint" = "启用异常断点 %@"; +"Edit %@ exception breakpoint" = "编辑异常断点 %@"; +"Disable %@ method breakpoint" = "禁用方法断点 %@"; +"Enable %@ method breakpoint" = "启用方法断点 %@"; +"Edit %@ method breakpoint" = "编辑方法断点 %@"; +"Actions for %@ method breakpoint" = "方法断点 %@ 的操作"; +"Disable %@ field breakpoint" = "禁用字段断点 %@"; +"Enable %@ field breakpoint" = "启用字段断点 %@"; +"Edit %@ field breakpoint" = "编辑字段断点 %@"; +"Actions for %@ field breakpoint" = "字段断点 %@ 的操作"; +"If: %@" = "条件:%@"; +"Hit: %@" = "命中次数:%@"; +"Verified" = "已验证"; +"Pending verification" = "等待验证"; "Open Project" = "打开项目"; "Where would you like to open the project ‘%@’?" = "你想在哪里打开项目“%@”?"; "Don't ask again" = "不再询问"; diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift index c985c3b0..1d389658 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift @@ -327,6 +327,16 @@ extension AppModel { isRunVisible = false } + func showDebugBreakpointManager() { + guard let requestedWorkspaceURL = workspaceURL else { return } + Task { [weak self] in + guard let self, + await activateDebugModule() != nil, + workspaceURL == requestedWorkspaceURL else { return } + debugBreakpointPresentation.isManagerPresented = true + } + } + func startDebugging() { Task { [weak self] in await self?.startDebuggingAfterActivation() } } @@ -765,7 +775,7 @@ extension AppModel { func editDebugBreakpoint(fileURL: URL, line: Int) { let normalizedURL = fileURL.standardizedFileURL - pendingDebugBreakpointEditor = genericDebugFeatureIfActive?.breakpoints + debugBreakpointPresentation.pendingEditor = genericDebugFeatureIfActive?.breakpoints .filter { $0.fileURL.standardizedFileURL == normalizedURL && $0.line == line } @@ -779,7 +789,7 @@ extension AppModel { hitCondition: String?, logMessage: String? ) { - pendingDebugBreakpointEditor = nil + debugBreakpointPresentation.pendingEditor = nil guard let expectedWorkspaceURL = workspaceURL, workspaceRelativePath( for: breakpoint.fileURL, diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift index 38b5f7ab..9a309dbb 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift @@ -351,7 +351,7 @@ extension AppModel { "replace-in-project", "project-local-history", "run", "debug", "stop-run", "stop-debug", "toggle-terminal", "toggle-problems", "toggle-maven", "toggle-git-log", "toggle-run", "toggle-tests", - "toggle-debug", "spring-endpoints": + "toggle-debug", "view-breakpoints", "spring-endpoints": workspaceURL != nil default: false diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 9cb875ba..3b2e4cfd 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -122,7 +122,7 @@ final class AppModel: ObservableObject, Identifiable { @Published var isMavenVisible = false @Published var isSpringVisible = false @Published var isDebugVisible = false - @Published var pendingDebugBreakpointEditor: GenericDebugBreakpoint? + @Published var debugBreakpointPresentation = DebugBreakpointPresentationState() @Published var isDiscourseCommunityVisible = false @Published var isImplementationChooserVisible = false var languageProviderCatalog: LanguageProviderCatalog { languageToolingFeature.catalog } @@ -936,7 +936,7 @@ final class AppModel: ObservableObject, Identifiable { mavenFeatureIfActive?.reset() runFeatureIfActive?.reset() genericDebugFeatureIfActive?.reset() - pendingDebugBreakpointEditor = nil + debugBreakpointPresentation.reset() clearLanguageNavigationProjection() javaFeature.stop() springFeature.reset() @@ -1049,7 +1049,7 @@ final class AppModel: ObservableObject, Identifiable { mavenFeatureIfActive?.reset() runFeatureIfActive?.reset() genericDebugFeatureIfActive?.reset() - pendingDebugBreakpointEditor = nil + debugBreakpointPresentation.reset() javaFeature.stop() springFeature.reset() editorChrome.reset() diff --git a/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift b/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift index f14307d8..9981b1a2 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift @@ -1,5 +1,6 @@ import Foundation import LitheCoreContracts +import LitheDebugModule /// Product-level availability switches for integrations that require external /// credentials or services. Keeping these switches in one place lets the UI @@ -30,6 +31,16 @@ struct WorkbenchNotification: Identifiable, Equatable { } } +struct DebugBreakpointPresentationState { + var isManagerPresented = false + var pendingEditor: GenericDebugBreakpoint? + + mutating func reset() { + isManagerPresented = false + pendingEditor = nil + } +} + enum SidebarDestination: String, CaseIterable, Identifiable { case project case changes diff --git a/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift b/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift index 7e0ec0eb..12db3729 100644 --- a/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift +++ b/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift @@ -25,6 +25,7 @@ enum LitheCommandCatalog { command("debug-step-over", "Debug: Step Over", "Execute the next source line", .run, "f8"), command("debug-step-into", "Debug: Step Into", "Enter the next function call", .run, "f7"), command("debug-step-out", "Debug: Step Out", "Return from the current function", .run, "f8", [.shift]), + command("view-breakpoints", "View Breakpoints", "Manage all project breakpoints", .run, "f8", [.shift, .command]), LitheCommandDefinition( id: "search-everywhere", diff --git a/macos/Sources/Lithe/Models/LitheAction.swift b/macos/Sources/Lithe/Models/LitheAction.swift index b8d2011a..1b1ce9db 100644 --- a/macos/Sources/Lithe/Models/LitheAction.swift +++ b/macos/Sources/Lithe/Models/LitheAction.swift @@ -63,6 +63,7 @@ enum LitheActionRegistry { action("debug-step-over", model: model) { model.stepOverDebugging() }, action("debug-step-into", model: model) { model.stepIntoDebugging() }, action("debug-step-out", model: model) { model.stepOutDebugging() }, + action("view-breakpoints", model: model) { model.showDebugBreakpointManager() }, action("open-project", model: model) { model.chooseProject() }, action("close-project", model: model) { model.closeProject() }, action("settings", model: model) { model.showSettings() }, diff --git a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift index 65b5d528..a9b2d494 100644 --- a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift +++ b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift @@ -6,10 +6,6 @@ struct GenericDebugView: View { @EnvironmentObject private var model: AppModel @ObservedObject var feature: GenericDebugFeatureModel @State private var evaluateExpression = "" - @State private var editingBreakpoint: GenericDebugBreakpoint? - @State private var editingExceptionBreakpoint: GenericDebugExceptionBreakpoint? - @State private var functionBreakpointEditor: FunctionBreakpointEditorContext? - @State private var editingDataBreakpoint: GenericDebugDataBreakpoint? @State private var editingVariable: DebugVariable? @State private var watchEditor: WatchEditorContext? @State private var smartStepTargets: [DebugStepInTarget] = [] @@ -33,57 +29,6 @@ struct GenericDebugView: View { } } .litheWorkbenchSurface(LitheTheme.editor) - .sheet(item: $editingBreakpoint) { breakpoint in - BreakpointEditorView(breakpoint: breakpoint) { - feature.updateBreakpoint( - fileURL: breakpoint.fileURL, - line: breakpoint.line, - enabled: $0.enabled, - condition: $0.condition, - hitCondition: $0.hitCondition, - logMessage: $0.logMessage - ) - } - } - .sheet(item: $editingExceptionBreakpoint) { breakpoint in - ExceptionBreakpointEditorView(breakpoint: breakpoint) { - feature.updateExceptionBreakpoint( - breakpoint, - enabled: $0.enabled, - condition: $0.condition - ) - } - } - .sheet(item: $functionBreakpointEditor) { context in - FunctionBreakpointEditorView(breakpoint: context.breakpoint) { value in - if let breakpoint = context.breakpoint { - feature.updateFunctionBreakpoint( - breakpoint, - name: value.name, - enabled: value.enabled, - condition: value.condition, - hitCondition: value.hitCondition - ) - } else { - feature.addFunctionBreakpoint( - name: value.name, - condition: value.condition, - hitCondition: value.hitCondition - ) - } - } - } - .sheet(item: $editingDataBreakpoint) { breakpoint in - DataBreakpointEditorView(breakpoint: breakpoint) { value in - feature.updateDataBreakpoint( - breakpoint, - enabled: value.enabled, - accessType: value.accessType, - condition: value.condition, - hitCondition: value.hitCondition - ) - } - } .sheet(item: $editingVariable) { variable in VariableValueEditorView(variable: variable) { feature.setVariable(variable, value: $0) @@ -130,7 +75,7 @@ struct GenericDebugView: View { case .debugger: inspector case .breakpoints: - breakpointInspector + DebugBreakpointManagerView(feature: feature) case .console: output } @@ -198,6 +143,12 @@ struct GenericDebugView: View { .lineLimit(1) } Spacer() + Button { model.showDebugBreakpointManager() } label: { + Image(systemName: "list.bullet.rectangle") + } + .litheIconButton() + .help("View breakpoints (⌘⇧F8)") + .accessibilityLabel("View breakpoints") if feature.javaSteppingFilters != nil { Button { isJavaSteppingSettingsPresented = true } label: { Image(systemName: "line.3.horizontal.decrease.circle") @@ -547,287 +498,6 @@ struct GenericDebugView: View { .litheWorkbenchSurface(LitheTheme.sidebar) } - private var breakpointInspector: some View { - ScrollView { - LazyVStack(alignment: .leading, spacing: 0) { - Group { - breakpointSectionHeader - if feature.breakpoints.isEmpty { - placeholder("Click the editor gutter to add a breakpoint") - } else { - ForEach(feature.breakpoints) { breakpoint in - HStack(spacing: 7) { - Button { - feature.setBreakpointEnabled( - breakpoint, - enabled: !breakpoint.enabled - ) - } label: { - Image(systemName: breakpointSymbol(breakpoint)) - .font(.system(size: 9)) - .foregroundStyle(breakpointColor(breakpoint)) - } - .buttonStyle(.plain) - .help(breakpoint.enabled ? "Disable breakpoint" : "Enable breakpoint") - Button { - model.openSourceLocation( - url: breakpoint.fileURL, - line: breakpoint.line, - column: breakpoint.column ?? 1 - ) - } label: { - VStack(alignment: .leading, spacing: 1) { - Text(breakpoint.title) - .font(.system(size: 11, design: .monospaced)) - .lineLimit(1) - if let detail = breakpointDetail(breakpoint) { - Text(detail) - .font(.system(size: 9.5, design: .monospaced)) - .foregroundStyle(LitheTheme.secondaryText) - .lineLimit(1) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .buttonStyle(.plain) - Menu { - Button("Edit…") { editingBreakpoint = breakpoint } - Button(breakpoint.enabled ? "Disable" : "Enable") { - feature.setBreakpointEnabled( - breakpoint, - enabled: !breakpoint.enabled - ) - } - Divider() - Button("Remove", role: .destructive) { - feature.removeBreakpoint(breakpoint) - } - } label: { - Image(systemName: "ellipsis") - } - .menuStyle(.borderlessButton) - .fixedSize() - } - .help(breakpoint.message ?? breakpoint.title) - .padding(.horizontal, 10) - .frame(minHeight: 31) - .opacity(breakpoint.enabled && !feature.areBreakpointsMuted ? 1 : 0.55) - .contextMenu { - Button("Edit…") { editingBreakpoint = breakpoint } - Button(breakpoint.enabled ? "Disable" : "Enable") { - feature.setBreakpointEnabled( - breakpoint, - enabled: !breakpoint.enabled - ) - } - Divider() - Button("Remove", role: .destructive) { - feature.removeBreakpoint(breakpoint) - } - } - } - } - if !feature.exceptionBreakpoints.isEmpty { - divider - sectionHeader("Exception Breakpoints", count: feature.exceptionBreakpoints.count) - ForEach(feature.exceptionBreakpoints) { breakpoint in - HStack(spacing: 7) { - Button { - feature.updateExceptionBreakpoint( - breakpoint, - enabled: !breakpoint.enabled, - condition: breakpoint.condition - ) - } label: { - Image(systemName: breakpoint.enabled ? "bolt.circle.fill" : "bolt.circle") - .font(.system(size: 10)) - .foregroundStyle( - breakpoint.enabled ? LitheTheme.error : LitheTheme.secondaryText - ) - } - .buttonStyle(.plain) - .help(breakpoint.enabled ? "Disable exception breakpoint" : "Enable exception breakpoint") - VStack(alignment: .leading, spacing: 1) { - Text(breakpoint.label) - .font(.system(size: 11)) - .lineLimit(1) - if let condition = breakpoint.condition { - Text("If: \(condition)") - .font(.system(size: 9.5, design: .monospaced)) - .foregroundStyle(LitheTheme.secondaryText) - .lineLimit(1) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - if breakpoint.supportsCondition { - Button { - editingExceptionBreakpoint = breakpoint - } label: { - Image(systemName: "ellipsis") - } - .buttonStyle(.plain) - .help("Edit exception breakpoint") - } - } - .help(breakpoint.description ?? breakpoint.label) - .padding(.horizontal, 10) - .frame(minHeight: 31) - .opacity(breakpoint.enabled ? 1 : 0.55) - .contextMenu { - Button(breakpoint.enabled ? "Disable" : "Enable") { - feature.updateExceptionBreakpoint( - breakpoint, - enabled: !breakpoint.enabled, - condition: breakpoint.condition - ) - } - if breakpoint.supportsCondition { - Button("Edit Condition…") { - editingExceptionBreakpoint = breakpoint - } - } - } - } - } - if feature.capabilities.supportsFunctionBreakpoints - || !feature.functionBreakpoints.isEmpty { - divider - functionBreakpointSectionHeader - if feature.functionBreakpoints.isEmpty { - placeholder("Add a class or method name") - } else { - ForEach(feature.functionBreakpoints) { breakpoint in - HStack(spacing: 7) { - Button { - feature.setFunctionBreakpointEnabled( - breakpoint, - enabled: !breakpoint.enabled - ) - } label: { - Image(systemName: "function") - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle( - breakpoint.enabled - ? (breakpoint.verified ? LitheTheme.error : LitheTheme.warning) - : LitheTheme.secondaryText - ) - } - .buttonStyle(.plain) - .help(breakpoint.enabled ? "Disable method breakpoint" : "Enable method breakpoint") - Button { - functionBreakpointEditor = FunctionBreakpointEditorContext( - breakpoint: breakpoint - ) - } label: { - VStack(alignment: .leading, spacing: 1) { - Text(breakpoint.name) - .font(.system(size: 11, design: .monospaced)) - .lineLimit(1) - if let detail = functionBreakpointDetail(breakpoint) { - Text(detail) - .font(.system(size: 9.5, design: .monospaced)) - .foregroundStyle(LitheTheme.secondaryText) - .lineLimit(1) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .buttonStyle(.plain) - Menu { - Button("Edit…") { - functionBreakpointEditor = FunctionBreakpointEditorContext( - breakpoint: breakpoint - ) - } - Button(breakpoint.enabled ? "Disable" : "Enable") { - feature.setFunctionBreakpointEnabled( - breakpoint, - enabled: !breakpoint.enabled - ) - } - Divider() - Button("Remove", role: .destructive) { - feature.removeFunctionBreakpoint(breakpoint) - } - } label: { - Image(systemName: "ellipsis") - } - .menuStyle(.borderlessButton) - .fixedSize() - } - .padding(.horizontal, 10) - .frame(minHeight: 31) - .opacity(breakpoint.enabled ? 1 : 0.55) - } - } - } - if feature.capabilities.supportsDataBreakpoints - || !feature.dataBreakpoints.isEmpty { - divider - sectionHeader("Field Breakpoints", count: feature.dataBreakpoints.count) - if feature.dataBreakpoints.isEmpty { - placeholder("Right-click a field while paused to add a breakpoint") - } else { - ForEach(feature.dataBreakpoints) { breakpoint in - HStack(spacing: 7) { - Button { - feature.setDataBreakpointEnabled( - breakpoint, - enabled: !breakpoint.enabled - ) - } label: { - Image(systemName: "eye.circle.fill") - .font(.system(size: 10)) - .foregroundStyle( - breakpoint.enabled - ? (breakpoint.verified ? LitheTheme.error : LitheTheme.warning) - : LitheTheme.secondaryText - ) - } - .buttonStyle(.plain) - Button { editingDataBreakpoint = breakpoint } label: { - VStack(alignment: .leading, spacing: 1) { - Text(breakpoint.label) - .font(.system(size: 11, design: .monospaced)) - .lineLimit(1) - Text(dataBreakpointDetail(breakpoint)) - .font(.system(size: 9.5, design: .monospaced)) - .foregroundStyle(LitheTheme.secondaryText) - .lineLimit(1) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .buttonStyle(.plain) - Menu { - Button("Edit…") { editingDataBreakpoint = breakpoint } - Button(breakpoint.enabled ? "Disable" : "Enable") { - feature.setDataBreakpointEnabled( - breakpoint, - enabled: !breakpoint.enabled - ) - } - Divider() - Button("Remove", role: .destructive) { - feature.removeDataBreakpoint(breakpoint) - } - } label: { Image(systemName: "ellipsis") } - .menuStyle(.borderlessButton) - .fixedSize() - } - .padding(.horizontal, 10) - .frame(minHeight: 31) - .opacity(breakpoint.enabled ? 1 : 0.55) - } - } - } - } - - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .litheWorkbenchSurface(LitheTheme.sidebar) - } - private func exceptionInspector(_ info: DebugExceptionInfo) -> some View { VStack(alignment: .leading, spacing: 7) { HStack(spacing: 7) { @@ -1008,6 +678,10 @@ struct GenericDebugView: View { Button("Connect to Running JVM") { isJavaAttachPresented = true } .buttonStyle(.bordered) .controlSize(.small) + Button("View Breakpoints") { model.showDebugBreakpointManager() } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(model.workspaceURL == nil) } .frame(maxWidth: .infinity, maxHeight: .infinity) } @@ -1034,9 +708,246 @@ struct GenericDebugView: View { .litheWorkbenchSurface(LitheTheme.toolHeader) } - private var breakpointSectionHeader: some View { + private func variableSymbol(_ variable: DebugVariable) -> String { + guard variable.isExpandable else { return "circle.fill" } + if feature.isVariableLoading(variable) { return "hourglass" } + return feature.isVariableExpanded(variable) ? "chevron.down" : "chevron.right" + } + + private func variableLoadMoreRow( + parentVariableID: String?, + nextCount: Int, + remainingCount: Int?, + depth: Int + ) -> some View { + let isLoading = feature.isVariablePageLoading(parentVariableID: parentVariableID) + return Button { + feature.loadMoreVariables(parentVariableID: parentVariableID) + } label: { + HStack(spacing: 6) { + if isLoading { + ProgressView().controlSize(.mini) + } else { + Image(systemName: "ellipsis.circle") + .font(.system(size: 9)) + } + Text(isLoading ? "Loading…" : "Load \(nextCount) more") + .font(LitheTheme.smallFont) + if let remainingCount, !isLoading { + Text("\(remainingCount) remaining") + .font(.system(size: 9.5, design: .monospaced)) + .foregroundStyle(LitheTheme.secondaryText) + } + Spacer(minLength: 0) + } + .foregroundStyle(LitheTheme.secondaryText) + .padding(.leading, 10 + CGFloat(depth * 14)) + .padding(.trailing, 10) + .frame(minHeight: 27) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(isLoading) + .accessibilityLabel(isLoading ? "Loading debugger variables" : "Load more debugger variables") + } + + private func placeholder(_ text: String) -> some View { + Text(text) + .font(LitheTheme.smallFont) + .foregroundStyle(LitheTheme.secondaryText) + .padding(10) + } + + private var divider: some View { + Rectangle().fill(LitheTheme.divider).frame(height: 1) + } + + private func rowButton( + selected: Bool, + action: @escaping () -> Void, + @ViewBuilder label: () -> Label + ) -> some View { + Button(action: action) { + HStack(spacing: 7) { + label() + Spacer(minLength: 0) + } + .font(.system(size: 11)) + .foregroundStyle(LitheTheme.primaryText) + .padding(.horizontal, 10) + .frame(minHeight: 28) + .background(selected ? LitheTheme.selection : Color.clear) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } +} + +struct DebugBreakpointManagerDialog: View { + @Environment(\.dismiss) private var dismiss + @ObservedObject var feature: GenericDebugFeatureModel + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 9) { + LitheIDEAIcon( + resourcePath: "toolwindows/toolWindowDebugger.svg", + size: 18, + fallbackSystemImage: "circle.fill" + ) + VStack(alignment: .leading, spacing: 1) { + Text("Breakpoints") + .font(.system(size: 14, weight: .semibold)) + Text("Manage project breakpoints without starting a debug session") + .font(LitheTheme.smallFont) + .foregroundStyle(LitheTheme.secondaryText) + } + Spacer(minLength: 16) + Button( + feature.areBreakpointsMuted + ? "Unmute Line Breakpoints" + : "Mute Line Breakpoints" + ) { + feature.toggleBreakpointMute() + } + .disabled(feature.breakpoints.isEmpty) + Button("Done") { dismiss() } + .keyboardShortcut(.defaultAction) + } + .padding(.horizontal, 14) + .frame(height: 52) + .litheWorkbenchSurface(LitheTheme.toolHeader) + Rectangle().fill(LitheTheme.divider).frame(height: 1) + DebugBreakpointManagerView(feature: feature, onNavigate: { dismiss() }) + } + .frame(minWidth: 720, idealWidth: 820, minHeight: 500, idealHeight: 580) + .litheWorkbenchSurface(LitheTheme.sidebar) + } +} + +struct DebugBreakpointManagerView: View { + @EnvironmentObject private var model: AppModel + @ObservedObject var feature: GenericDebugFeatureModel + let onNavigate: (() -> Void)? + + @State private var editingBreakpoint: GenericDebugBreakpoint? + @State private var editingExceptionBreakpoint: GenericDebugExceptionBreakpoint? + @State private var functionBreakpointEditor: FunctionBreakpointEditorContext? + @State private var editingDataBreakpoint: GenericDebugDataBreakpoint? + + init( + feature: GenericDebugFeatureModel, + onNavigate: (() -> Void)? = nil + ) { + self.feature = feature + self.onNavigate = onNavigate + } + + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + sourceBreakpointHeader + if feature.breakpoints.isEmpty { + placeholder("Click the editor gutter to add a breakpoint") + } else { + ForEach(feature.breakpoints) { breakpoint in + sourceBreakpointRow(breakpoint) + } + } + + if !feature.exceptionBreakpoints.isEmpty { + divider + sectionHeader("Exception Breakpoints", count: feature.exceptionBreakpoints.count) + ForEach(feature.exceptionBreakpoints) { breakpoint in + exceptionBreakpointRow(breakpoint) + } + } + + if feature.capabilities.supportsFunctionBreakpoints + || !feature.functionBreakpoints.isEmpty { + divider + functionBreakpointHeader + if feature.functionBreakpoints.isEmpty { + placeholder("Add a class or method name") + } else { + ForEach(feature.functionBreakpoints) { breakpoint in + functionBreakpointRow(breakpoint) + } + } + } + + if feature.capabilities.supportsDataBreakpoints + || !feature.dataBreakpoints.isEmpty { + divider + sectionHeader("Field Breakpoints", count: feature.dataBreakpoints.count) + if feature.dataBreakpoints.isEmpty { + placeholder("Right-click a field while paused to add a breakpoint") + } else { + ForEach(feature.dataBreakpoints) { breakpoint in + dataBreakpointRow(breakpoint) + } + } + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .litheWorkbenchSurface(LitheTheme.sidebar) + .sheet(item: $editingBreakpoint) { breakpoint in + BreakpointEditorView(breakpoint: breakpoint) { + feature.updateBreakpoint( + fileURL: breakpoint.fileURL, + line: breakpoint.line, + enabled: $0.enabled, + condition: $0.condition, + hitCondition: $0.hitCondition, + logMessage: $0.logMessage + ) + } + } + .sheet(item: $editingExceptionBreakpoint) { breakpoint in + ExceptionBreakpointEditorView(breakpoint: breakpoint) { + feature.updateExceptionBreakpoint( + breakpoint, + enabled: $0.enabled, + condition: $0.condition + ) + } + } + .sheet(item: $functionBreakpointEditor) { context in + FunctionBreakpointEditorView(breakpoint: context.breakpoint) { value in + if let breakpoint = context.breakpoint { + feature.updateFunctionBreakpoint( + breakpoint, + name: value.name, + enabled: value.enabled, + condition: value.condition, + hitCondition: value.hitCondition + ) + } else { + feature.addFunctionBreakpoint( + name: value.name, + condition: value.condition, + hitCondition: value.hitCondition + ) + } + } + } + .sheet(item: $editingDataBreakpoint) { breakpoint in + DataBreakpointEditorView(breakpoint: breakpoint) { value in + feature.updateDataBreakpoint( + breakpoint, + enabled: value.enabled, + accessType: value.accessType, + condition: value.condition, + hitCondition: value.hitCondition + ) + } + } + } + + private var sourceBreakpointHeader: some View { HStack { - Text("Breakpoints") + Text("Line Breakpoints") .font(.system(size: 10.5, weight: .semibold)) .foregroundStyle(LitheTheme.secondaryText) Spacer() @@ -1044,7 +955,11 @@ struct GenericDebugView: View { .font(.system(size: 9.5, design: .monospaced)) .foregroundStyle(LitheTheme.secondaryText) Menu { - Button(feature.areBreakpointsMuted ? "Unmute All" : "Mute All") { + Button( + feature.areBreakpointsMuted + ? "Unmute Line Breakpoints" + : "Mute Line Breakpoints" + ) { feature.toggleBreakpointMute() } Button("Remove All", role: .destructive) { @@ -1057,13 +972,14 @@ struct GenericDebugView: View { .menuStyle(.borderlessButton) .fixedSize() .help("Breakpoint actions") + .accessibilityLabel("Line breakpoint actions") } .padding(.horizontal, 10) - .frame(height: 27) + .frame(height: 29) .litheWorkbenchSurface(LitheTheme.toolHeader) } - private var functionBreakpointSectionHeader: some View { + private var functionBreakpointHeader: some View { HStack { Text("Method Breakpoints") .font(.system(size: 10.5, weight: .semibold)) @@ -1079,58 +995,256 @@ struct GenericDebugView: View { } .buttonStyle(.plain) .help("Add method breakpoint") + .accessibilityLabel("Add method breakpoint") } .padding(.horizontal, 10) - .frame(height: 27) + .frame(height: 29) .litheWorkbenchSurface(LitheTheme.toolHeader) } - private func breakpointSymbol(_ breakpoint: GenericDebugBreakpoint) -> String { - if breakpoint.isLogpoint { return breakpoint.enabled ? "diamond.fill" : "diamond" } - return breakpoint.enabled ? "circle.fill" : "circle" + private func sourceBreakpointRow(_ breakpoint: GenericDebugBreakpoint) -> some View { + HStack(spacing: 7) { + Button { + feature.setBreakpointEnabled(breakpoint, enabled: !breakpoint.enabled) + } label: { + Image(systemName: breakpointSymbol(breakpoint)) + .font(.system(size: 9)) + .foregroundStyle(breakpointColor(breakpoint)) + } + .buttonStyle(.plain) + .help(breakpoint.enabled ? "Disable breakpoint" : "Enable breakpoint") + .accessibilityLabel(breakpoint.enabled ? "Disable breakpoint" : "Enable breakpoint") + Button { + model.openSourceLocation( + url: breakpoint.fileURL, + line: breakpoint.line, + column: breakpoint.column ?? 1 + ) + onNavigate?() + } label: { + VStack(alignment: .leading, spacing: 1) { + Text(breakpoint.title) + .font(.system(size: 11, design: .monospaced)) + .lineLimit(1) + if let detail = breakpointDetail(breakpoint) { + Text(detail) + .font(.system(size: 9.5, design: .monospaced)) + .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + .accessibilityLabel("Open \(breakpoint.title)") + Menu { + Button("Edit…") { editingBreakpoint = breakpoint } + Button(breakpoint.enabled ? "Disable" : "Enable") { + feature.setBreakpointEnabled(breakpoint, enabled: !breakpoint.enabled) + } + Divider() + Button("Remove", role: .destructive) { + feature.removeBreakpoint(breakpoint) + } + } label: { + Image(systemName: "ellipsis") + } + .menuStyle(.borderlessButton) + .fixedSize() + .accessibilityLabel("Actions for \(breakpoint.title)") + } + .help(breakpoint.message ?? breakpoint.title) + .padding(.horizontal, 10) + .frame(minHeight: 33) + .opacity(breakpoint.enabled && !feature.areBreakpointsMuted ? 1 : 0.55) + .contextMenu { + Button("Edit…") { editingBreakpoint = breakpoint } + Button(breakpoint.enabled ? "Disable" : "Enable") { + feature.setBreakpointEnabled(breakpoint, enabled: !breakpoint.enabled) + } + Divider() + Button("Remove", role: .destructive) { feature.removeBreakpoint(breakpoint) } + } } - private func variableSymbol(_ variable: DebugVariable) -> String { - guard variable.isExpandable else { return "circle.fill" } - if feature.isVariableLoading(variable) { return "hourglass" } - return feature.isVariableExpanded(variable) ? "chevron.down" : "chevron.right" + private func exceptionBreakpointRow( + _ breakpoint: GenericDebugExceptionBreakpoint + ) -> some View { + HStack(spacing: 7) { + Button { + feature.updateExceptionBreakpoint( + breakpoint, + enabled: !breakpoint.enabled, + condition: breakpoint.condition + ) + } label: { + Image(systemName: breakpoint.enabled ? "bolt.circle.fill" : "bolt.circle") + .font(.system(size: 10)) + .foregroundStyle(breakpoint.enabled ? LitheTheme.error : LitheTheme.secondaryText) + } + .buttonStyle(.plain) + .accessibilityLabel( + breakpoint.enabled + ? "Disable \(breakpoint.label) exception breakpoint" + : "Enable \(breakpoint.label) exception breakpoint" + ) + VStack(alignment: .leading, spacing: 1) { + Text(breakpoint.label).font(.system(size: 11)).lineLimit(1) + if let condition = breakpoint.condition { + Text("If: \(condition)") + .font(.system(size: 9.5, design: .monospaced)) + .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + if breakpoint.supportsCondition { + Button { editingExceptionBreakpoint = breakpoint } label: { + Image(systemName: "ellipsis") + } + .buttonStyle(.plain) + .help("Edit exception breakpoint") + .accessibilityLabel("Edit \(breakpoint.label) exception breakpoint") + } + } + .help(breakpoint.description ?? breakpoint.label) + .padding(.horizontal, 10) + .frame(minHeight: 33) + .opacity(breakpoint.enabled ? 1 : 0.55) } - private func variableLoadMoreRow( - parentVariableID: String?, - nextCount: Int, - remainingCount: Int?, - depth: Int + private func functionBreakpointRow( + _ breakpoint: GenericDebugFunctionBreakpoint ) -> some View { - let isLoading = feature.isVariablePageLoading(parentVariableID: parentVariableID) - return Button { - feature.loadMoreVariables(parentVariableID: parentVariableID) - } label: { - HStack(spacing: 6) { - if isLoading { - ProgressView().controlSize(.mini) - } else { - Image(systemName: "ellipsis.circle") - .font(.system(size: 9)) + HStack(spacing: 7) { + Button { + feature.setFunctionBreakpointEnabled(breakpoint, enabled: !breakpoint.enabled) + } label: { + Image(systemName: "function") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle( + breakpoint.enabled + ? (breakpoint.verified ? LitheTheme.error : LitheTheme.warning) + : LitheTheme.secondaryText + ) + } + .buttonStyle(.plain) + .accessibilityLabel( + breakpoint.enabled + ? "Disable \(breakpoint.name) method breakpoint" + : "Enable \(breakpoint.name) method breakpoint" + ) + Button { + functionBreakpointEditor = FunctionBreakpointEditorContext(breakpoint: breakpoint) + } label: { + VStack(alignment: .leading, spacing: 1) { + Text(breakpoint.name) + .font(.system(size: 11, design: .monospaced)) + .lineLimit(1) + if let detail = functionBreakpointDetail(breakpoint) { + Text(detail) + .font(.system(size: 9.5, design: .monospaced)) + .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(1) + } } - Text(isLoading ? "Loading…" : "Load \(nextCount) more") - .font(LitheTheme.smallFont) - if let remainingCount, !isLoading { - Text("\(remainingCount) remaining") + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + .accessibilityLabel("Edit \(breakpoint.name) method breakpoint") + Menu { + Button("Edit…") { + functionBreakpointEditor = FunctionBreakpointEditorContext(breakpoint: breakpoint) + } + Button(breakpoint.enabled ? "Disable" : "Enable") { + feature.setFunctionBreakpointEnabled(breakpoint, enabled: !breakpoint.enabled) + } + Divider() + Button("Remove", role: .destructive) { + feature.removeFunctionBreakpoint(breakpoint) + } + } label: { + Image(systemName: "ellipsis") + } + .menuStyle(.borderlessButton) + .fixedSize() + .accessibilityLabel("Actions for \(breakpoint.name) method breakpoint") + } + .padding(.horizontal, 10) + .frame(minHeight: 33) + .opacity(breakpoint.enabled ? 1 : 0.55) + } + + private func dataBreakpointRow(_ breakpoint: GenericDebugDataBreakpoint) -> some View { + HStack(spacing: 7) { + Button { + feature.setDataBreakpointEnabled(breakpoint, enabled: !breakpoint.enabled) + } label: { + Image(systemName: "eye.circle.fill") + .font(.system(size: 10)) + .foregroundStyle( + breakpoint.enabled + ? (breakpoint.verified ? LitheTheme.error : LitheTheme.warning) + : LitheTheme.secondaryText + ) + } + .buttonStyle(.plain) + .accessibilityLabel( + breakpoint.enabled + ? "Disable \(breakpoint.label) field breakpoint" + : "Enable \(breakpoint.label) field breakpoint" + ) + Button { editingDataBreakpoint = breakpoint } label: { + VStack(alignment: .leading, spacing: 1) { + Text(breakpoint.label) + .font(.system(size: 11, design: .monospaced)) + .lineLimit(1) + Text(dataBreakpointDetail(breakpoint)) .font(.system(size: 9.5, design: .monospaced)) .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(1) } - Spacer(minLength: 0) + .frame(maxWidth: .infinity, alignment: .leading) } - .foregroundStyle(LitheTheme.secondaryText) - .padding(.leading, 10 + CGFloat(depth * 14)) - .padding(.trailing, 10) - .frame(minHeight: 27) - .contentShape(Rectangle()) + .buttonStyle(.plain) + .accessibilityLabel("Edit \(breakpoint.label) field breakpoint") + Menu { + Button("Edit…") { editingDataBreakpoint = breakpoint } + Button(breakpoint.enabled ? "Disable" : "Enable") { + feature.setDataBreakpointEnabled(breakpoint, enabled: !breakpoint.enabled) + } + Divider() + Button("Remove", role: .destructive) { feature.removeDataBreakpoint(breakpoint) } + } label: { + Image(systemName: "ellipsis") + } + .menuStyle(.borderlessButton) + .fixedSize() + .accessibilityLabel("Actions for \(breakpoint.label) field breakpoint") } - .buttonStyle(.plain) - .disabled(isLoading) - .accessibilityLabel(isLoading ? "Loading debugger variables" : "Load more debugger variables") + .padding(.horizontal, 10) + .frame(minHeight: 33) + .opacity(breakpoint.enabled ? 1 : 0.55) + } + + private func sectionHeader(_ title: String, count: Int) -> some View { + HStack { + Text(LocalizedStringKey(title)) + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(LitheTheme.secondaryText) + Spacer() + Text(String(count)) + .font(.system(size: 9.5, design: .monospaced)) + .foregroundStyle(LitheTheme.secondaryText) + } + .padding(.horizontal, 10) + .frame(height: 29) + .litheWorkbenchSurface(LitheTheme.toolHeader) + } + + private func breakpointSymbol(_ breakpoint: GenericDebugBreakpoint) -> String { + if breakpoint.isLogpoint { return breakpoint.enabled ? "diamond.fill" : "diamond" } + return breakpoint.enabled ? "circle.fill" : "circle" } private func breakpointColor(_ breakpoint: GenericDebugBreakpoint) -> Color { @@ -1142,18 +1256,30 @@ struct GenericDebugView: View { } private func breakpointDetail(_ breakpoint: GenericDebugBreakpoint) -> String? { - if let logMessage = breakpoint.logMessage { return "Log: \(logMessage)" } - if let condition = breakpoint.condition { return "If: \(condition)" } - if let hitCondition = breakpoint.hitCondition { return "Hit: \(hitCondition)" } + if let logMessage = breakpoint.logMessage { + return String(format: String(localized: "Log: %@"), logMessage) + } + if let condition = breakpoint.condition { + return String(format: String(localized: "If: %@"), condition) + } + if let hitCondition = breakpoint.hitCondition { + return String(format: String(localized: "Hit: %@"), hitCondition) + } return breakpoint.message + ?? String(localized: breakpoint.verified ? "Verified" : "Pending verification") } private func functionBreakpointDetail( _ breakpoint: GenericDebugFunctionBreakpoint ) -> String? { - if let condition = breakpoint.condition { return "If: \(condition)" } - if let hitCondition = breakpoint.hitCondition { return "Hit: \(hitCondition)" } + if let condition = breakpoint.condition { + return String(format: String(localized: "If: %@"), condition) + } + if let hitCondition = breakpoint.hitCondition { + return String(format: String(localized: "Hit: %@"), hitCondition) + } return breakpoint.message + ?? String(localized: breakpoint.verified ? "Verified" : "Pending verification") } private func dataBreakpointDetail(_ breakpoint: GenericDebugDataBreakpoint) -> String { @@ -1161,10 +1287,13 @@ struct GenericDebugView: View { if let condition = breakpoint.condition { parts.append("if \(condition)") } if let hitCondition = breakpoint.hitCondition { parts.append("hit \(hitCondition)") } if let message = breakpoint.message { parts.append(message) } + if breakpoint.message == nil { + parts.append(breakpoint.verified ? "verified" : "pending verification") + } return parts.joined(separator: " · ") } - private func placeholder(_ text: String) -> some View { + private func placeholder(_ text: LocalizedStringKey) -> some View { Text(text) .font(LitheTheme.smallFont) .foregroundStyle(LitheTheme.secondaryText) @@ -1174,26 +1303,6 @@ struct GenericDebugView: View { private var divider: some View { Rectangle().fill(LitheTheme.divider).frame(height: 1) } - - private func rowButton( - selected: Bool, - action: @escaping () -> Void, - @ViewBuilder label: () -> Label - ) -> some View { - Button(action: action) { - HStack(spacing: 7) { - label() - Spacer(minLength: 0) - } - .font(.system(size: 11)) - .foregroundStyle(LitheTheme.primaryText) - .padding(.horizontal, 10) - .frame(minHeight: 28) - .background(selected ? LitheTheme.selection : Color.clear) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } } private enum DebugContent: CaseIterable, Identifiable { diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift index e2d6c7ce..d431a419 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift @@ -88,7 +88,7 @@ struct WorkbenchView: View { } } } - .sheet(item: $model.pendingDebugBreakpointEditor) { breakpoint in + .sheet(item: $model.debugBreakpointPresentation.pendingEditor) { breakpoint in BreakpointEditorView(breakpoint: breakpoint) { value in model.updateDebugBreakpoint( breakpoint, @@ -99,6 +99,14 @@ struct WorkbenchView: View { ) } } + .sheet(isPresented: $model.debugBreakpointPresentation.isManagerPresented) { + if let feature = model.genericDebugFeatureIfActive { + DebugBreakpointManagerDialog(feature: feature) + } else { + ProgressView("Loading breakpoints…") + .frame(minWidth: 640, minHeight: 420) + } + } .onAppear { updateWorkbenchBackgroundImage(model.workbenchBackgroundFeature.imageData) } diff --git a/macos/Tests/LitheTests/AppLocalizationTests.swift b/macos/Tests/LitheTests/AppLocalizationTests.swift index 880a6916..f4fd1855 100644 --- a/macos/Tests/LitheTests/AppLocalizationTests.swift +++ b/macos/Tests/LitheTests/AppLocalizationTests.swift @@ -160,6 +160,44 @@ struct AppLocalizationTests { #expect(translations["Java service failed to start: %@"] == "Java 服务启动失败:%@") } + @Test + func simplifiedChineseResourcesCoverBreakpointManager() throws { + let translations = try simplifiedChineseTranslations() + let requiredKeys = [ + "View Breakpoints", + "Manage all project breakpoints", + "View breakpoints (⌘⇧F8)", + "View breakpoints", + "Loading breakpoints…", + "Manage project breakpoints without starting a debug session", + "Line Breakpoints", + "Exception Breakpoints", + "Method Breakpoints", + "Field Breakpoints", + "Mute Line Breakpoints", + "Unmute Line Breakpoints", + "Click the editor gutter to add a breakpoint", + "Add a class or method name", + "Right-click a field while paused to add a breakpoint", + "Remove All", + "Disable breakpoint", + "Enable breakpoint", + "Edit…", + "Edit exception breakpoint", + "Add method breakpoint", + "Breakpoint actions", + "Line breakpoint actions", + "If: %@", + "Hit: %@", + "Verified", + "Pending verification" + ] + + for key in requiredKeys { + #expect(translations[key] != nil, "Missing breakpoint manager translation: \(key)") + } + } + private func simplifiedChineseTranslations() throws -> [String: String] { let repositoryRoot = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() diff --git a/macos/Tests/LitheTests/DebugBreakpointManagerPresentationTests.swift b/macos/Tests/LitheTests/DebugBreakpointManagerPresentationTests.swift new file mode 100644 index 00000000..618a9f88 --- /dev/null +++ b/macos/Tests/LitheTests/DebugBreakpointManagerPresentationTests.swift @@ -0,0 +1,62 @@ +import Foundation +import Testing +@testable import Lithe + +@Suite("Debug breakpoint manager presentation") +@MainActor +struct DebugBreakpointManagerPresentationTests { + @Test + func doesNotPresentWithoutAWorkspace() { + let model = makeModel() + + model.showDebugBreakpointManager() + + #expect(!model.debugBreakpointPresentation.isManagerPresented) + #expect(model.workspaceURL == nil) + } + + @Test + func switchingAndClosingProjectsDismissesTheManager() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-breakpoint-manager-\(UUID().uuidString)") + let firstProject = root.appendingPathComponent("first", isDirectory: true) + let secondProject = root.appendingPathComponent("second", isDirectory: true) + try FileManager.default.createDirectory(at: firstProject, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: secondProject, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let model = makeModel() + model.openProjectDirectly(firstProject) + model.debugBreakpointPresentation.isManagerPresented = true + + model.openProjectDirectly(secondProject) + + #expect(!model.debugBreakpointPresentation.isManagerPresented) + model.debugBreakpointPresentation.isManagerPresented = true + + model.closeProject() + + #expect(!model.debugBreakpointPresentation.isManagerPresented) + #expect(model.workspaceURL == nil) + } + + private func makeModel() -> AppModel { + let store = DebugBreakpointManagerTestStore() + let settings = AppSettings(store: store) + let services = MacServiceContainer( + store: store, + settings: settings, + moduleLaunchMode: .safeMode + ).services + return AppModel(settings: settings, services: services) + } +} + +private final class DebugBreakpointManagerTestStore: KeyValueStore, @unchecked Sendable { + private var values: [String: Any] = [:] + + func data(forKey key: String) -> Data? { values[key] as? Data } + func object(forKey key: String) -> Any? { values[key] } + func string(forKey key: String) -> String? { values[key] as? String } + func stringArray(forKey key: String) -> [String]? { values[key] as? [String] } + func set(_ value: Any?, forKey key: String) { values[key] = value } +} diff --git a/macos/Tests/LitheTests/KeyboardShortcutTests.swift b/macos/Tests/LitheTests/KeyboardShortcutTests.swift index af8b3f6b..5e439814 100644 --- a/macos/Tests/LitheTests/KeyboardShortcutTests.swift +++ b/macos/Tests/LitheTests/KeyboardShortcutTests.swift @@ -8,7 +8,7 @@ struct KeyboardShortcutTests { @Test func catalogHasStableUniqueCommandsAndConflictFreeDefaults() { let commands = LitheCommandCatalog.commands - #expect(commands.count == 35) + #expect(commands.count == 36) #expect(Set(commands.map(\.id)).count == commands.count) let owners = commands.flatMap { command in @@ -21,6 +21,15 @@ struct KeyboardShortcutTests { } } + @Test + func viewBreakpointsUsesTheIDEADefaultShortcut() throws { + let command = try #require(LitheCommandCatalog.command(id: "view-breakpoints")) + + #expect(command.defaultBindings == [ + .keyPress(key: "f8", modifiers: [.shift, .command]) + ]) + } + @Test @MainActor func actionRegistryCoversEveryCatalogCommand() { @@ -49,6 +58,7 @@ struct KeyboardShortcutTests { #expect(actionIDs.contains("go-to-implementation")) #expect(actionIDs.contains("rebuild-java-index")) #expect(actionIDs.contains("spring-endpoints")) + #expect(actionIDs.contains("view-breakpoints")) } @Test From 9484c4f2b7f1ad488df628535554c681ade0ba2a Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sun, 30 Aug 2026 10:24:35 +0800 Subject: [PATCH 41/66] feat(debug): launch Java debuggee in integrated terminal --- .../Core/Rust/RustDebugProtocolCore.swift | 59 ++- .../AppModel/AppModel+Development.swift | 6 + .../AppModel/AppModel+ExecutionModules.swift | 12 + .../Models/AppModel/AppModel+Terminal.swift | 130 ++++++ .../Lithe/Models/AppModel/AppModel.swift | 24 +- .../MacOS/Terminal/MacTerminalTransport.swift | 89 ++++- .../DebugLaunchConfigurationResolver.swift | 2 +- .../Lithe/Views/Terminal/TerminalView.swift | 1 + .../Debug/DebugAdapterContracts.swift | 67 ++++ .../Debug/DebugProtocolCore.swift | 12 +- .../GenericDebugFeatureModel.swift | 6 + .../CoreDebugAdapterProtocolSession.swift | 78 +++- .../Runtime/DebugAdapterSessionManager.swift | 13 + .../Application/TerminalFeatureModel.swift | 11 + .../Ports/TerminalTransport.swift | 36 ++ .../Runtime/TerminalSession.swift | 69 +++- .../DebugModuleTests.swift | 124 +++++- .../TerminalModuleTests.swift | 45 ++- .../LitheTests/LitheCoreLogicTests.swift | 11 + .../RealJavaDebugIntegrationTests.swift | 66 +++- .../TerminalPlacementFeatureModelTests.swift | 10 + rust/lithe-core/src/debug/engine.rs | 369 +++++++++++++++++- rust/lithe-core/src/debug/java_test.rs | 2 +- rust/lithe-core/src/debug/types.rs | 53 +++ rust/lithe-core/src/protocol/command.rs | 4 + rust/lithe-core/src/runtime/dispatcher.rs | 21 + rust/lithe-core/src/tests/protocol.rs | 92 +++++ shared/contracts/application-boundary.md | 10 +- shared/contracts/rust-core-api.md | 16 +- .../fixtures/debug/java-test-launch-v1.json | 4 +- shared/fixtures/debug/run-in-terminal-v1.json | 54 +++ 31 files changed, 1438 insertions(+), 58 deletions(-) create mode 100644 shared/fixtures/debug/run-in-terminal-v1.json diff --git a/macos/Sources/Lithe/Core/Rust/RustDebugProtocolCore.swift b/macos/Sources/Lithe/Core/Rust/RustDebugProtocolCore.swift index a643274d..a2692fbd 100644 --- a/macos/Sources/Lithe/Core/Rust/RustDebugProtocolCore.swift +++ b/macos/Sources/Lithe/Core/Rust/RustDebugProtocolCore.swift @@ -45,14 +45,16 @@ extension RustCoreBridge: DebugProtocolCore { func createDebugSession( sessionID: String, adapterID: String, - rootPath: String + rootPath: String, + supportsRunInTerminalRequest: Bool ) throws -> DebugCoreUpdate { try executeResult( command: "debug.createSession", payload: DebugCreateSessionPayload( sessionID: sessionID, adapterID: adapterID, - rootPath: rootPath + rootPath: rootPath, + supportsRunInTerminalRequest: supportsRunInTerminalRequest ) ).get() } @@ -251,6 +253,38 @@ extension RustCoreBridge: DebugProtocolCore { ).get() } + func completeDebugRunInTerminalRequest( + sessionID: String, + requestID: String, + result: Result + ) throws -> DebugCoreUpdate { + let payload: DebugRunInTerminalResponsePayload + switch result { + case .success(let response): + payload = DebugRunInTerminalResponsePayload( + sessionID: sessionID, + requestID: requestID, + success: true, + processID: response.processID, + shellProcessID: response.shellProcessID, + message: nil + ) + case .failure(let error): + payload = DebugRunInTerminalResponsePayload( + sessionID: sessionID, + requestID: requestID, + success: false, + processID: nil, + shellProcessID: nil, + message: error.localizedDescription + ) + } + return try executeResult( + command: "debug.runInTerminalResponse", + payload: payload + ).get() + } + func disconnectDebugSession(sessionID: String) throws -> DebugCoreUpdate { try executeResult( command: "debug.disconnect", @@ -318,11 +352,30 @@ private struct DebugCreateSessionPayload: Encodable { let sessionID: String let adapterID: String let rootPath: String + let supportsRunInTerminalRequest: Bool private enum CodingKeys: String, CodingKey { case sessionID = "sessionId" case adapterID = "adapterId" - case rootPath + case rootPath, supportsRunInTerminalRequest + } +} + +private struct DebugRunInTerminalResponsePayload: Encodable { + let sessionID: String + let requestID: String + let success: Bool + let processID: Int? + let shellProcessID: Int? + let message: String? + + private enum CodingKeys: String, CodingKey { + case sessionID = "sessionId" + case requestID = "requestId" + case success + case processID = "processId" + case shellProcessID = "shellProcessId" + case message } } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift index 1d389658..2ca18a14 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift @@ -654,6 +654,7 @@ extension AppModel { func stopDebugging() { cancelJavaTestDebugLaunch() genericDebugFeatureIfActive?.stop() + stopDebugTerminalProcesses() } func cancelJavaTestDebugLaunch() { @@ -679,8 +680,13 @@ extension AppModel { } func handleDebugSessionStateChange(_ state: DebugAdapterState) { + if state == .paused { + showDebugToolWindow() + return + } guard state == .terminated || state == .failed else { return } stopJavaTestResultServer() + stopDebugTerminalProcesses() } private func isCurrentJavaTestDebugLaunch(_ operationID: UUID) -> Bool { diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift index ed3fbf3d..22890767 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift @@ -51,6 +51,7 @@ extension AppModel { func activateDebugModule() async -> DebugFeatureAccess? { if let genericFeature = genericDebugFeatureIfActive { + configureDebugRunInTerminalHandler(genericFeature) if let workspaceURL { genericFeature.openWorkspace(at: workspaceURL) } return DebugFeatureAccess(genericFeature: genericFeature) } @@ -58,6 +59,7 @@ extension AppModel { let value = try await services.moduleRuntime.activateCapability(.debugWorkspace) guard let capability = value as? LitheDebugModule.DebugModuleCapability, let genericFeature = capability.genericFeature as? GenericDebugFeatureModel else { return nil } + configureDebugRunInTerminalHandler(genericFeature) cacheModuleCapability(capability, id: .debugWorkspace, moduleID: .debug) genericFeature.onStoppedLocation = { [weak self] url, line, column in self?.openSourceLocation(url: url, line: line, column: column) @@ -78,6 +80,16 @@ extension AppModel { } } + private func configureDebugRunInTerminalHandler(_ feature: GenericDebugFeatureModel) { + feature.onRunInTerminalRequest = { [weak self] request, completion in + guard let self else { + completion(.failure(DebugAdapterCapabilityError.unsupported("run in terminal"))) + return + } + handleDebugRunInTerminalRequest(request, completion: completion) + } + } + func restoreDebugBreakpoints(for workspaceURL: URL) async { guard self.workspaceURL == workspaceURL, let persistence = services.debugBreakpointPersistence else { return } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift index 90a5f976..eca45f14 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift @@ -1,7 +1,33 @@ +import Combine import Foundation +import LitheCoreContracts import LitheTerminalModule extension AppModel { + var terminalCapability: LitheTerminalModule.TerminalModuleCapability? { + cachedModuleCapability(.terminalWorkspace) + } + + var terminalFeature: TerminalFeatureModel? { terminalCapability?.feature } + var availableTerminalShells: [String] { terminalFeature?.availableShells ?? [] } + + @MainActor + func activateTerminalModule() async -> Bool { + guard terminalCapability == nil else { return true } + do { + let value = try await services.moduleRuntime.activateCapability(.terminalWorkspace) + guard let capability = value as? LitheTerminalModule.TerminalModuleCapability else { return false } + let feature = capability.feature + cacheModuleCapability(capability, id: .terminalWorkspace, moduleID: .terminal) + observeModuleFeature(.terminal, observation: feature.objectWillChange.sink { [weak self] _ in + self?.scheduleObjectWillChangeRelay() + }) + return true + } catch { + return false + } + } + func toggleTerminal() { isTerminalVisible.toggle() guard isTerminalVisible else { return } @@ -74,6 +100,79 @@ extension AppModel { } } + func handleDebugRunInTerminalRequest( + _ request: DebugRunInTerminalRequest, + completion: @escaping DebugRunInTerminalCompletion + ) { + Task { @MainActor [weak self] in + guard let self else { + completion(.failure(DebugTerminalLaunchError.hostUnavailable)) + return + } + do { + completion(.success(try await startDebugProcessInTerminal(request))) + } catch { + completion(.failure(error)) + } + } + } + + private func startDebugProcessInTerminal( + _ request: DebugRunInTerminalRequest + ) async throws -> DebugRunInTerminalResponse { + guard request.kind == .integrated else { + throw DebugTerminalLaunchError.externalTerminalUnsupported + } + guard !request.argsCanBeInterpretedByShell else { + throw DebugTerminalLaunchError.shellInterpretationUnsupported + } + guard let executablePath = request.args.first, !executablePath.isEmpty else { + throw DebugTerminalLaunchError.missingExecutable + } + guard let workspaceURL else { + throw DebugTerminalLaunchError.workspaceUnavailable + } + guard await activateTerminalModule(), let feature = terminalFeature else { + throw DebugTerminalLaunchError.terminalUnavailable + } + let workingDirectory = request.cwd.isEmpty + ? workspaceURL.standardizedFileURL.path + : request.cwd + guard workingDirectory.hasPrefix("/") else { + throw DebugTerminalLaunchError.invalidWorkingDirectory + } + let launch = TerminalProcessLaunch( + title: request.title, + executablePath: executablePath, + arguments: Array(request.args.dropFirst()), + workingDirectory: workingDirectory, + environmentChanges: request.environment.map { + TerminalEnvironmentChange(name: $0.name, value: $0.value) + } + ) + let created = try feature.createProcessSession(launch) + configureTerminalSession(created.session) + terminalPlacementFeature.registerSession(created.session.id) + debugTerminalSessionIDs.insert(created.session.id) + isTerminalVisible = true + isTestsVisible = false + isGitLogVisible = false + isReferencesVisible = false + isProblemsVisible = false + isMavenVisible = false + isRunVisible = false + isDebugVisible = false + created.session.focus() + return DebugRunInTerminalResponse(processID: Int(created.processID)) + } + + func stopDebugTerminalProcesses() { + for sessionID in debugTerminalSessionIDs.sorted(by: { $0.uuidString < $1.uuidString }) { + terminalSessions.first(where: { $0.id == sessionID })?.stop() + } + debugTerminalSessionIDs.removeAll() + } + private func openTerminalLink(_ link: String, params: [String: String], sessionID: UUID) { guard let session = terminalSessions.first(where: { $0.id == sessionID }), let fallbackDirectory = session.currentDirectory ?? workspaceURL else { return } @@ -160,6 +259,7 @@ extension AppModel { func closeTerminalSession(_ session: TerminalSession) { guard terminalSessions.contains(where: { $0.id == session.id }) else { return } + debugTerminalSessionIDs.remove(session.id) editorTabOrderFeature.remove(.terminal(session.id)) terminalPlacementFeature.removeSession(session.id) terminalFeature?.closeSession(session) @@ -172,6 +272,7 @@ extension AppModel { func restartActiveTerminal() { terminalFeature?.restartActiveSession() } func restartActiveTerminal(using shellPath: String) { terminalFeature?.restartActiveSession(using: shellPath) } func stopTerminalSessions() { + debugTerminalSessionIDs.removeAll() editorTabOrderFeature.removeAllTerminals() terminalPlacementFeature.reset() terminalFeature?.stopAllSessions() @@ -186,3 +287,32 @@ extension AppModel { return sessionIDs.compactMap { sessionsByID[$0] } } } + +private enum DebugTerminalLaunchError: LocalizedError { + case hostUnavailable + case externalTerminalUnsupported + case shellInterpretationUnsupported + case missingExecutable + case workspaceUnavailable + case terminalUnavailable + case invalidWorkingDirectory + + var errorDescription: String? { + switch self { + case .hostUnavailable: + "The application closed before the debug terminal could start." + case .externalTerminalUnsupported: + "This debug session requires an external terminal, which is not supported." + case .shellInterpretationUnsupported: + "This debug session requires shell-interpreted terminal arguments." + case .missingExecutable: + "The debug adapter did not provide a terminal executable." + case .workspaceUnavailable: + "Open a project before starting a debug terminal." + case .terminalUnavailable: + "The integrated terminal is unavailable." + case .invalidWorkingDirectory: + "The debug adapter provided an invalid terminal working directory." + } + } +} diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 3b2e4cfd..caa1b716 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -8,7 +8,6 @@ import LitheLocalHistoryModule import LitheLanguageIntelligenceModule import LitheModuleAPI import LitheSearchModule -import LitheTerminalModule import LitheWorkspaceModule import LitheCoreContracts @@ -164,6 +163,7 @@ final class AppModel: ObservableObject, Identifiable { let discourseCommunityFeature: DiscourseCommunityFeatureModel let editorTabOrderFeature = EditorTabOrderFeatureModel() let terminalPlacementFeature: TerminalPlacementFeatureModel + var debugTerminalSessionIDs: Set = [] private struct CachedModuleCapability { let moduleID: ModuleID let value: AnyObject @@ -182,28 +182,6 @@ final class AppModel: ObservableObject, Identifiable { var searchCapability: LitheSearchModule.SearchModuleCapability? { cachedModuleCapability(.searchWorkspace) } - var terminalCapability: LitheTerminalModule.TerminalModuleCapability? { - cachedModuleCapability(.terminalWorkspace) - } - var terminalFeature: TerminalFeatureModel? { terminalCapability?.feature } - var availableTerminalShells: [String] { terminalFeature?.availableShells ?? [] } - - @MainActor - func activateTerminalModule() async -> Bool { - guard terminalCapability == nil else { return true } - do { - let value = try await services.moduleRuntime.activateCapability(.terminalWorkspace) - guard let capability = value as? LitheTerminalModule.TerminalModuleCapability else { return false } - let feature = capability.feature - cacheModuleCapability(capability, id: .terminalWorkspace, moduleID: .terminal) - observeModuleFeature(.terminal, observation: feature.objectWillChange.sink { [weak self] _ in - self?.scheduleObjectWillChangeRelay() - }) - return true - } catch { - return false - } - } var historyCapability: LitheLocalHistoryModule.HistoryModuleCapability? { cachedModuleCapability(.historyWorkspace) } diff --git a/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift b/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift index 67739015..f349de4d 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift @@ -147,6 +147,11 @@ final class MacTerminalTransport: NSObject, TerminalTransport, @preconcurrency L view.process.running } + var processID: Int32? { + let processID = view.process.shellPid + return processID > 0 ? processID : nil + } + var shellName: String { guard let selectedShellPath else { return "Shell" } return URL(fileURLWithPath: selectedShellPath).lastPathComponent @@ -207,9 +212,36 @@ final class MacTerminalTransport: NSObject, TerminalTransport, @preconcurrency L shellPath: String, environment: [String: String] ) throws { + _ = try startProcess( + TerminalProcessLaunch( + title: nil, + executablePath: shellPath, + arguments: ["-l"], + workingDirectory: workingDirectory + ), + environment: environment + ) + } + + func startProcess( + _ launch: TerminalProcessLaunch, + environment: [String: String] + ) throws -> Int32 { stop() suppressNextTermination = false - selectedShellPath = shellPath + let executablePath = try resolveExecutablePath( + launch.executablePath, + workingDirectory: launch.workingDirectory, + environment: environment + ) + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists( + atPath: launch.workingDirectory, + isDirectory: &isDirectory + ), isDirectory.boolValue else { + throw terminalError("The terminal working directory does not exist: \(launch.workingDirectory)") + } + selectedShellPath = executablePath view.terminal.resetToInitialState() var options = view.terminal.options @@ -222,21 +254,16 @@ final class MacTerminalTransport: NSObject, TerminalTransport, @preconcurrency L } view.startProcess( - executable: shellPath, - args: ["-l"], + executable: executablePath, + args: launch.arguments, environment: environmentArray, - currentDirectory: workingDirectory + currentDirectory: launch.workingDirectory ) - guard view.process.running else { - throw NSError( - domain: "Lithe.Terminal", - code: 1, - userInfo: [ - NSLocalizedDescriptionKey: "Unable to start \(shellPath)" - ] - ) + guard view.process.running, let processID else { + throw terminalError("Unable to start \(executablePath)") } + return processID } func send(_ input: Data) throws { @@ -281,4 +308,42 @@ final class MacTerminalTransport: NSObject, TerminalTransport, @preconcurrency L } onTermination?(exitCode) } + + private func resolveExecutablePath( + _ executablePath: String, + workingDirectory: String, + environment: [String: String] + ) throws -> String { + let fileManager = FileManager.default + let candidate: String? + if executablePath.contains("/") { + let url = executablePath.hasPrefix("/") + ? URL(fileURLWithPath: executablePath) + : URL(fileURLWithPath: workingDirectory, isDirectory: true) + .appendingPathComponent(executablePath) + candidate = url.standardizedFileURL.path + } else { + candidate = environment["PATH"]? + .split(separator: ":", omittingEmptySubsequences: false) + .map(String.init) + .map { directory in + URL(fileURLWithPath: directory, isDirectory: true) + .appendingPathComponent(executablePath) + .standardizedFileURL.path + } + .first { fileManager.isExecutableFile(atPath: $0) } + } + guard let candidate, fileManager.isExecutableFile(atPath: candidate) else { + throw terminalError("The terminal executable is unavailable: \(executablePath)") + } + return candidate + } + + private func terminalError(_ message: String) -> NSError { + NSError( + domain: "Lithe.Terminal", + code: 1, + userInfo: [NSLocalizedDescriptionKey: message] + ) + } } diff --git a/macos/Sources/Lithe/Services/Debug/DebugLaunchConfigurationResolver.swift b/macos/Sources/Lithe/Services/Debug/DebugLaunchConfigurationResolver.swift index b8234b06..cbc3a100 100644 --- a/macos/Sources/Lithe/Services/Debug/DebugLaunchConfigurationResolver.swift +++ b/macos/Sources/Lithe/Services/Debug/DebugLaunchConfigurationResolver.swift @@ -178,7 +178,7 @@ struct DebugLaunchConfigurationResolver { var arguments: [String: ToolingJSONValue] = [ "mainClass": .string(mainClass), "cwd": .string(workingDirectory), - "console": .string("internalConsole") + "console": .string("integratedTerminal") ] if let projectName = target.projectName { arguments["projectName"] = .string(projectName) diff --git a/macos/Sources/Lithe/Views/Terminal/TerminalView.swift b/macos/Sources/Lithe/Views/Terminal/TerminalView.swift index abbb99dd..2f764a05 100644 --- a/macos/Sources/Lithe/Views/Terminal/TerminalView.swift +++ b/macos/Sources/Lithe/Views/Terminal/TerminalView.swift @@ -84,6 +84,7 @@ struct TerminalView: View { session.restart() session.focus() } + .disabled(session.isManagedProcess) Button("Clear", action: session.clear) Divider() Button("Move to Editor") { diff --git a/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift b/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift index 0519181f..275e877b 100644 --- a/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift +++ b/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift @@ -77,6 +77,73 @@ public struct DebugLaunchConfiguration: Codable, Equatable, Sendable { } } +public enum DebugRunInTerminalKind: String, Codable, Equatable, Sendable { + case integrated, external +} + +public struct DebugRunInTerminalEnvironmentVariable: Codable, Equatable, Sendable { + public let name: String + public let value: String? + + public init(name: String, value: String?) { + self.name = name + self.value = value + } +} + +/// A normalized DAP reverse request. `args.first` is the executable and the +/// remaining values stay as an argument array unless shell interpretation was +/// explicitly requested by the adapter. +public struct DebugRunInTerminalRequest: Codable, Equatable, Sendable { + public let kind: DebugRunInTerminalKind + public let title: String? + public let cwd: String + public let args: [String] + public let environment: [DebugRunInTerminalEnvironmentVariable] + public let argsCanBeInterpretedByShell: Bool + + public init( + kind: DebugRunInTerminalKind, + title: String?, + cwd: String, + args: [String], + environment: [DebugRunInTerminalEnvironmentVariable], + argsCanBeInterpretedByShell: Bool + ) { + self.kind = kind + self.title = title + self.cwd = cwd + self.args = args + self.environment = environment + self.argsCanBeInterpretedByShell = argsCanBeInterpretedByShell + } +} + +public struct DebugRunInTerminalResponse: Equatable, Sendable { + public let processID: Int? + public let shellProcessID: Int? + + public init(processID: Int?, shellProcessID: Int? = nil) { + self.processID = processID + self.shellProcessID = shellProcessID + } +} + +public typealias DebugRunInTerminalCompletion = ( + Result +) -> Void +public typealias DebugRunInTerminalRequestHandler = ( + DebugRunInTerminalRequest, + @escaping DebugRunInTerminalCompletion +) -> Void + +/// Optional reverse-request surface implemented only by sessions whose native +/// host can create an integrated terminal before DAP initialization begins. +@MainActor +public protocol DebugAdapterRunInTerminalSession: AnyObject { + var onRunInTerminalRequest: DebugRunInTerminalRequestHandler? { get set } +} + public struct DebugSteppingFilters: Codable, Equatable, Sendable { public let classNameFilters: [String] public let skipSynthetics: Bool diff --git a/macos/Sources/LitheCoreContracts/Debug/DebugProtocolCore.swift b/macos/Sources/LitheCoreContracts/Debug/DebugProtocolCore.swift index fc117b14..18f5b4d3 100644 --- a/macos/Sources/LitheCoreContracts/Debug/DebugProtocolCore.swift +++ b/macos/Sources/LitheCoreContracts/Debug/DebugProtocolCore.swift @@ -33,6 +33,8 @@ public struct DebugCoreEvent: Decodable, Equatable, Sendable { public let exitCode: Int? public let breakpoint: DebugCoreBreakpoint? public let capabilities: DebugCoreCapabilities? + public let requestID: String? + public let request: DebugRunInTerminalRequest? public let operationID: String? public let result: DebugCoreOperationResult? public let command: String? @@ -51,6 +53,8 @@ public struct DebugCoreEvent: Decodable, Equatable, Sendable { case exitCode case breakpoint case capabilities + case requestID = "requestId" + case request case operationID = "operationId" case result case command @@ -275,7 +279,8 @@ public protocol DebugProtocolCore: DebugSteppingFilterResolving, Sendable { func createDebugSession( sessionID: String, adapterID: String, - rootPath: String + rootPath: String, + supportsRunInTerminalRequest: Bool ) throws -> DebugCoreUpdate func launchDebugSession( sessionID: String, @@ -342,6 +347,11 @@ public protocol DebugProtocolCore: DebugSteppingFilterResolving, Sendable { column: Int? ) throws -> DebugCoreUpdate func receiveDebugData(sessionID: String, data: Data) throws -> DebugCoreUpdate + func completeDebugRunInTerminalRequest( + sessionID: String, + requestID: String, + result: Result + ) throws -> DebugCoreUpdate func disconnectDebugSession(sessionID: String) throws -> DebugCoreUpdate func destroyDebugSession(sessionID: String) } diff --git a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift index b7e87e3f..0b7b0110 100644 --- a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift +++ b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift @@ -156,6 +156,12 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu /// Delivers the selected stopped frame to the host editor for source /// navigation. The Debug module does not own editor presentation. public var onStoppedLocation: ((URL, Int, Int) -> Void)? + /// Lets the host activate its native Terminal module without coupling + /// Debug to a platform process or presentation implementation. + public var onRunInTerminalRequest: DebugRunInTerminalRequestHandler? { + get { sessions.onRunInTerminalRequest } + set { sessions.onRunInTerminalRequest = newValue } + } private let sessions: DebugAdapterSessionManager private let breakpointPersistence: (any DebugBreakpointPersisting)? diff --git a/macos/Sources/LitheDebugModule/Runtime/CoreDebugAdapterProtocolSession.swift b/macos/Sources/LitheDebugModule/Runtime/CoreDebugAdapterProtocolSession.swift index f85ee1bf..9cc2c59f 100644 --- a/macos/Sources/LitheDebugModule/Runtime/CoreDebugAdapterProtocolSession.swift +++ b/macos/Sources/LitheDebugModule/Runtime/CoreDebugAdapterProtocolSession.swift @@ -5,7 +5,8 @@ import LitheCoreContracts /// callback correlation and UI model conversion; framing and state reduction /// stay behind `DebugProtocolCore`, while native I/O stays in the transport. @MainActor -public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSession { +public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSession, + DebugAdapterRunInTerminalSession { private typealias OperationHandler = (Result) -> Void private struct PendingOperation { @@ -13,6 +14,10 @@ public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSessi let deadline: any DebugOperationDeadline } + private struct PendingRunInTerminalRequest { + let deadline: any DebugOperationDeadline + } + private let adapterID: String private let transport: any DebugAdapterTransport private let core: any DebugProtocolCore @@ -20,6 +25,7 @@ public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSessi private let deadlineScheduler: any DebugOperationDeadlineScheduling private let operationTimeoutMilliseconds: Int private var operationHandlers: [String: PendingOperation] = [:] + private var pendingRunInTerminalRequests: [String: PendingRunInTerminalRequest] = [:] private var ownsCoreSession = false private var isStopping = false @@ -28,6 +34,7 @@ public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSessi } public var onStateChange: ((DebugAdapterState) -> Void)? public var onEvent: ((DebugAdapterEvent) -> Void)? + public var onRunInTerminalRequest: DebugRunInTerminalRequestHandler? public var isRunning: Bool { transport.isRunning } public private(set) var capabilities: DebugAdapterCapabilities = .unknown @@ -57,13 +64,15 @@ public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSessi guard state == .idle || state == .terminated || state == .failed else { return } isStopping = false operationHandlers = [:] + discardPendingRunInTerminalRequests() capabilities = .unknown try transport.start(rootURL: rootURL.standardizedFileURL) do { let update = try core.createDebugSession( sessionID: sessionID, adapterID: adapterID, - rootPath: rootURL.standardizedFileURL.path + rootPath: rootURL.standardizedFileURL.path, + supportsRunInTerminalRequest: onRunInTerminalRequest != nil ) ownsCoreSession = true try apply(update) @@ -78,6 +87,7 @@ public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSessi public func stop() { guard state != .idle || transport.isRunning || ownsCoreSession else { return } isStopping = true + failPendingRunInTerminalRequests(DebugAdapterProtocolError.stopped) if ownsCoreSession, transport.isRunning, let update = try? core.disconnectDebugSession(sessionID: sessionID) { try? apply(update) @@ -565,6 +575,10 @@ public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSessi functionName: breakpoint.functionName, dataID: breakpoint.dataID ))) + case "runInTerminalRequested": + guard let requestID = event.requestID, + let request = event.request else { return } + beginRunInTerminalRequest(requestID: requestID, request: request) case "operationCompleted": guard let operationID = event.operationID, let result = event.result else { return } @@ -592,6 +606,7 @@ public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSessi private func transportTerminated(exitCode: Int) { guard !isStopping else { return } + discardPendingRunInTerminalRequests() releaseCoreSession() failPendingOperations(DebugAdapterProtocolError.stopped) state = exitCode == 0 ? .terminated : .failed @@ -600,6 +615,7 @@ public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSessi private func failSession() { transport.stop() + discardPendingRunInTerminalRequests() releaseCoreSession() failPendingOperations(DebugAdapterProtocolError.stopped) state = .failed @@ -611,6 +627,64 @@ public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSessi core.destroyDebugSession(sessionID: sessionID) } + private func beginRunInTerminalRequest( + requestID: String, + request: DebugRunInTerminalRequest + ) { + let deadline = deadlineScheduler.schedule( + afterMilliseconds: operationTimeoutMilliseconds + ) { [weak self] in + self?.completeRunInTerminalRequest( + requestID, + result: .failure(DebugAdapterProtocolError.timedOut("runInTerminal")) + ) + } + pendingRunInTerminalRequests[requestID] = PendingRunInTerminalRequest( + deadline: deadline + ) + guard let handler = onRunInTerminalRequest else { + completeRunInTerminalRequest( + requestID, + result: .failure(DebugAdapterCapabilityError.unsupported("run in terminal")) + ) + return + } + handler(request) { [weak self] result in + self?.completeRunInTerminalRequest(requestID, result: result) + } + } + + private func completeRunInTerminalRequest( + _ requestID: String, + result: Result + ) { + guard let pending = pendingRunInTerminalRequests.removeValue(forKey: requestID), + ownsCoreSession else { return } + pending.deadline.cancel() + do { + try apply(core.completeDebugRunInTerminalRequest( + sessionID: sessionID, + requestID: requestID, + result: result + )) + } catch { + onEvent?(.output(category: "stderr", output: error.localizedDescription + "\n")) + if !isStopping { failSession() } + } + } + + private func failPendingRunInTerminalRequests(_ error: Error) { + for requestID in pendingRunInTerminalRequests.keys.sorted() { + completeRunInTerminalRequest(requestID, result: .failure(error)) + } + } + + private func discardPendingRunInTerminalRequests() { + let pending = pendingRunInTerminalRequests.values + pendingRunInTerminalRequests = [:] + pending.forEach { $0.deadline.cancel() } + } + private static func makeCapabilities( _ value: DebugCoreCapabilities ) -> DebugAdapterCapabilities { diff --git a/macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift b/macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift index 0ecc63a1..2c803caa 100644 --- a/macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift +++ b/macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift @@ -14,6 +14,13 @@ public final class DebugAdapterSessionManager: ObservableObject { public var onStateChange: ((String, DebugAdapterState) -> Void)? public var onEvent: ((String, DebugAdapterEvent) -> Void)? + public var onRunInTerminalRequest: DebugRunInTerminalRequestHandler? { + didSet { + for session in sessions.values { + configureRunInTerminalHandler(session) + } + } + } private let providers: [DebugProviderDescriptor] private let makeSession: @MainActor ( @@ -182,6 +189,7 @@ public final class DebugAdapterSessionManager: ObservableObject { _ session: any DebugAdapterSession, providerID: String ) { + configureRunInTerminalHandler(session) guard let controlling = session as? any DebugAdapterControllingSession else { return } controlling.onStateChange = { [weak self] state in self?.states[providerID] = state @@ -205,4 +213,9 @@ public final class DebugAdapterSessionManager: ObservableObject { } } } + + private func configureRunInTerminalHandler(_ session: any DebugAdapterSession) { + guard let session = session as? any DebugAdapterRunInTerminalSession else { return } + session.onRunInTerminalRequest = onRunInTerminalRequest + } } diff --git a/macos/Sources/LitheTerminalModule/Application/TerminalFeatureModel.swift b/macos/Sources/LitheTerminalModule/Application/TerminalFeatureModel.swift index d1ba8ef6..76838e90 100644 --- a/macos/Sources/LitheTerminalModule/Application/TerminalFeatureModel.swift +++ b/macos/Sources/LitheTerminalModule/Application/TerminalFeatureModel.swift @@ -40,6 +40,17 @@ public final class TerminalFeatureModel: ObservableObject { return session } + @discardableResult + public func createProcessSession( + _ launch: TerminalProcessLaunch + ) throws -> (session: TerminalSession, processID: Int32) { + let session = TerminalSession(transport: terminalFactory()) + let processID = try session.startProcess(launch) + terminalSessions.append(session) + activeTerminalSessionID = session.id + return (session, processID) + } + @discardableResult public func selectSession(_ session: TerminalSession) -> Bool { guard terminalSessions.contains(where: { $0.id == session.id }) else { return false } diff --git a/macos/Sources/LitheTerminalModule/Ports/TerminalTransport.swift b/macos/Sources/LitheTerminalModule/Ports/TerminalTransport.swift index 662776f9..095f8c2e 100644 --- a/macos/Sources/LitheTerminalModule/Ports/TerminalTransport.swift +++ b/macos/Sources/LitheTerminalModule/Ports/TerminalTransport.swift @@ -1,9 +1,44 @@ import Foundation +public struct TerminalEnvironmentChange: Equatable, Sendable { + public let name: String + public let value: String? + + public init(name: String, value: String?) { + self.name = name + self.value = value + } +} + +/// Direct process launch for a PTY-backed terminal. Arguments stay separated +/// so callers never need to build a shell command string. +public struct TerminalProcessLaunch: Equatable, Sendable { + public let title: String? + public let executablePath: String + public let arguments: [String] + public let workingDirectory: String + public let environmentChanges: [TerminalEnvironmentChange] + + public init( + title: String?, + executablePath: String, + arguments: [String], + workingDirectory: String, + environmentChanges: [TerminalEnvironmentChange] = [] + ) { + self.title = title + self.executablePath = executablePath + self.arguments = arguments + self.workingDirectory = workingDirectory + self.environmentChanges = environmentChanges + } +} + /// Platform terminal runtime injected by the native composition root. @MainActor public protocol TerminalTransport: AnyObject { var isRunning: Bool { get } + var processID: Int32? { get } var shellName: String { get } var nativeView: AnyObject { get } var onTermination: ((Int32?) -> Void)? { get set } @@ -14,6 +49,7 @@ public protocol TerminalTransport: AnyObject { func defaultShellPath() -> String func defaultEnvironment() -> [String: String] func start(workingDirectory: String, shellPath: String, environment: [String: String]) throws + func startProcess(_ launch: TerminalProcessLaunch, environment: [String: String]) throws -> Int32 func send(_ input: Data) throws func interrupt() throws func focus() diff --git a/macos/Sources/LitheTerminalModule/Runtime/TerminalSession.swift b/macos/Sources/LitheTerminalModule/Runtime/TerminalSession.swift index d0f486d4..ca8cfb5f 100644 --- a/macos/Sources/LitheTerminalModule/Runtime/TerminalSession.swift +++ b/macos/Sources/LitheTerminalModule/Runtime/TerminalSession.swift @@ -6,6 +6,7 @@ public final class TerminalSession: ObservableObject, Identifiable { public let id = UUID() @Published public private(set) var isRunning = false @Published public private(set) var isReady = false + @Published public private(set) var isManagedProcess = false @Published public private(set) var shellName = "Shell" @Published public private(set) var processTitle: String? @Published public private(set) var currentDirectory: URL? @@ -48,15 +49,13 @@ public final class TerminalSession: ObservableObject, Identifiable { public func start(in workspaceURL: URL, shellPath: String? = nil) { stop() self.workspaceURL = workspaceURL + isManagedProcess = false currentDirectory = workspaceURL.standardizedFileURL processTitle = nil; lastExitCode = nil; startedAt = Date(); endedAt = nil let shell = shellPath ?? selectedShellPath ?? transport.defaultShellPath() selectedShellPath = shell shellName = URL(fileURLWithPath: shell).lastPathComponent - var environment = transport.defaultEnvironment() - environment["TERM"] = "xterm-256color" - environment["COLORTERM"] = "truecolor" - environment["TERM_PROGRAM"] = "Lithe" + let environment = terminalEnvironment() do { try transport.start(workingDirectory: workspaceURL.path, shellPath: shell, environment: environment) isRunning = transport.isRunning; isReady = isRunning @@ -65,8 +64,49 @@ public final class TerminalSession: ObservableObject, Identifiable { } } - public func restart() { if let workspaceURL { start(in: workspaceURL, shellPath: selectedShellPath) } } - public func restart(using shellPath: String) { if let workspaceURL { start(in: workspaceURL, shellPath: shellPath) } } + @discardableResult + public func startProcess(_ launch: TerminalProcessLaunch) throws -> Int32 { + stop() + let workingDirectory = URL( + fileURLWithPath: launch.workingDirectory, + isDirectory: true + ).standardizedFileURL + workspaceURL = workingDirectory + selectedShellPath = nil + isManagedProcess = true + currentDirectory = workingDirectory + processTitle = launch.title?.trimmingCharacters(in: .whitespacesAndNewlines) + if processTitle?.isEmpty == true { processTitle = nil } + lastExitCode = nil + startedAt = Date() + endedAt = nil + shellName = URL(fileURLWithPath: launch.executablePath).lastPathComponent + + do { + let processID = try transport.startProcess( + launch, + environment: terminalEnvironment(applying: launch.environmentChanges) + ) + isRunning = transport.isRunning + isReady = isRunning + return processID + } catch { + isRunning = false + isReady = false + startedAt = nil + endedAt = Date() + throw error + } + } + + public func restart() { + guard !isManagedProcess, let workspaceURL else { return } + start(in: workspaceURL, shellPath: selectedShellPath) + } + public func restart(using shellPath: String) { + guard !isManagedProcess, let workspaceURL else { return } + start(in: workspaceURL, shellPath: shellPath) + } public func send(_ command: String) { sendInput(command + "\n") } public func sendInput(_ input: String) { guard isRunning, isReady, let data = input.data(using: .utf8) else { return } @@ -85,6 +125,23 @@ public final class TerminalSession: ObservableObject, Identifiable { if let url = URL(string: rawValue), url.isFileURL { currentDirectory = url.standardizedFileURL } else if rawValue.hasPrefix("/") { currentDirectory = URL(fileURLWithPath: rawValue).standardizedFileURL } } + + private func terminalEnvironment( + applying changes: [TerminalEnvironmentChange] = [] + ) -> [String: String] { + var environment = transport.defaultEnvironment() + environment["TERM"] = "xterm-256color" + environment["COLORTERM"] = "truecolor" + environment["TERM_PROGRAM"] = "Lithe" + for change in changes { + if let value = change.value { + environment[change.name] = value + } else { + environment[change.name] = nil + } + } + return environment + } } private extension String { var nonEmpty: String? { isEmpty ? nil : self } } diff --git a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift index 9f0c3142..f7f27bb1 100644 --- a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift +++ b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift @@ -237,6 +237,90 @@ struct DebugModuleTests { #expect(transport.stopCalls == 1) } + @Test + func coreProtocolSessionLaunchesRunInTerminalAndReturnsProcessID() throws { + let transport = RecordingTransport() + let core = RecordingDebugProtocolCore() + let session = CoreDebugAdapterProtocolSession( + adapterID: "java", + transport: transport, + core: core, + sessionID: "java-run-in-terminal", + deadlineScheduler: RecordingDebugDeadlineScheduler() + ) + var receivedRequest: DebugRunInTerminalRequest? + session.onRunInTerminalRequest = { request, completion in + receivedRequest = request + completion(.success(DebugRunInTerminalResponse(processID: 4242))) + } + + try session.start(rootURL: URL(fileURLWithPath: "/tmp/java-run-in-terminal")) + defer { session.stop() } + #expect(core.lastSupportsRunInTerminalRequest == true) + core.enqueueReceive(sessionID: "java-run-in-terminal", state: "launching", events: [[ + "sequence": 2, + "type": "runInTerminalRequested", + "requestId": "runInTerminal-44", + "request": [ + "kind": "integrated", + "title": "Debug Main", + "cwd": "/tmp/java-run-in-terminal", + "args": ["/opt/jdk/bin/java", "example.Main"], + "environment": [["name": "JAVA_HOME", "value": "/opt/jdk"]], + "argsCanBeInterpretedByShell": false + ] + ]]) + + transport.emitData(Data("run-in-terminal-request".utf8)) + + #expect(receivedRequest?.args == ["/opt/jdk/bin/java", "example.Main"]) + #expect(core.runInTerminalCompletions == [RecordingRunInTerminalCompletion( + requestID: "runInTerminal-44", + response: DebugRunInTerminalResponse(processID: 4242), + errorDescription: nil + )]) + #expect(transport.sentData.contains(Data("run-in-terminal-response".utf8))) + } + + @Test + func stoppingCoreProtocolSessionFailsPendingTerminalRequestAndIgnoresLateCompletion() throws { + let transport = RecordingTransport() + let core = RecordingDebugProtocolCore() + let session = CoreDebugAdapterProtocolSession( + adapterID: "java", + transport: transport, + core: core, + sessionID: "java-run-in-terminal-stop", + deadlineScheduler: RecordingDebugDeadlineScheduler() + ) + var pendingCompletion: DebugRunInTerminalCompletion? + session.onRunInTerminalRequest = { _, completion in + pendingCompletion = completion + } + try session.start(rootURL: URL(fileURLWithPath: "/tmp/java-run-in-terminal-stop")) + core.enqueueReceive(sessionID: "java-run-in-terminal-stop", state: "launching", events: [[ + "sequence": 2, + "type": "runInTerminalRequested", + "requestId": "runInTerminal-45", + "request": [ + "kind": "integrated", + "cwd": "/tmp/java-run-in-terminal-stop", + "args": ["/opt/jdk/bin/java"], + "environment": [], + "argsCanBeInterpretedByShell": false + ] + ]]) + transport.emitData(Data("run-in-terminal-request".utf8)) + + session.stop() + #expect(core.runInTerminalCompletions.count == 1) + #expect(core.runInTerminalCompletions[0].requestID == "runInTerminal-45") + #expect(core.runInTerminalCompletions[0].errorDescription != nil) + + pendingCompletion?(.success(DebugRunInTerminalResponse(processID: 4242))) + #expect(core.runInTerminalCompletions.count == 1) + } + @Test func coreProtocolSessionForwardsVariablePagingAndChildCounts() throws { let transport = RecordingTransport() @@ -2289,6 +2373,8 @@ private final class RecordingDebugProtocolCore: DebugProtocolCore { private(set) var lastExecutionThreadID: Int? private(set) var inspectionRequests: [RecordingDebugInspectionRequest] = [] private(set) var lastLaunchConfiguration: DebugLaunchConfiguration? + private(set) var lastSupportsRunInTerminalRequest: Bool? + private(set) var runInTerminalCompletions: [RecordingRunInTerminalCompletion] = [] let defaultSteppingFilters = DebugSteppingFilters( classNameFilters: ["$JDK", "org.junit.*"], skipSynthetics: true, @@ -2307,9 +2393,11 @@ private final class RecordingDebugProtocolCore: DebugProtocolCore { func createDebugSession( sessionID: String, adapterID _: String, - rootPath _: String + rootPath _: String, + supportsRunInTerminalRequest: Bool ) throws -> DebugCoreUpdate { - update( + lastSupportsRunInTerminalRequest = supportsRunInTerminalRequest + return update( sessionID: sessionID, state: "initializing", frames: [Data("initialize-frame".utf8)] @@ -2444,6 +2532,32 @@ private final class RecordingDebugProtocolCore: DebugProtocolCore { receiveUpdates.removeFirst() } + func completeDebugRunInTerminalRequest( + sessionID: String, + requestID: String, + result: Result + ) throws -> DebugCoreUpdate { + switch result { + case .success(let response): + runInTerminalCompletions.append(RecordingRunInTerminalCompletion( + requestID: requestID, + response: response, + errorDescription: nil + )) + case .failure(let error): + runInTerminalCompletions.append(RecordingRunInTerminalCompletion( + requestID: requestID, + response: nil, + errorDescription: error.localizedDescription + )) + } + return update( + sessionID: sessionID, + state: "launching", + frames: [Data("run-in-terminal-response".utf8)] + ) + } + func disconnectDebugSession(sessionID: String) throws -> DebugCoreUpdate { update(sessionID: sessionID, state: "terminating") } @@ -2481,6 +2595,12 @@ private final class RecordingDebugProtocolCore: DebugProtocolCore { } } +private struct RecordingRunInTerminalCompletion: Equatable { + let requestID: String + let response: DebugRunInTerminalResponse? + let errorDescription: String? +} + @MainActor private final class RecordingDebugDeadlineScheduler: DebugOperationDeadlineScheduling { private var deadlines: [RecordingDebugDeadline] = [] diff --git a/macos/Tests/LitheTerminalModuleTests/TerminalModuleTests.swift b/macos/Tests/LitheTerminalModuleTests/TerminalModuleTests.swift index de18aba0..f49f57dd 100644 --- a/macos/Tests/LitheTerminalModuleTests/TerminalModuleTests.swift +++ b/macos/Tests/LitheTerminalModuleTests/TerminalModuleTests.swift @@ -21,6 +21,37 @@ struct TerminalModuleTests { #expect(feature.terminalSessions.isEmpty) } + @Test + func managedProcessLaunchPreservesArgumentsEnvironmentAndProcessID() throws { + let transport = TestTransport() + let feature = TerminalFeatureModel(terminalFactory: { transport }) + let launch = TerminalProcessLaunch( + title: "Debug Main", + executablePath: "/opt/jdk/bin/java", + arguments: ["-cp", "/workspace/classes", "example.Main"], + workingDirectory: "/workspace", + environmentChanges: [ + TerminalEnvironmentChange(name: "JAVA_HOME", value: "/opt/jdk"), + TerminalEnvironmentChange(name: "REMOVE_ME", value: nil) + ] + ) + + let created = try feature.createProcessSession(launch) + + #expect(created.processID == 1234) + #expect(created.session.isManagedProcess) + #expect(created.session.displayTitle == "Debug Main") + #expect(transport.processLaunches == [launch]) + #expect(transport.processEnvironments.first?["JAVA_HOME"] == "/opt/jdk") + #expect(transport.processEnvironments.first?["REMOVE_ME"] == nil) + #expect(transport.processEnvironments.first?["TERM_PROGRAM"] == "Lithe") + created.session.restart() + #expect(transport.processLaunches.count == 1) + + feature.stopAllSessions() + #expect(transport.stopCount == 1) + } + @Test func linkResolverKeepsExternalURLsAndResolvesLocations() { let workspace = URL(fileURLWithPath: "/tmp/lithe-terminal-module-test") @@ -42,15 +73,27 @@ struct TerminalModuleTests { private final class TestTransport: TerminalTransport { let nativeView: AnyObject = NSObject() var isRunning = false + var processID: Int32? { isRunning ? 1234 : nil } var shellName = "Shell" var onTermination: ((Int32?) -> Void)? var onTitle: ((String) -> Void)? var onDirectoryUpdate: ((String?) -> Void)? var onLink: ((String, [String: String]) -> Void)? var stopCount = 0 + var processLaunches: [TerminalProcessLaunch] = [] + var processEnvironments: [[String: String]] = [] func defaultShellPath() -> String { "/bin/zsh" } - func defaultEnvironment() -> [String: String] { [:] } + func defaultEnvironment() -> [String: String] { ["REMOVE_ME": "old"] } func start(workingDirectory: String, shellPath: String, environment: [String: String]) throws { isRunning = true } + func startProcess( + _ launch: TerminalProcessLaunch, + environment: [String: String] + ) throws -> Int32 { + processLaunches.append(launch) + processEnvironments.append(environment) + isRunning = true + return 1234 + } func send(_ input: Data) throws {} func interrupt() throws {} func focus() {} diff --git a/macos/Tests/LitheTests/LitheCoreLogicTests.swift b/macos/Tests/LitheTests/LitheCoreLogicTests.swift index 00efa217..58cc31b6 100644 --- a/macos/Tests/LitheTests/LitheCoreLogicTests.swift +++ b/macos/Tests/LitheTests/LitheCoreLogicTests.swift @@ -4020,6 +4020,7 @@ private actor SequencedGitWatchContextProvider: GitWatchContextProviding { private final class TestTerminalTransport: TerminalTransport { let nativeView: AnyObject = NSView(frame: .zero) var isRunning = false + var processID: Int32? { isRunning ? 1234 : nil } var shellName = "Shell" var onTermination: ((Int32?) -> Void)? var onTitle: ((String) -> Void)? @@ -4042,6 +4043,16 @@ private final class TestTerminalTransport: TerminalTransport { isRunning = true } + func startProcess( + _ launch: TerminalProcessLaunch, + environment: [String: String] + ) throws -> Int32 { + startRequests.append(launch.executablePath) + shellName = URL(fileURLWithPath: launch.executablePath).lastPathComponent + isRunning = true + return 1234 + } + func send(_ input: Data) throws {} func interrupt() throws {} diff --git a/macos/Tests/LitheTests/RealJavaDebugIntegrationTests.swift b/macos/Tests/LitheTests/RealJavaDebugIntegrationTests.swift index 0d29fbfb..b46b235b 100644 --- a/macos/Tests/LitheTests/RealJavaDebugIntegrationTests.swift +++ b/macos/Tests/LitheTests/RealJavaDebugIntegrationTests.swift @@ -2,6 +2,7 @@ import Foundation import LitheCoreContracts import LitheDebugModule import LitheLanguageIntelligenceModule +import LitheTerminalModule import Testing @testable import Lithe @@ -120,10 +121,13 @@ struct RealJavaDebugIntegrationTests { ) } let feature = GenericDebugFeatureModel(sessions: debugManager) + let debugTerminals = RealJavaDebugTerminalOwner(workspaceURL: rootURL) + feature.onRunInTerminalRequest = debugTerminals.handle var requestTask: Task<(Data, URLResponse), Error>? defer { requestTask?.cancel() feature.stop() + debugTerminals.stop() languageManager.stopAll() try? fileManager.removeItem(at: rootURL) try? fileManager.removeItem(at: cacheURL) @@ -151,7 +155,7 @@ struct RealJavaDebugIntegrationTests { var arguments: [String: ToolingJSONValue] = [ "mainClass": .string(target.mainClass), "cwd": .string(rootURL.path), - "console": .string("internalConsole"), + "console": .string("integratedTerminal"), "args": .string("--server.port=0") ] if let projectName = target.projectName { @@ -345,6 +349,8 @@ struct RealJavaDebugIntegrationTests { ) } let feature = GenericDebugFeatureModel(sessions: debugManager) + let debugTerminals = RealJavaDebugTerminalOwner(workspaceURL: rootURL) + feature.onRunInTerminalRequest = debugTerminals.handle let launchService = JavaTestDebugLaunchService( configurationResolver: DebugLaunchConfigurationResolver( fileExists: { fileManager.fileExists(atPath: $0.path) }, @@ -354,6 +360,7 @@ struct RealJavaDebugIntegrationTests { ) defer { feature.stop() + debugTerminals.stop() languageManager.stopAll() } @@ -615,6 +622,63 @@ private enum RealJavaTestDebugScenario: String { } } +@MainActor +private final class RealJavaDebugTerminalOwner { + private let workspaceURL: URL + private let feature = TerminalFeatureModel( + terminalFactory: { MacTerminalTransport() } + ) + + init(workspaceURL: URL) { + self.workspaceURL = workspaceURL.standardizedFileURL + } + + func handle( + _ request: DebugRunInTerminalRequest, + completion: @escaping DebugRunInTerminalCompletion + ) { + do { + guard request.kind == .integrated else { + throw RealJavaDebugTerminalError.externalTerminalUnsupported + } + guard !request.argsCanBeInterpretedByShell else { + throw RealJavaDebugTerminalError.shellInterpretationUnsupported + } + guard let executablePath = request.args.first, !executablePath.isEmpty else { + throw RealJavaDebugTerminalError.missingExecutable + } + let workingDirectory = request.cwd.isEmpty ? workspaceURL.path : request.cwd + guard workingDirectory.hasPrefix("/") else { + throw RealJavaDebugTerminalError.invalidWorkingDirectory + } + let launch = TerminalProcessLaunch( + title: request.title, + executablePath: executablePath, + arguments: Array(request.args.dropFirst()), + workingDirectory: workingDirectory, + environmentChanges: request.environment.map { + TerminalEnvironmentChange(name: $0.name, value: $0.value) + } + ) + let created = try feature.createProcessSession(launch) + completion(.success(DebugRunInTerminalResponse(processID: Int(created.processID)))) + } catch { + completion(.failure(error)) + } + } + + func stop() { + feature.stopAllSessions() + } +} + +private enum RealJavaDebugTerminalError: Error { + case externalTerminalUnsupported + case shellInterpretationUnsupported + case missingExecutable + case invalidWorkingDirectory +} + @MainActor private final class RealJavaDebugProtocolTrace { private(set) var entries: [String] = [] diff --git a/macos/Tests/LitheTests/TerminalPlacementFeatureModelTests.swift b/macos/Tests/LitheTests/TerminalPlacementFeatureModelTests.swift index 40038b52..ee62f5a2 100644 --- a/macos/Tests/LitheTests/TerminalPlacementFeatureModelTests.swift +++ b/macos/Tests/LitheTests/TerminalPlacementFeatureModelTests.swift @@ -99,6 +99,7 @@ struct TerminalPlacementFeatureModelTests { private final class PlacementTestTerminalTransport: TerminalTransport { let nativeView: AnyObject = NSObject() var isRunning = false + var processID: Int32? { isRunning ? 1234 : nil } var shellName = "Shell" var onTermination: ((Int32?) -> Void)? var onTitle: ((String) -> Void)? @@ -119,6 +120,15 @@ private final class PlacementTestTerminalTransport: TerminalTransport { isRunning = true } + func startProcess( + _ launch: TerminalProcessLaunch, + environment: [String: String] + ) throws -> Int32 { + startCount += 1 + isRunning = true + return 1234 + } + func send(_ input: Data) throws {} func interrupt() throws {} func focus() {} diff --git a/rust/lithe-core/src/debug/engine.rs b/rust/lithe-core/src/debug/engine.rs index bf469a3c..4fdf3ad7 100644 --- a/rust/lithe-core/src/debug/engine.rs +++ b/rust/lithe-core/src/debug/engine.rs @@ -26,6 +26,8 @@ struct DebugSession { did_configure_exception_breakpoints: bool, function_breakpoints: Vec, data_breakpoints: Vec, + supports_run_in_terminal_request: bool, + pending_run_in_terminal_requests: BTreeMap, did_receive_initialized: bool, supports_configuration_done: bool, capabilities: DebugCapabilities, @@ -101,6 +103,8 @@ pub(crate) fn create_session( did_configure_exception_breakpoints: false, function_breakpoints: Vec::new(), data_breakpoints: Vec::new(), + supports_run_in_terminal_request: request.supports_run_in_terminal_request, + pending_run_in_terminal_requests: BTreeMap::new(), did_receive_initialized: false, supports_configuration_done: false, capabilities: DebugCapabilities::default(), @@ -122,7 +126,7 @@ pub(crate) fn create_session( "pathFormat": "path", "supportsVariableType": true, "supportsVariablePaging": true, - "supportsRunInTerminalRequest": false, + "supportsRunInTerminalRequest": session.supports_run_in_terminal_request, "supportsMemoryReferences": false, "supportsProgressReporting": false, "supportsInvalidatedEvent": true @@ -671,6 +675,56 @@ pub(crate) fn receive(request: ReceiveRequest) -> Result Result { + validate_identifier(&request.request_id, "requestId")?; + with_session(&request.session_id, |session| { + let Some(&request_sequence) = session + .pending_run_in_terminal_requests + .get(&request.request_id) + else { + // Native terminal creation is asynchronous. A response that races + // session shutdown, timeout, or a previous completion must not be + // applied to a later adapter request. + return Ok(session.take_update()); + }; + + if request.success { + validate_dap_process_id(request.process_id, "processId")?; + validate_dap_process_id(request.shell_process_id, "shellProcessId")?; + } + session + .pending_run_in_terminal_requests + .remove(&request.request_id); + + if request.success { + let mut body = Map::new(); + insert_option(&mut body, "processId", request.process_id); + insert_option(&mut body, "shellProcessId", request.shell_process_id); + session.send_response_with_body( + request_sequence, + "runInTerminal", + true, + None, + Some(Value::Object(body)), + )?; + } else { + let message = normalize_optional_text(request.message) + .unwrap_or_else(|| "Lithe could not start the debuggee terminal.".to_string()); + session.send_response_with_body( + request_sequence, + "runInTerminal", + false, + Some(&message), + None, + )?; + } + Ok(session.take_update()) + }) +} + /// Begins a graceful DAP disconnect while the host keeps transport ownership. pub(crate) fn disconnect(request: SessionRequest) -> Result { with_session(&request.session_id, |session| { @@ -680,6 +734,9 @@ pub(crate) fn disconnect(request: SessionRequest) -> Result, + ) -> Result<(), CoreError> { + self.send_response_with_body(request_sequence, command, success, message, None) + } + + fn send_response_with_body( + &mut self, + request_sequence: i64, + command: &str, + success: bool, + message: Option<&str>, + body: Option, ) -> Result<(), CoreError> { let sequence = self.next_request_sequence; self.next_request_sequence += 1; @@ -911,6 +979,9 @@ impl DebugSession { if let Some(message) = message { response["message"] = Value::String(message.to_string()); } + if let Some(body) = body { + response["body"] = body; + } self.outbound_frames.push(frame_message(&response)?); Ok(()) } @@ -1167,6 +1238,38 @@ impl DebugSession { fn handle_server_request(&mut self, message: &Value) -> Result<(), CoreError> { let request_sequence = required_i64(message, "seq")?; let command = required_str(message, "command")?; + if command == "runInTerminal" && self.supports_run_in_terminal_request { + let arguments = message.get("arguments").unwrap_or(&Value::Null); + match parse_run_in_terminal_request(arguments) { + Ok(request) => { + let request_id = format!("runInTerminal-{request_sequence}"); + if self + .pending_run_in_terminal_requests + .contains_key(&request_id) + { + // One DAP sequence can have only one response. Keep the + // original native launch pending instead of replacing + // it or starting a second process for a malformed retry. + return Ok(()); + } + self.pending_run_in_terminal_requests + .insert(request_id.clone(), request_sequence); + self.emit(DebugEventBody::RunInTerminalRequested { + request_id, + request, + }); + return Ok(()); + } + Err(error) => { + return self.send_response( + request_sequence, + command, + false, + Some(&error.message), + ); + } + } + } self.send_response( request_sequence, command, @@ -1175,6 +1278,14 @@ impl DebugSession { ) } + fn fail_pending_run_in_terminal_requests(&mut self, message: &str) -> Result<(), CoreError> { + let pending = std::mem::take(&mut self.pending_run_in_terminal_requests); + for (_, request_sequence) in pending { + self.send_response(request_sequence, "runInTerminal", false, Some(message))?; + } + Ok(()) + } + fn emit_breakpoint_results( &mut self, body: &Value, @@ -1854,6 +1965,132 @@ fn sessions_lock( .map_err(|_| CoreError::new(ErrorCode::Unknown, "Debug session state is unavailable.")) } +fn parse_run_in_terminal_request(value: &Value) -> Result { + const MAX_ARGUMENT_COUNT: usize = 4_096; + const MAX_ENVIRONMENT_COUNT: usize = 4_096; + + let object = value + .as_object() + .ok_or_else(|| invalid_request("Debug runInTerminal arguments must be a JSON object."))?; + let kind = match object.get("kind") { + None => DebugRunInTerminalKind::Integrated, + Some(Value::String(value)) if value == "integrated" => DebugRunInTerminalKind::Integrated, + Some(Value::String(value)) if value == "external" => DebugRunInTerminalKind::External, + _ => { + return Err(invalid_request( + "Debug runInTerminal kind must be integrated or external.", + )) + } + }; + let title = match object.get("title") { + None | Some(Value::Null) => None, + Some(Value::String(value)) => normalize_optional_text(Some(value.clone())), + _ => { + return Err(invalid_request( + "Debug runInTerminal title must be a string.", + )) + } + }; + let cwd = object + .get("cwd") + .and_then(Value::as_str) + .ok_or_else(|| invalid_request("Debug runInTerminal cwd must be a string."))? + .to_string(); + if cwd.contains('\0') { + return Err(invalid_request( + "Debug runInTerminal cwd contains an invalid null byte.", + )); + } + let argument_values = object + .get("args") + .and_then(Value::as_array) + .ok_or_else(|| invalid_request("Debug runInTerminal args must be an array."))?; + if argument_values.is_empty() || argument_values.len() > MAX_ARGUMENT_COUNT { + return Err(invalid_request( + "Debug runInTerminal args must contain between 1 and 4096 items.", + )); + } + let mut args = Vec::with_capacity(argument_values.len()); + for value in argument_values { + let argument = value.as_str().ok_or_else(|| { + invalid_request("Debug runInTerminal args must contain only strings.") + })?; + if argument.contains('\0') { + return Err(invalid_request( + "Debug runInTerminal args contain an invalid null byte.", + )); + } + args.push(argument.to_string()); + } + if args[0].trim().is_empty() { + return Err(invalid_request( + "Debug runInTerminal args must begin with an executable.", + )); + } + + let mut environment = Vec::new(); + if let Some(value) = object.get("env") { + let values = value + .as_object() + .ok_or_else(|| invalid_request("Debug runInTerminal env must be a JSON object."))?; + if values.len() > MAX_ENVIRONMENT_COUNT { + return Err(invalid_request( + "Debug runInTerminal env cannot exceed 4096 entries.", + )); + } + let mut names: Vec<&String> = values.keys().collect(); + names.sort(); + for name in names { + if name.is_empty() || name.contains(['=', '\0']) { + return Err(invalid_request( + "Debug runInTerminal env contains an invalid variable name.", + )); + } + let value = match &values[name] { + Value::Null => None, + Value::String(value) if !value.contains('\0') => Some(value.clone()), + _ => { + return Err(invalid_request( + "Debug runInTerminal env values must be strings or null.", + )) + } + }; + environment.push(DebugRunInTerminalEnvironmentVariable { + name: name.clone(), + value, + }); + } + } + let args_can_be_interpreted_by_shell = match object.get("argsCanBeInterpretedByShell") { + None => false, + Some(Value::Bool(value)) => *value, + _ => { + return Err(invalid_request( + "Debug runInTerminal argsCanBeInterpretedByShell must be a boolean.", + )) + } + }; + + Ok(DebugRunInTerminalRequest { + kind, + title, + cwd, + args, + environment, + args_can_be_interpreted_by_shell, + }) +} + +fn validate_dap_process_id(value: Option, field: &str) -> Result<(), CoreError> { + if value.is_some_and(|value| !(1..=i32::MAX as i64).contains(&value)) { + return Err(invalid_request(&format!( + "Debug runInTerminal {field} must be between 1 and {}.", + i32::MAX + ))); + } + Ok(()) +} + fn validate_identifier(value: &str, field: &str) -> Result<(), CoreError> { if value.trim().is_empty() || value.contains('\0') || value.len() > 512 { return Err(invalid_request(&format!("Debug {field} was invalid."))); @@ -2051,6 +2288,7 @@ mod tests { session_id: session_id.clone(), adapter_id: "java".to_string(), root_path: "/workspace".to_string(), + supports_run_in_terminal_request: false, }) .unwrap(); @@ -2228,6 +2466,7 @@ mod tests { session_id: session_id.to_string(), adapter_id: "java".to_string(), root_path: "/workspace".to_string(), + supports_run_in_terminal_request: false, }) .unwrap(); launch(LaunchRequest { @@ -2362,6 +2601,7 @@ mod tests { session_id: session_id.to_string(), adapter_id: "java".to_string(), root_path: "/workspace".to_string(), + supports_run_in_terminal_request: false, }) .unwrap(); assert_eq!(created.state, DebugSessionState::Initializing); @@ -2637,6 +2877,7 @@ mod tests { session_id: session_id.to_string(), adapter_id: "java".to_string(), root_path: "/workspace".to_string(), + supports_run_in_terminal_request: false, }) .unwrap(); set_data_breakpoints(SetDataBreakpointsRequest { @@ -2757,6 +2998,7 @@ mod tests { session_id: session_id.to_string(), adapter_id: "java".to_string(), root_path: "/workspace".to_string(), + supports_run_in_terminal_request: false, }) .unwrap(); launch(LaunchRequest { @@ -2842,6 +3084,7 @@ mod tests { session_id: session_id.to_string(), adapter_id: "java".to_string(), root_path: "/workspace".to_string(), + supports_run_in_terminal_request: false, }) .unwrap(); launch(LaunchRequest { @@ -2962,6 +3205,7 @@ mod tests { session_id: session_id.to_string(), adapter_id: "java".to_string(), root_path: "/workspace".to_string(), + supports_run_in_terminal_request: false, }) .unwrap(); launch(LaunchRequest { @@ -3058,6 +3302,7 @@ mod tests { session_id: session_id.to_string(), adapter_id: "java".to_string(), root_path: "/workspace".to_string(), + supports_run_in_terminal_request: false, }) .unwrap(); launch(LaunchRequest { @@ -3228,6 +3473,7 @@ mod tests { session_id: session_id.to_string(), adapter_id: "java".to_string(), root_path: "/workspace".to_string(), + supports_run_in_terminal_request: false, }) .unwrap(); launch(LaunchRequest { @@ -3330,6 +3576,7 @@ mod tests { session_id: session_id.to_string(), adapter_id: "java".to_string(), root_path: "/workspace".to_string(), + supports_run_in_terminal_request: false, }) .unwrap(); launch(LaunchRequest { @@ -3419,6 +3666,7 @@ mod tests { session_id: session_id.to_string(), adapter_id: "java".to_string(), root_path: "/workspace".to_string(), + supports_run_in_terminal_request: false, }) .unwrap(); launch(LaunchRequest { @@ -3470,6 +3718,124 @@ mod tests { .unwrap(); } + #[test] + fn run_in_terminal_request_is_normalized_completed_and_stale_safe() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/debug/run-in-terminal-v1.json" + ))) + .unwrap(); + let session_id = "debug-run-in-terminal"; + let created = create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "java".to_string(), + root_path: "/workspace".to_string(), + supports_run_in_terminal_request: true, + }) + .unwrap(); + assert_eq!( + decode_frame(&created.outbound_frames[0])["arguments"]["supportsRunInTerminalRequest"], + true + ); + + let requested = receive_messages(session_id, vec![fixture["adapterRequest"].clone()]); + assert!(requested.outbound_frames.is_empty()); + let request_id = requested + .events + .iter() + .find_map(|event| match &event.body { + DebugEventBody::RunInTerminalRequested { + request_id, + request, + } => { + assert_eq!( + serde_json::to_value(request).unwrap(), + fixture["expectedRequest"] + ); + Some(request_id.clone()) + } + _ => None, + }) + .unwrap(); + + let duplicate = receive_messages(session_id, vec![fixture["adapterRequest"].clone()]); + assert!(duplicate.outbound_frames.is_empty()); + assert!(duplicate.events.is_empty()); + + let invalid = run_in_terminal_response(DebugRunInTerminalResponseRequest { + session_id: session_id.to_string(), + request_id: request_id.clone(), + success: true, + process_id: Some(0), + shell_process_id: None, + message: None, + }) + .unwrap_err(); + assert!(matches!(invalid.code, ErrorCode::InvalidRequest)); + + let success = run_in_terminal_response(DebugRunInTerminalResponseRequest { + session_id: session_id.to_string(), + request_id: request_id.clone(), + success: true, + process_id: fixture["successResponse"]["processId"].as_i64(), + shell_process_id: fixture["successResponse"]["shellProcessId"].as_i64(), + message: None, + }) + .unwrap(); + let response = decode_frame(&success.outbound_frames[0]); + assert_eq!(response["request_seq"], fixture["adapterRequest"]["seq"]); + assert_eq!(response["success"], true); + assert_eq!(response["body"], fixture["expectedSuccessBody"]); + + let stale = run_in_terminal_response(DebugRunInTerminalResponseRequest { + session_id: session_id.to_string(), + request_id, + success: false, + process_id: None, + shell_process_id: None, + message: Some(fixture["failureMessage"].as_str().unwrap().to_string()), + }) + .unwrap(); + assert!(stale.outbound_frames.is_empty()); + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); + } + + #[test] + fn malformed_run_in_terminal_request_gets_an_explicit_failure_response() { + let session_id = "debug-run-in-terminal-invalid"; + create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "java".to_string(), + root_path: "/workspace".to_string(), + supports_run_in_terminal_request: true, + }) + .unwrap(); + + let update = receive_messages( + session_id, + vec![request_message( + 45, + "runInTerminal", + json!({"cwd": "/workspace", "args": []}), + )], + ); + + let response = decode_frame(&update.outbound_frames[0]); + assert_eq!(response["request_seq"], 45); + assert_eq!(response["success"], false); + assert!(response["message"] + .as_str() + .unwrap() + .contains("between 1 and 4096")); + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); + } + #[test] fn unknown_server_request_gets_an_explicit_failure_response() { let session_id = "debug-server-request"; @@ -3477,6 +3843,7 @@ mod tests { session_id: session_id.to_string(), adapter_id: "java".to_string(), root_path: "/workspace".to_string(), + supports_run_in_terminal_request: false, }) .unwrap(); diff --git a/rust/lithe-core/src/debug/java_test.rs b/rust/lithe-core/src/debug/java_test.rs index f119e467..9723ab56 100644 --- a/rust/lithe-core/src/debug/java_test.rs +++ b/rust/lithe-core/src/debug/java_test.rs @@ -66,7 +66,7 @@ pub fn java_test_launch( arguments.insert("cwd".to_string(), Value::String(working_directory)); arguments.insert( "console".to_string(), - Value::String("internalConsole".to_string()), + Value::String("integratedTerminal".to_string()), ); if let Some(project_name) = optional_non_empty(request.project_name) { arguments.insert("projectName".to_string(), Value::String(project_name)); diff --git a/rust/lithe-core/src/debug/types.rs b/rust/lithe-core/src/debug/types.rs index 828d7284..4855c16f 100644 --- a/rust/lithe-core/src/debug/types.rs +++ b/rust/lithe-core/src/debug/types.rs @@ -25,6 +25,9 @@ pub struct CreateSessionRequest { pub session_id: String, pub adapter_id: String, pub root_path: String, + /// Whether the native host can launch adapter-requested processes in a terminal. + #[serde(default)] + pub supports_run_in_terminal_request: bool, } #[derive(Debug, Clone, Deserialize)] @@ -433,6 +436,52 @@ pub struct ReceiveRequest { pub data_base64: String, } +#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +/// Terminal surface requested by a Debug Adapter Protocol reverse request. +pub enum DebugRunInTerminalKind { + Integrated, + External, +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +/// One deterministic environment mutation requested for a debuggee process. +pub struct DebugRunInTerminalEnvironmentVariable { + pub name: String, + /// A missing value removes the inherited variable from the child environment. + pub value: Option, +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +/// Normalized platform request for launching a debuggee inside a terminal. +pub struct DebugRunInTerminalRequest { + pub kind: DebugRunInTerminalKind, + pub title: Option, + pub cwd: String, + /// The executable is the first item; remaining items are passed without a shell. + pub args: Vec, + pub environment: Vec, + /// True only when the adapter intentionally supplied shell-language arguments. + pub args_can_be_interpreted_by_shell: bool, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Completes one pending `runInTerminal` reverse request from the native host. +pub struct DebugRunInTerminalResponseRequest { + pub session_id: String, + pub request_id: String, + pub success: bool, + #[serde(default)] + pub process_id: Option, + #[serde(default)] + pub shell_process_id: Option, + #[serde(default)] + pub message: Option, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] /// Effects produced by one deterministic session reduction. @@ -522,6 +571,10 @@ pub enum DebugEventBody { Breakpoint { breakpoint: DebugBreakpoint, }, + RunInTerminalRequested { + request_id: String, + request: DebugRunInTerminalRequest, + }, OperationCompleted { operation_id: String, result: DebugOperationResult, diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index 0547face..ac42b0b1 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -113,6 +113,8 @@ pub enum CoreCommand { DebugInspect, /// Reduces bytes received from a platform-owned DAP transport (`debug.receive`). DebugReceive, + /// Completes one adapter-requested terminal launch (`debug.runInTerminalResponse`). + DebugRunInTerminalResponse, /// Begins the DAP disconnect handshake (`debug.disconnect`). DebugDisconnect, /// Removes all state for a debug session (`debug.destroySession`). @@ -278,6 +280,7 @@ impl CoreCommand { "debug.execute" => Some(Self::DebugExecute), "debug.inspect" => Some(Self::DebugInspect), "debug.receive" => Some(Self::DebugReceive), + "debug.runInTerminalResponse" => Some(Self::DebugRunInTerminalResponse), "debug.disconnect" => Some(Self::DebugDisconnect), "debug.destroySession" => Some(Self::DebugDestroySession), "lsp.applyTextEdits" => Some(Self::LspApplyTextEdits), @@ -401,6 +404,7 @@ mod tests { "debug.execute", "debug.inspect", "debug.receive", + "debug.runInTerminalResponse", "debug.disconnect", "debug.destroySession", ] { diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index 1819a85d..90e44bbc 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -750,6 +750,27 @@ fn execute(request: &str) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::DebugRunInTerminalResponse => { + match serde_json::from_value::( + parsed.payload, + ) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid debug run-in-terminal response", + ) + .with_details(error.to_string()) + }) + .and_then(crate::debug::run_in_terminal_response) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data) + .expect("Debug run-in-terminal response update should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::DebugDisconnect => { match serde_json::from_value::(parsed.payload) .map_err(|error| { diff --git a/rust/lithe-core/src/tests/protocol.rs b/rust/lithe-core/src/tests/protocol.rs index f35d5059..b9f3a0a8 100644 --- a/rust/lithe-core/src/tests/protocol.rs +++ b/rust/lithe-core/src/tests/protocol.rs @@ -60,6 +60,98 @@ fn debug_create_and_destroy_commands_cross_the_json_boundary() { assert_eq!(destroyed["data"]["destroyed"], true); } +#[test] +fn debug_run_in_terminal_response_crosses_the_json_boundary() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/debug/run-in-terminal-v1.json" + ))) + .expect("debug run-in-terminal fixture should be valid JSON"); + let session_id = "debug-terminal-protocol-boundary"; + let create_request = serde_json::json!({ + "id": "debug-terminal-create", + "command": "debug.createSession", + "payload": { + "sessionId": session_id, + "adapterId": "java", + "rootPath": "/workspace", + "supportsRunInTerminalRequest": true + } + }); + let created: Value = serde_json::from_str(&execute_json(&create_request.to_string())) + .expect("debug create response should be JSON"); + assert_eq!(created["ok"], true); + + let adapter_message = + serde_json::to_vec(&fixture["adapterRequest"]).expect("adapter request should encode"); + let mut framed = format!("Content-Length: {}\r\n\r\n", adapter_message.len()).into_bytes(); + framed.extend(adapter_message); + let receive_request = serde_json::json!({ + "id": "debug-terminal-receive", + "command": "debug.receive", + "payload": { + "sessionId": session_id, + "dataBase64": BASE64.encode(framed) + } + }); + let received: Value = serde_json::from_str(&execute_json(&receive_request.to_string())) + .expect("debug receive response should be JSON"); + assert_eq!(received["ok"], true); + let terminal_event = received["data"]["events"] + .as_array() + .expect("debug update should contain events") + .iter() + .find(|event| event["type"] == "runInTerminalRequested") + .expect("debug update should request an integrated terminal"); + assert_eq!(terminal_event["request"], fixture["expectedRequest"]); + let request_id = terminal_event["requestId"] + .as_str() + .expect("terminal request should have a correlation ID"); + + let response_request = serde_json::json!({ + "id": "debug-terminal-response", + "command": "debug.runInTerminalResponse", + "payload": { + "sessionId": session_id, + "requestId": request_id, + "success": true, + "processId": fixture["successResponse"]["processId"], + "shellProcessId": fixture["successResponse"]["shellProcessId"] + } + }); + let completed: Value = serde_json::from_str(&execute_json(&response_request.to_string())) + .expect("debug run-in-terminal response should be JSON"); + assert_eq!(completed["ok"], true); + let response_frame = completed["data"]["outboundFrames"][0] + .as_str() + .expect("terminal response frame should be base64"); + let response_bytes = BASE64 + .decode(response_frame) + .expect("terminal response frame should decode"); + let body_start = response_bytes + .windows(4) + .position(|window| window == b"\r\n\r\n") + .expect("terminal response frame should have a header") + + 4; + let response_message: Value = serde_json::from_slice(&response_bytes[body_start..]) + .expect("terminal response body should be JSON"); + assert_eq!( + response_message["request_seq"], + fixture["adapterRequest"]["seq"] + ); + assert_eq!(response_message["success"], true); + assert_eq!(response_message["body"], fixture["expectedSuccessBody"]); + + let destroy_request = serde_json::json!({ + "id": "debug-terminal-destroy", + "command": "debug.destroySession", + "payload": {"sessionId": session_id} + }); + let destroyed: Value = serde_json::from_str(&execute_json(&destroy_request.to_string())) + .expect("debug destroy response should be JSON"); + assert_eq!(destroyed["ok"], true); +} + #[test] fn debug_stepping_filters_match_the_shared_contract_fixture() { let fixture: Value = serde_json::from_str(include_str!(concat!( diff --git a/shared/contracts/application-boundary.md b/shared/contracts/application-boundary.md index 928a38a9..1165c66e 100644 --- a/shared/contracts/application-boundary.md +++ b/shared/contracts/application-boundary.md @@ -30,7 +30,7 @@ verification scripts are the executable source of boundary checks. | Runtime | Java/Maven requirements, normalized candidates, and effective toolchain references | JDK/Maven probing and executable paths | | Language tooling | provider catalog, local fallback results, complete LSP process/session runtime, capabilities, diagnostics, UTF-16 edits, and normalized feature results | executable/environment discovery and UI provider routing | | Java/Maven/Spring | deterministic Maven-root selection, project structure, modules and profiles; compiler diagnostic parsing; Java source structure, symbols, code vision, run-configuration detection, Spring configuration/bean/endpoint indexing, and JDTLS/Java Debug adapter policy | JDK/Maven discovery, local dependency-repository selection, Java/Maven child processes, and sockets | -| Run/Debug | versioned configuration documents, three-layer resolution, diagnostics, platform-neutral launch plans, DAP framing/state, breakpoint relocation, stepping filters, threads, stacks, variables, and events | project and preference persistence, native edit reporting, adapter discovery, child processes, sockets, native termination, and UI | +| Run/Debug | versioned configuration documents, three-layer resolution, diagnostics, platform-neutral launch plans, DAP framing/state, reverse terminal requests, breakpoint relocation, stepping filters, threads, stacks, variables, and events | project and preference persistence, native edit reporting, adapter discovery, PTY/ConPTY debuggee launch, child processes, sockets, native termination, and UI | | Terminal | input bytes, output bytes, lifecycle | PTY/ConPTY, shell and environment | | Workbench background | versioned source (`none`, bundled slot `01`–`10`, or `custom`) and opacity | UI, image rendering, bundled-resource packaging, local-image access permission and persistence | | Local History | revision metadata, text content, restore result | persistence location and file operations | @@ -223,6 +223,14 @@ and discards stale pages after the selected frame changes. A native client must also stop offering more pages when an adapter returns more children than were requested or repeats an already loaded page. +Debugger terminal launch ownership is split at the native boundary. Rust Core +advertises terminal support, validates and normalizes DAP `runInTerminal` +reverse requests, correlates the platform response, and rejects stale or +duplicate completions. The platform Terminal module owns PTY/ConPTY creation, +direct executable-and-argument startup, environment application, process IDs, +terminal presentation, and native termination. A Debug session is still lazy: +neither a terminal nor a debuggee process exists until an adapter requests one. + Debugger disconnect ownership is portable. A session started with `launch` owns its local debuggee and sends `terminateDebuggee: true` when stopping. A session started with `attach` does not own the remote JVM and sends diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index c03bf22a..37daf6a5 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -96,6 +96,7 @@ stable error code and a user-facing message: | `debug.execute` | Submit continue, pause, next, step-in, or step-out control | | `debug.inspect` | Request normalized threads, frames, scopes, variables, or evaluation | | `debug.receive` | Reduce base64-encoded bytes received from a platform-owned DAP transport | +| `debug.runInTerminalResponse` | Complete one adapter-requested native terminal launch | | `debug.disconnect` | Begin the DAP disconnect handshake without closing the native transport | | `debug.destroySession` | Remove a session after the platform closes its native transport | | `lsp.applyTextEdits` | Apply LSP UTF-16 text edits with range validation | @@ -368,7 +369,8 @@ scope, variable, evaluation, output, stop, continue, and termination events. Platforms own adapter discovery, JDT LS activation, sockets or process pipes, native process termination, persistence, and UI rendering. -`debug.createSession` accepts `{ sessionId, adapterId, rootPath }`. It does not +`debug.createSession` accepts `{ sessionId, adapterId, rootPath, +supportsRunInTerminalRequest }`. It does not open a socket or launch a process. It returns a session update in `initializing` state with an ordered `outboundFrames` array. Each frame is a complete Content-Length-framed byte sequence encoded as base64. Every Debug @@ -377,6 +379,18 @@ events }`. The platform writes frames in array order and feeds received chunks back through `debug.receive` as `{ sessionId, dataBase64 }`; partial and consecutive messages are buffered and reduced in Rust. +When `supportsRunInTerminalRequest` is true, the initialize frame advertises +the native host's terminal capability. An adapter `runInTerminal` reverse +request becomes a deterministic `runInTerminalRequested` event containing a +Core-generated `requestId`, terminal kind, title, working directory, ordered +argument vector, sorted environment changes, and shell-interpretation flag. +The platform launches the process through its PTY/ConPTY adapter and calls +`debug.runInTerminalResponse` with `{ sessionId, requestId, success, processId?, +shellProcessId?, message? }`. Core validates process identifiers, emits the DAP +response, ignores duplicate or expired completions, and fails pending terminal +requests when the session disconnects. The shared compatibility cases are in +`shared/fixtures/debug/run-in-terminal-v1.json`. + `debug.launch` accepts an `operationId` and a language-neutral configuration containing `name`, request kind (`launch` or `attach`), provider arguments, and optional portable `steppingFilters`. diff --git a/shared/fixtures/debug/java-test-launch-v1.json b/shared/fixtures/debug/java-test-launch-v1.json index de29eced..9f680a19 100644 --- a/shared/fixtures/debug/java-test-launch-v1.json +++ b/shared/fixtures/debug/java-test-launch-v1.json @@ -23,7 +23,7 @@ "arguments": { "mainClass": "org.eclipse.jdt.internal.junit.runner.RemoteTestRunner", "cwd": "/workspace/service", - "console": "internalConsole", + "console": "integratedTerminal", "projectName": "service", "classPaths": ["/workspace/service/classes"], "args": "-version 3 -port 43127", @@ -57,7 +57,7 @@ "arguments": { "mainClass": "com.microsoft.java.test.runner.Launcher", "cwd": "/workspace/service", - "console": "internalConsole", + "console": "integratedTerminal", "projectName": "service", "classPaths": ["/workspace/service/classes", "/lithe/java-test-runner.jar"], "modulePaths": ["/workspace/service/modules"], diff --git a/shared/fixtures/debug/run-in-terminal-v1.json b/shared/fixtures/debug/run-in-terminal-v1.json new file mode 100644 index 00000000..06d95d23 --- /dev/null +++ b/shared/fixtures/debug/run-in-terminal-v1.json @@ -0,0 +1,54 @@ +{ + "version": 1, + "adapterRequest": { + "seq": 44, + "type": "request", + "command": "runInTerminal", + "arguments": { + "kind": "integrated", + "title": "Debug Main", + "cwd": "/workspace/service", + "args": [ + "/opt/jdk/bin/java", + "-cp", + "/workspace/service/classes", + "example.Main" + ], + "env": { + "JAVA_HOME": "/opt/jdk", + "LITHE_REMOVE_ME": null + }, + "argsCanBeInterpretedByShell": false + } + }, + "expectedRequest": { + "kind": "integrated", + "title": "Debug Main", + "cwd": "/workspace/service", + "args": [ + "/opt/jdk/bin/java", + "-cp", + "/workspace/service/classes", + "example.Main" + ], + "environment": [ + { + "name": "JAVA_HOME", + "value": "/opt/jdk" + }, + { + "name": "LITHE_REMOVE_ME", + "value": null + } + ], + "argsCanBeInterpretedByShell": false + }, + "successResponse": { + "processId": 4242, + "shellProcessId": null + }, + "expectedSuccessBody": { + "processId": 4242 + }, + "failureMessage": "The integrated terminal is unavailable." +} From 6a0f1bdc7586c3d133154f0bb3ba1791e668a2c4 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sun, 30 Aug 2026 10:38:25 +0800 Subject: [PATCH 42/66] feat(debug): polish macOS debugger toolbar and console --- macos/Resources/en.lproj/Localizable.strings | 11 + .../zh-Hans.lproj/Localizable.strings | 11 + .../Lithe/Views/Debug/GenericDebugView.swift | 316 ++++++++++++------ 3 files changed, 240 insertions(+), 98 deletions(-) diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 889cf7d9..d1f62a35 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -111,5 +111,16 @@ "Console" = "Console"; "Debugger" = "Debugger"; "Breakpoints" = "Breakpoints"; +"Mute breakpoints" = "Mute breakpoints"; +"Enable breakpoints" = "Enable breakpoints"; +"Resume" = "Resume"; +"Rerun" = "Rerun"; +"Stop debugging" = "Stop debugging"; +"Choose Step Target" = "Choose Step Target"; +"No callable target at this location" = "No callable target at this location"; +"Evaluate expression while paused" = "Evaluate expression while paused"; +"Connect to running JVM" = "Connect to running JVM"; +"Smart step into" = "Smart step into"; +"Clear output" = "Clear output"; "Pull Requests integration is under development" = "Pull Requests integration is under development"; "GitHub sign-in and pull request management are temporarily unavailable." = "GitHub sign-in and pull request management are temporarily unavailable."; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 1d031025..5a77abcc 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -883,6 +883,17 @@ "Manage all project breakpoints" = "管理项目中的所有断点"; "View breakpoints (⌘⇧F8)" = "查看断点(⌘⇧F8)"; "View breakpoints" = "查看断点"; +"Mute breakpoints" = "暂停断点"; +"Enable breakpoints" = "启用断点"; +"Resume" = "恢复执行"; +"Rerun" = "重新运行"; +"Stop debugging" = "停止调试"; +"Choose Step Target" = "选择步进目标"; +"No callable target at this location" = "当前位置没有可调用目标"; +"Evaluate expression while paused" = "暂停时计算表达式"; +"Connect to running JVM" = "连接到正在运行的 JVM"; +"Smart step into" = "智能步入"; +"Clear output" = "清除输出"; "Loading breakpoints…" = "正在加载断点…"; "Manage project breakpoints without starting a debug session" = "无需启动调试会话即可管理项目断点"; "Line Breakpoints" = "行断点"; diff --git a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift index a9b2d494..2c5ca054 100644 --- a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift +++ b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift @@ -13,11 +13,15 @@ struct GenericDebugView: View { @State private var isJavaAttachPresented = false @State private var isJavaSteppingSettingsPresented = false @State private var selectedContent: DebugContent = .debugger + @State private var consoleExpression = "" + @FocusState private var isConsoleInputFocused: Bool var body: some View { VStack(spacing: 0) { header Rectangle().fill(LitheTheme.divider).frame(height: 1) + debugToolbar + Rectangle().fill(LitheTheme.divider).frame(height: 1) if feature.isSessionActive || !feature.output.isEmpty || feature.errorMessage != nil { VStack(spacing: 0) { contentTabs @@ -61,12 +65,20 @@ struct GenericDebugView: View { switch state { case .paused: selectedContent = .debugger + if isConsoleInputFocused { + isConsoleInputFocused = false + } case .failed: selectedContent = .console default: break } } + .onChange(of: selectedContent) { content in + if content == .console && feature.state == .paused { + isConsoleInputFocused = true + } + } } @ViewBuilder @@ -77,7 +89,7 @@ struct GenericDebugView: View { case .breakpoints: DebugBreakpointManagerView(feature: feature) case .console: - output + debugConsole } } @@ -149,6 +161,13 @@ struct GenericDebugView: View { .litheIconButton() .help("View breakpoints (⌘⇧F8)") .accessibilityLabel("View breakpoints") + Button { feature.toggleBreakpointMute() } label: { + Image(systemName: feature.areBreakpointsMuted ? "eye.slash" : "eye") + } + .litheIconButton() + .disabled(feature.breakpoints.isEmpty) + .help(feature.areBreakpointsMuted ? "Enable breakpoints" : "Mute breakpoints") + .accessibilityLabel(feature.areBreakpointsMuted ? "Enable breakpoints" : "Mute breakpoints") if feature.javaSteppingFilters != nil { Button { isJavaSteppingSettingsPresented = true } label: { Image(systemName: "line.3.horizontal.decrease.circle") @@ -163,99 +182,165 @@ struct GenericDebugView: View { .litheIconButton() .disabled(feature.isSessionActive) .help("Connect to running JVM") - controlButton( - feature.state == .running ? "pause.fill" : "play.fill", - help: feature.state == .running ? "Pause" : "Continue", - disabled: !feature.canControl - ) { - feature.execute(feature.state == .running ? .pause : .continueExecution) - } - controlButton("arrow.right.to.line", help: "Step over", disabled: feature.state != .paused) { - feature.execute(.next) - } - controlButton("arrow.down.to.line", help: "Step into", disabled: feature.state != .paused) { - feature.execute(.stepIn) + controlButton("trash", help: "Clear output", disabled: false) { + feature.clearOutput() } - if feature.capabilities.supportsStepInTargetsRequest { - Button { - feature.requestSmartStepInto { result in - guard case .success(let targets) = result else { return } - if targets.count == 1, let target = targets.first { - feature.smartStepInto(target) + } + } + + private var debugToolbar: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 3) { + toolbarGroup { + Button { + if feature.isSessionActive { + if feature.canTerminate { + feature.execute(.terminate) + } else { + model.stopDebugging() + } } else { - smartStepTargets = targets - isSmartStepPickerPresented = true + model.startDebugging() } + } label: { + Image(systemName: feature.isSessionActive ? "stop.fill" : "play.fill") + } + .litheIconButton() + .foregroundStyle(feature.isSessionActive ? LitheTheme.warning : LitheTheme.success) + .help(feature.isSessionActive ? "Stop debugging" : "Start debugging") + + controlButton( + feature.state == .running ? "pause.fill" : "play.fill", + help: feature.state == .running ? "Pause" : "Resume", + disabled: !feature.canControl + ) { + feature.execute(feature.state == .running ? .pause : .continueExecution) } - } label: { - Image(systemName: "arrow.down.right.and.arrow.up.left") } - .litheIconButton() - .disabled(feature.state != .paused || feature.selectedFrameID == nil) - .help("Smart step into") - .popover(isPresented: $isSmartStepPickerPresented, arrowEdge: .bottom) { - VStack(alignment: .leading, spacing: 4) { - Text("Choose Step Target") - .font(.system(size: 11, weight: .semibold)) - .padding(.horizontal, 8) - .padding(.top, 6) - if smartStepTargets.isEmpty { - Text("No callable target at this location") - .font(LitheTheme.smallFont) - .foregroundStyle(LitheTheme.secondaryText) - .padding(8) - } else { - ForEach(smartStepTargets) { target in - Button(target.label) { - feature.smartStepInto(target) - isSmartStepPickerPresented = false - } - .buttonStyle(.plain) - .font(.system(size: 11, design: .monospaced)) - .padding(.horizontal, 8) - .padding(.vertical, 4) - } + + toolbarDivider + + toolbarGroup { + controlButton("arrow.right.to.line", help: "Step over", disabled: feature.state != .paused) { + feature.execute(.next) + } + controlButton("arrow.down.to.line", help: "Step into", disabled: feature.state != .paused) { + feature.execute(.stepIn) + } + if feature.capabilities.supportsStepInTargetsRequest { + smartStepButton + } + controlButton("arrow.up.to.line", help: "Step out", disabled: feature.state != .paused) { + feature.execute(.stepOut) + } + if feature.capabilities.supportsStepBack { + controlButton("arrow.uturn.backward", help: "Step back", disabled: !feature.canStepBack) { + feature.execute(.stepBack) } } - .frame(minWidth: 230) - .padding(.vertical, 4) } - } - controlButton("arrow.up.to.line", help: "Step out", disabled: feature.state != .paused) { - feature.execute(.stepOut) - } - if feature.capabilities.supportsStepBack { - controlButton("arrow.uturn.backward", help: "Step back", disabled: !feature.canStepBack) { - feature.execute(.stepBack) + + if feature.capabilities.supportsRestartRequest { + toolbarDivider + controlButton("arrow.clockwise", help: "Rerun", disabled: !feature.canRestart) { + feature.execute(.restart) + } } - } - if feature.capabilities.supportsRestartRequest { - controlButton("arrow.clockwise", help: "Restart", disabled: !feature.canRestart) { - feature.execute(.restart) + + Spacer(minLength: 8) + + if let frame = feature.selectedFrame { + HStack(spacing: 5) { + Image(systemName: feature.state == .paused ? "pause.circle.fill" : "circle") + .foregroundStyle(feature.state == .paused ? LitheTheme.warning : LitheTheme.secondaryText) + VStack(alignment: .leading, spacing: 0) { + Text(feature.state == .paused ? "Paused" : feature.state.title) + .font(.system(size: 10, weight: .semibold)) + if let sourceURL = frame.sourceURL { + Text("\(sourceURL.lastPathComponent):\(frame.line)") + .font(.system(size: 9.5, design: .monospaced)) + .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(1) + } + } + } + .foregroundStyle(LitheTheme.primaryText) + .padding(.horizontal, 7) + .help(frame.name) + } else { + Text(feature.state.title) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(LitheTheme.secondaryText) + .padding(.horizontal, 7) } } - Button { - if feature.isSessionActive { - if feature.canTerminate { - feature.execute(.terminate) - } else { - model.stopDebugging() - } + .padding(.horizontal, 8) + .frame(minHeight: 34) + } + .litheWorkbenchSurface(LitheTheme.toolHeader) + } + + @ViewBuilder + private var smartStepButton: some View { + Button { + feature.requestSmartStepInto { result in + guard case .success(let targets) = result else { return } + if targets.count == 1, let target = targets.first { + feature.smartStepInto(target) } else { - model.startDebugging() + smartStepTargets = targets + isSmartStepPickerPresented = true } - } label: { - Image(systemName: feature.isSessionActive ? "stop.fill" : "play.fill") } - .litheIconButton() - .foregroundStyle(feature.isSessionActive ? LitheTheme.warning : LitheTheme.success) - .help(feature.isSessionActive ? "Stop debugging" : "Start debugging") - controlButton("trash", help: "Clear output", disabled: false) { - feature.clearOutput() + } label: { + Image(systemName: "arrow.down.right.and.arrow.up.left") + } + .litheIconButton() + .disabled(feature.state != .paused || feature.selectedFrameID == nil) + .help("Smart step into") + .popover(isPresented: $isSmartStepPickerPresented, arrowEdge: .bottom) { + VStack(alignment: .leading, spacing: 4) { + Text("Choose Step Target") + .font(.system(size: 11, weight: .semibold)) + .padding(.horizontal, 8) + .padding(.top, 6) + if smartStepTargets.isEmpty { + Text("No callable target at this location") + .font(LitheTheme.smallFont) + .foregroundStyle(LitheTheme.secondaryText) + .padding(8) + } else { + ForEach(smartStepTargets) { target in + Button(target.label) { + feature.smartStepInto(target) + isSmartStepPickerPresented = false + } + .buttonStyle(.plain) + .font(.system(size: 11, design: .monospaced)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + } + } } + .frame(minWidth: 230) + .padding(.vertical, 4) } } + private func toolbarGroup(@ViewBuilder content: () -> Content) -> some View { + HStack(spacing: 1, content: content) + .padding(2) + .background(LitheTheme.inputBackground.opacity(0.6)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } + + private var toolbarDivider: some View { + Rectangle() + .fill(LitheTheme.divider) + .frame(width: 1, height: 20) + .padding(.horizontal, 4) + } + private func controlButton( _ image: String, help: String, @@ -630,37 +715,72 @@ struct GenericDebugView: View { evaluateExpression = "" } - private var output: some View { + private var debugConsole: some View { GeometryReader { geometry in - ScrollView([.vertical, .horizontal]) { - VStack(alignment: .leading, spacing: 8) { - if let stoppedReason = feature.stoppedReason { - Label(stoppedReason, systemImage: "pause.circle.fill") - .font(.system(size: 11.5, weight: .medium)) - .foregroundStyle(LitheTheme.warning) - } - if let errorMessage = feature.errorMessage { - Label(errorMessage, systemImage: "exclamationmark.triangle.fill") - .font(.system(size: 11.5)) - .foregroundStyle(LitheTheme.error) + VStack(spacing: 0) { + ScrollView([.vertical, .horizontal]) { + VStack(alignment: .leading, spacing: 8) { + if let stoppedReason = feature.stoppedReason { + Label(stoppedReason, systemImage: "pause.circle.fill") + .font(.system(size: 11.5, weight: .medium)) + .foregroundStyle(LitheTheme.warning) + } + if let errorMessage = feature.errorMessage { + Label(errorMessage, systemImage: "exclamationmark.triangle.fill") + .font(.system(size: 11.5)) + .foregroundStyle(LitheTheme.error) + } + Text(feature.output.isEmpty ? "Waiting for Debug Adapter output…" : feature.output) + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(LitheTheme.primaryText) + .textSelection(.enabled) } - Text(feature.output.isEmpty ? "Waiting for Debug Adapter output…" : feature.output) - .font(.system(size: 12, design: .monospaced)) - .foregroundStyle(LitheTheme.primaryText) - .textSelection(.enabled) + .frame( + minWidth: max(0, geometry.size.width - 24), + minHeight: max(0, geometry.size.height - 62), + alignment: .topLeading + ) + .padding(12) } - .frame( - minWidth: max(0, geometry.size.width - 24), - minHeight: max(0, geometry.size.height - 24), - alignment: .topLeading - ) - .padding(12) + Rectangle().fill(LitheTheme.divider).frame(height: 1) + consoleInputRow } } .frame(maxWidth: .infinity, maxHeight: .infinity) .litheWorkbenchSurface(LitheTheme.editor) } + private var consoleInputRow: some View { + HStack(spacing: 7) { + Image(systemName: "chevron.right") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(LitheTheme.accent) + TextField("Evaluate expression while paused", text: $consoleExpression) + .textFieldStyle(.plain) + .font(.system(size: 11.5, design: .monospaced)) + .focused($isConsoleInputFocused) + .disabled(feature.state != .paused) + .onSubmit { evaluateConsoleExpression() } + Button { evaluateConsoleExpression() } label: { + Image(systemName: "arrow.right.circle.fill") + } + .litheIconButton() + .disabled(feature.state != .paused || consoleExpression.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .help("Evaluate expression") + } + .padding(.horizontal, 10) + .frame(height: 34) + .litheWorkbenchSurface(LitheTheme.toolHeader) + } + + private func evaluateConsoleExpression() { + guard feature.state == .paused else { return } + let expression = consoleExpression.trimmingCharacters(in: .whitespacesAndNewlines) + guard !expression.isEmpty else { return } + feature.evaluate(expression) + consoleExpression = "" + } + private var emptyState: some View { VStack(spacing: 10) { LitheSystemIcon(systemImage: "ladybug") From c49d819556b812163a07c6d345974a0f16f0db47 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sun, 30 Aug 2026 10:55:48 +0800 Subject: [PATCH 43/66] feat(debug): add debuggee input and session recovery --- macos/Resources/en.lproj/Localizable.strings | 4 ++ .../zh-Hans.lproj/Localizable.strings | 4 ++ .../Models/AppModel/AppModel+Terminal.swift | 41 +++++++++++ .../Lithe/Models/AppModel/AppModel.swift | 1 + .../Lithe/Views/Debug/GenericDebugView.swift | 42 ++++++++++- .../GenericDebugFeatureModel.swift | 45 +++++++++--- .../Runtime/DebugAdapterSessionManager.swift | 28 ++++++-- .../Application/TerminalFeatureModel.swift | 12 ++++ .../DebugModuleTests.swift | 72 ++++++++++++++++++- .../TerminalModuleTests.swift | 25 ++++++- 10 files changed, 256 insertions(+), 18 deletions(-) diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index d1f62a35..63afb8db 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -122,5 +122,9 @@ "Connect to running JVM" = "Connect to running JVM"; "Smart step into" = "Smart step into"; "Clear output" = "Clear output"; +"Send input to debuggee" = "Send input to debuggee"; +"Send program input" = "Send program input"; +"No running debug process accepts standard input" = "No running debug process accepts standard input"; +"Retry debugging" = "Retry debugging"; "Pull Requests integration is under development" = "Pull Requests integration is under development"; "GitHub sign-in and pull request management are temporarily unavailable." = "GitHub sign-in and pull request management are temporarily unavailable."; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 5a77abcc..b512c708 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -894,6 +894,10 @@ "Connect to running JVM" = "连接到正在运行的 JVM"; "Smart step into" = "智能步入"; "Clear output" = "清除输出"; +"Send input to debuggee" = "向调试进程发送输入"; +"Send program input" = "发送程序输入"; +"No running debug process accepts standard input" = "没有正在运行且接受标准输入的调试进程"; +"Retry debugging" = "重试调试"; "Loading breakpoints…" = "正在加载断点…"; "Manage project breakpoints without starting a debug session" = "无需启动调试会话即可管理项目断点"; "Line Breakpoints" = "行断点"; diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift index eca45f14..00dfc390 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift @@ -154,6 +154,7 @@ extension AppModel { configureTerminalSession(created.session) terminalPlacementFeature.registerSession(created.session.id) debugTerminalSessionIDs.insert(created.session.id) + activeDebugTerminalSessionID = created.session.id isTerminalVisible = true isTestsVisible = false isGitLogVisible = false @@ -171,6 +172,42 @@ extension AppModel { terminalSessions.first(where: { $0.id == sessionID })?.stop() } debugTerminalSessionIDs.removeAll() + activeDebugTerminalSessionID = nil + } + + var isDebugStandardInputAvailable: Bool { + guard let terminalFeature else { return false } + let candidateIDs = [activeDebugTerminalSessionID] + .compactMap { $0 } + + debugTerminalSessionIDs.sorted(by: { $0.uuidString < $1.uuidString }) + return candidateIDs.contains { sessionID in + guard let session = terminalFeature.terminalSessions.first(where: { $0.id == sessionID }) else { + return false + } + return session.isRunning && session.isReady + } + } + + @discardableResult + func sendDebugStandardInput(_ input: String) -> Bool { + guard !input.isEmpty, let terminalFeature else { + showNotification("No running debug process accepts standard input") + return false + } + let candidateIDs = [activeDebugTerminalSessionID] + .compactMap { $0 } + + debugTerminalSessionIDs.sorted(by: { $0.uuidString < $1.uuidString }) + guard let sessionID = candidateIDs.first(where: { sessionID in + guard let session = terminalFeature.terminalSessions.first(where: { $0.id == sessionID }) else { + return false + } + return session.isRunning && session.isReady + }) else { + showNotification("No running debug process accepts standard input") + return false + } + let payload = input.hasSuffix("\n") ? input : input + "\n" + return terminalFeature.sendInput(payload, to: sessionID) } private func openTerminalLink(_ link: String, params: [String: String], sessionID: UUID) { @@ -260,6 +297,9 @@ extension AppModel { func closeTerminalSession(_ session: TerminalSession) { guard terminalSessions.contains(where: { $0.id == session.id }) else { return } debugTerminalSessionIDs.remove(session.id) + if activeDebugTerminalSessionID == session.id { + activeDebugTerminalSessionID = nil + } editorTabOrderFeature.remove(.terminal(session.id)) terminalPlacementFeature.removeSession(session.id) terminalFeature?.closeSession(session) @@ -273,6 +313,7 @@ extension AppModel { func restartActiveTerminal(using shellPath: String) { terminalFeature?.restartActiveSession(using: shellPath) } func stopTerminalSessions() { debugTerminalSessionIDs.removeAll() + activeDebugTerminalSessionID = nil editorTabOrderFeature.removeAllTerminals() terminalPlacementFeature.reset() terminalFeature?.stopAllSessions() diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index caa1b716..6b41eb14 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -164,6 +164,7 @@ final class AppModel: ObservableObject, Identifiable { let editorTabOrderFeature = EditorTabOrderFeatureModel() let terminalPlacementFeature: TerminalPlacementFeatureModel var debugTerminalSessionIDs: Set = [] + var activeDebugTerminalSessionID: UUID? private struct CachedModuleCapability { let moduleID: ModuleID let value: AnyObject diff --git a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift index 2c5ca054..f5f7be18 100644 --- a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift +++ b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift @@ -14,6 +14,7 @@ struct GenericDebugView: View { @State private var isJavaSteppingSettingsPresented = false @State private var selectedContent: DebugContent = .debugger @State private var consoleExpression = "" + @State private var programInput = "" @FocusState private var isConsoleInputFocused: Bool var body: some View { @@ -199,15 +200,21 @@ struct GenericDebugView: View { } else { model.stopDebugging() } + } else if feature.canRetry { + _ = feature.retry() } else { model.startDebugging() } } label: { - Image(systemName: feature.isSessionActive ? "stop.fill" : "play.fill") + Image(systemName: feature.isSessionActive ? "stop.fill" : feature.canRetry ? "arrow.clockwise" : "play.fill") } .litheIconButton() .foregroundStyle(feature.isSessionActive ? LitheTheme.warning : LitheTheme.success) - .help(feature.isSessionActive ? "Stop debugging" : "Start debugging") + .help( + feature.isSessionActive + ? "Stop debugging" + : feature.canRetry ? "Retry debugging" : "Start debugging" + ) controlButton( feature.state == .running ? "pause.fill" : "play.fill", @@ -737,13 +744,14 @@ struct GenericDebugView: View { } .frame( minWidth: max(0, geometry.size.width - 24), - minHeight: max(0, geometry.size.height - 62), + minHeight: max(0, geometry.size.height - 97), alignment: .topLeading ) .padding(12) } Rectangle().fill(LitheTheme.divider).frame(height: 1) consoleInputRow + programInputRow } } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -781,6 +789,34 @@ struct GenericDebugView: View { consoleExpression = "" } + private var programInputRow: some View { + HStack(spacing: 7) { + Image(systemName: "arrow.down.to.line") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(LitheTheme.warning) + TextField("Send input to debuggee", text: $programInput) + .textFieldStyle(.plain) + .font(.system(size: 11.5, design: .monospaced)) + .disabled(!model.isDebugStandardInputAvailable) + .onSubmit { sendProgramInput() } + Button { sendProgramInput() } label: { + Image(systemName: "paperplane.fill") + } + .litheIconButton() + .disabled(!model.isDebugStandardInputAvailable || programInput.isEmpty) + .help("Send program input") + } + .padding(.horizontal, 10) + .frame(height: 34) + .litheWorkbenchSurface(LitheTheme.toolHeader) + } + + private func sendProgramInput() { + guard !programInput.isEmpty, + model.sendDebugStandardInput(programInput) else { return } + programInput = "" + } + private var emptyState: some View { VStack(spacing: 10) { LitheSystemIcon(systemImage: "ladybug") diff --git a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift index 0b7b0110..9dbc7386 100644 --- a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift +++ b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift @@ -119,6 +119,12 @@ private struct GenericDebugVariablePageItemFingerprint: Hashable, Sendable { let variablesReference: Int } +private struct GenericDebugStartRequest: Equatable, Sendable { + let fileURL: URL + let rootURL: URL + let configuration: DebugLaunchConfiguration +} + @MainActor public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatureTarget { @Published public private(set) var providerID: String? @@ -171,6 +177,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu private var requestedBreakpointsByFile: [URL: [Int: DebugSourceBreakpoint]] = [:] private var workspaceURL: URL? private var activeFileURL: URL? + private var lastStartRequest: GenericDebugStartRequest? private let maximumOutputCharacters = 400_000 private let variablePageSize = 100 private var watchGeneration = 0 @@ -214,6 +221,9 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu public var canStepBack: Bool { state == .paused && capabilities.supportsStepBack } + public var canRetry: Bool { + lastStartRequest != nil && (state == .failed || state == .terminated) + } public var visibleVariableRows: [GenericDebugVariableRow] { var rows: [GenericDebugVariableRow] = [] appendVisibleVariables(variables, parentPath: "root", depth: 0, to: &rows) @@ -261,7 +271,13 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu configuration: DebugLaunchConfiguration ) -> Bool { stop() - activeFileURL = fileURL.standardizedFileURL + let request = GenericDebugStartRequest( + fileURL: fileURL.standardizedFileURL, + rootURL: rootURL.standardizedFileURL, + configuration: configuration + ) + lastStartRequest = request + activeFileURL = request.fileURL providerID = sessionsProviderID(for: fileURL) targetTitle = configuration.name output = "" @@ -279,26 +295,26 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu stoppedFrame = nil selectedFrame = nil do { - if requestedBreakpointsByFile[fileURL.standardizedFileURL] != nil { + if requestedBreakpointsByFile[request.fileURL] != nil { try sessions.setBreakpoints( - effectiveBreakpoints(for: fileURL.standardizedFileURL), - in: fileURL + effectiveBreakpoints(for: request.fileURL), + in: request.fileURL ) } if !dataBreakpoints.isEmpty { - try sessions.setDataBreakpoints(coreDataBreakpoints, for: fileURL) + try sessions.setDataBreakpoints(coreDataBreakpoints, for: request.fileURL) } let effectiveConfiguration: DebugLaunchConfiguration if providerID == "java", let javaSteppingFilters { - effectiveConfiguration = configuration.applying( + effectiveConfiguration = request.configuration.applying( steppingFilters: javaSteppingFilters ) } else { - effectiveConfiguration = configuration + effectiveConfiguration = request.configuration } let session = try sessions.launch( - for: fileURL, - rootURL: rootURL, + for: request.fileURL, + rootURL: request.rootURL, configuration: effectiveConfiguration ) state = session.state @@ -311,6 +327,16 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } } + @discardableResult + public func retry() -> Bool { + guard let request = lastStartRequest else { return false } + return start( + fileURL: request.fileURL, + rootURL: request.rootURL, + configuration: request.configuration + ) + } + public func stop() { invalidateInspectionRequests() if let activeFileURL { @@ -351,6 +377,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu requestedBreakpointsByFile = [:] areBreakpointsMuted = false workspaceURL = nil + lastStartRequest = nil } public func openWorkspace(at workspaceURL: URL) { diff --git a/macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift b/macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift index 2c803caa..0c6222f3 100644 --- a/macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift +++ b/macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift @@ -29,6 +29,7 @@ public final class DebugAdapterSessionManager: ObservableObject { ) -> (any DebugAdapterSession)? private var sessions: [String: any DebugAdapterSession] = [:] private var roots: [String: URL] = [:] + private var activationTokens: [String: UUID] = [:] private var requestedBreakpoints: [String: [URL: [DebugSourceBreakpoint]]] = [:] private var requestedExceptionBreakpoints: [String: [DebugExceptionBreakpoint]] = [:] private var requestedFunctionBreakpoints: [String: [DebugFunctionBreakpoint]] = [:] @@ -67,13 +68,27 @@ public final class DebugAdapterSessionManager: ObservableObject { active.stop() sessions[descriptor.id] = nil roots[descriptor.id] = nil + activationTokens[descriptor.id] = nil } guard let session = makeSession(descriptor, normalizedRoot) else { throw DebugProviderError.adapterUnavailable(descriptor.displayName) } - configureCallbacks(session, providerID: descriptor.id) - try session.start(rootURL: normalizedRoot) + let activationToken = UUID() + activationTokens[descriptor.id] = activationToken + configureCallbacks( + session, + providerID: descriptor.id, + activationToken: activationToken + ) + do { + try session.start(rootURL: normalizedRoot) + } catch { + if activationTokens[descriptor.id] == activationToken { + activationTokens[descriptor.id] = nil + } + throw error + } sessions[descriptor.id] = session roots[descriptor.id] = normalizedRoot states[descriptor.id] = session.state @@ -167,6 +182,7 @@ public final class DebugAdapterSessionManager: ObservableObject { } public func stop(providerID: String) { + activationTokens[providerID] = nil sessions.removeValue(forKey: providerID)?.stop() roots[providerID] = nil states[providerID] = .idle @@ -176,6 +192,7 @@ public final class DebugAdapterSessionManager: ObservableObject { for session in sessions.values { session.stop() } sessions.removeAll() roots.removeAll() + activationTokens.removeAll() states.removeAll() lastEvents.removeAll() verifiedBreakpoints.removeAll() @@ -187,16 +204,19 @@ public final class DebugAdapterSessionManager: ObservableObject { private func configureCallbacks( _ session: any DebugAdapterSession, - providerID: String + providerID: String, + activationToken: UUID ) { configureRunInTerminalHandler(session) guard let controlling = session as? any DebugAdapterControllingSession else { return } controlling.onStateChange = { [weak self] state in + guard self?.activationTokens[providerID] == activationToken else { return } self?.states[providerID] = state self?.onStateChange?(providerID, state) } controlling.onEvent = { [weak self] event in - guard let self else { return } + guard let self, + self.activationTokens[providerID] == activationToken else { return } lastEvents[providerID] = event onEvent?(providerID, event) if case .breakpoint(let breakpoint) = event { diff --git a/macos/Sources/LitheTerminalModule/Application/TerminalFeatureModel.swift b/macos/Sources/LitheTerminalModule/Application/TerminalFeatureModel.swift index 76838e90..7c48300d 100644 --- a/macos/Sources/LitheTerminalModule/Application/TerminalFeatureModel.swift +++ b/macos/Sources/LitheTerminalModule/Application/TerminalFeatureModel.swift @@ -72,6 +72,18 @@ public final class TerminalFeatureModel: ObservableObject { public func restartActiveSession() { activeTerminalSession?.restart() } public func restartActiveSession(using shellPath: String) { activeTerminalSession?.restart(using: shellPath) } + /// Sends UTF-8 input to a specific terminal session when its PTY is live. + /// The session ID keeps callers from accidentally writing to whichever + /// terminal happens to be selected in the UI. + @discardableResult + public func sendInput(_ input: String, to sessionID: UUID) -> Bool { + guard let session = terminalSessions.first(where: { $0.id == sessionID }), + session.isRunning, + session.isReady else { return false } + session.sendInput(input) + return true + } + public func stopAllSessions() { terminalSessions.forEach { $0.stop() } terminalSessions.removeAll() diff --git a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift index f7f27bb1..80ce975e 100644 --- a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift +++ b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift @@ -7,6 +7,37 @@ import Testing @MainActor struct DebugModuleTests { + @Test + func staleSessionCallbacksCannotOverwriteAReplacementSession() throws { + let descriptor = DebugProviderDescriptor( + id: "java", + displayName: "Java", + fileExtensions: ["java"] + ) + var createdSessions: [DeferredInspectionDebugSession] = [] + let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in + let session = DeferredInspectionDebugSession() + createdSessions.append(session) + return session + } + let root = URL(fileURLWithPath: "/tmp/java-debug-reconnect", isDirectory: true) + let source = root.appendingPathComponent("src/Main.java") + + _ = try manager.activate(for: source, rootURL: root) + let first = try #require(createdSessions.first) + manager.stop(providerID: "java") + + _ = try manager.activate(for: source, rootURL: root) + let second = try #require(createdSessions.dropFirst().first) + second.emit(.output(category: "stdout", output: "current\n")) + first.emit(.output(category: "stderr", output: "stale\n")) + first.fail() + + #expect(manager.lastEvents["java"] == .output(category: "stdout", output: "current\n")) + #expect(manager.states["java"] == .ready) + manager.stopAll() + } + @Test func coreProtocolSessionProjectsRustUpdatesThroughInjectedTransport() throws { let transport = RecordingTransport() @@ -677,6 +708,35 @@ struct DebugModuleTests { #expect(core.lastLaunchConfiguration?.steppingFilters == core.defaultSteppingFilters) } + @Test + func failedSessionCanRetryUsingTheLastLaunchRequest() throws { + let session = DeferredInspectionDebugSession() + let descriptor = DebugProviderDescriptor( + id: "java", + displayName: "Java", + fileExtensions: ["java"] + ) + let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in session } + let feature = GenericDebugFeatureModel(sessions: manager) + let root = URL(fileURLWithPath: "/tmp/java-debug-retry", isDirectory: true) + let source = root.appendingPathComponent("src/Main.java") + let configuration = DebugLaunchConfiguration( + name: "Retry Main", + request: .launch, + arguments: ["mainClass": .string("example.Main")] + ) + + #expect(feature.start(fileURL: source, rootURL: root, configuration: configuration)) + session.fail() + #expect(feature.state == .failed) + #expect(feature.canRetry) + #expect(feature.retry()) + #expect(session.startCount == 2) + #expect(session.launchConfigurations == [configuration, configuration]) + + feature.stop() + } + @Test func filteredStackFramesCollapseByConsecutiveRunsAndRestoreOrder() { let session = DeferredInspectionDebugSession() @@ -2218,6 +2278,8 @@ private final class DeferredInspectionDebugSession: DebugAdapterControllingSessi let capabilities: DebugAdapterCapabilities private(set) var isRunning = false private(set) var state: DebugAdapterState = .idle + private(set) var startCount = 0 + private(set) var launchConfigurations: [DebugLaunchConfiguration] = [] private(set) var breakpointUpdates: [[DebugSourceBreakpoint]] = [] var onStateChange: ((DebugAdapterState) -> Void)? var onEvent: ((DebugAdapterEvent) -> Void)? @@ -2262,6 +2324,7 @@ private final class DeferredInspectionDebugSession: DebugAdapterControllingSessi } func start(rootURL _: URL) throws { + startCount += 1 isRunning = true state = .ready } @@ -2271,11 +2334,18 @@ private final class DeferredInspectionDebugSession: DebugAdapterControllingSessi state = .idle } - func launch(_: DebugLaunchConfiguration) throws { + func launch(_ configuration: DebugLaunchConfiguration) throws { + launchConfigurations.append(configuration) state = .paused onStateChange?(.paused) } + func fail() { + isRunning = false + state = .failed + onStateChange?(.failed) + } + func setBreakpoints(_ breakpoints: [DebugSourceBreakpoint], in _: URL) { breakpointUpdates.append(breakpoints) } diff --git a/macos/Tests/LitheTerminalModuleTests/TerminalModuleTests.swift b/macos/Tests/LitheTerminalModuleTests/TerminalModuleTests.swift index f49f57dd..30481a3c 100644 --- a/macos/Tests/LitheTerminalModuleTests/TerminalModuleTests.swift +++ b/macos/Tests/LitheTerminalModuleTests/TerminalModuleTests.swift @@ -52,6 +52,26 @@ struct TerminalModuleTests { #expect(transport.stopCount == 1) } + @Test + func managedProcessForwardsInputToItsOwnPTY() throws { + let transport = TestTransport() + let feature = TerminalFeatureModel(terminalFactory: { transport }) + let launch = TerminalProcessLaunch( + title: "Debug Main", + executablePath: "/opt/jdk/bin/java", + arguments: ["example.Main"], + workingDirectory: "/workspace" + ) + let created = try feature.createProcessSession(launch) + + #expect(feature.sendInput("username\n", to: created.session.id)) + #expect(transport.sentInputs == ["username\n"]) + + created.session.stop() + #expect(!feature.sendInput("late\n", to: created.session.id)) + feature.stopAllSessions() + } + @Test func linkResolverKeepsExternalURLsAndResolvesLocations() { let workspace = URL(fileURLWithPath: "/tmp/lithe-terminal-module-test") @@ -82,6 +102,7 @@ private final class TestTransport: TerminalTransport { var stopCount = 0 var processLaunches: [TerminalProcessLaunch] = [] var processEnvironments: [[String: String]] = [] + var sentInputs: [String] = [] func defaultShellPath() -> String { "/bin/zsh" } func defaultEnvironment() -> [String: String] { ["REMOVE_ME": "old"] } func start(workingDirectory: String, shellPath: String, environment: [String: String]) throws { isRunning = true } @@ -94,7 +115,9 @@ private final class TestTransport: TerminalTransport { isRunning = true return 1234 } - func send(_ input: Data) throws {} + func send(_ input: Data) throws { + sentInputs.append(String(decoding: input, as: UTF8.self)) + } func interrupt() throws {} func focus() {} func clear() {} From 307418fb8676ceba17651408277c5fe3bfee6cec Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sun, 30 Aug 2026 11:09:07 +0800 Subject: [PATCH 44/66] fix(debug): clean up child sessions and report launch errors --- .../Runtime/DebugAdapterProtocolSession.swift | 26 +++++- .../DebugModuleTests.swift | 87 +++++++++++++++++++ 2 files changed, 109 insertions(+), 4 deletions(-) diff --git a/macos/Sources/LitheDebugModule/Runtime/DebugAdapterProtocolSession.swift b/macos/Sources/LitheDebugModule/Runtime/DebugAdapterProtocolSession.swift index 16ddd739..d4e5172c 100644 --- a/macos/Sources/LitheDebugModule/Runtime/DebugAdapterProtocolSession.swift +++ b/macos/Sources/LitheDebugModule/Runtime/DebugAdapterProtocolSession.swift @@ -84,6 +84,7 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession { do { try transport.start(rootURL: rootURL.standardizedFileURL) } catch { + reportFailure(error, context: "Debug Adapter failed to start") state = .failed throw error } @@ -114,7 +115,8 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession { self.pendingLaunch = nil self.performLaunch(pendingLaunch) } - case .failure: + case .failure(let error): + self.reportFailure(error, context: "Debug Adapter initialization failed") self.state = .failed } } @@ -145,7 +147,8 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession { switch result { case .success: if self.state == .launching { self.state = .running } - case .failure: + case .failure(let error): + self.reportFailure(error, context: "Debug launch failed") self.state = .failed } } @@ -875,7 +878,7 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession { child.setBreakpoints(breakpoints, in: source) } child.onStateChange = { [weak self, weak child] childState in - guard let self else { return } + guard let self, let child else { return } switch childState { case .paused: self.activeChildSession = child @@ -884,9 +887,10 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession { self.activeChildSession = child self.state = .running case .failed: + self.removeFinishedChild(child) self.state = .failed case .terminated: - if self.activeChildSession === child { self.activeChildSession = nil } + self.removeFinishedChild(child) self.state = .terminated default: break @@ -932,6 +936,20 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession { } } + private func removeFinishedChild(_ child: DebugAdapterProtocolSession) { + childSessions.removeAll { $0 === child } + if activeChildSession === child { + activeChildSession = nil + } + } + + private func reportFailure(_ error: Error, context: String) { + onEvent?(.output( + category: "stderr", + output: "\(context): \(error.localizedDescription)\n" + )) + } + private func failPendingRequests(_ error: Error) { let handlers = responseHandlers.values responseHandlers = [:] diff --git a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift index 80ce975e..72658d17 100644 --- a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift +++ b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift @@ -1942,6 +1942,88 @@ struct DebugModuleTests { #expect(child.stopCalls == 1) } + @Test + func protocolSessionRemovesFinishedChildTransport() throws { + let parent = RecordingTransport() + let session = DebugAdapterProtocolSession(adapterID: "test-adapter", transport: parent) + let root = URL(fileURLWithPath: "/tmp/debug-child-cleanup", isDirectory: true) + + try session.start(rootURL: root) + let initialize = try #require(parent.request(named: "initialize")) + parent.emitJSON([ + "seq": 2, + "type": "response", + "request_seq": initialize["seq"] as! Int, + "success": true, + "command": "initialize", + "body": [:] + ]) + parent.emitJSON([ + "seq": 3, + "type": "request", + "command": "startDebugging", + "arguments": [ + "configuration": [ + "name": "Child", + "request": "launch", + "program": root.appendingPathComponent("main.js").path + ] + ] + ]) + + let child = try #require(parent.children.first) + child.terminate(0) + #expect(!child.isRunning) + + session.stop() + + // A terminated child is removed immediately, so parent shutdown does + // not retain or stop the same transport a second time. + #expect(child.stopCalls == 0) + } + + @Test + func protocolSessionReportsLaunchFailureInDebugOutput() throws { + let transport = RecordingTransport() + let session = DebugAdapterProtocolSession( + adapterID: "test-adapter", + transport: transport + ) + var events: [DebugAdapterEvent] = [] + session.onEvent = { events.append($0) } + + try session.start(rootURL: URL(fileURLWithPath: "/tmp/debug-launch-failure")) + let initialize = try #require(transport.request(named: "initialize")) + transport.emitJSON([ + "seq": 2, + "type": "response", + "request_seq": initialize["seq"] as! Int, + "success": true, + "command": "initialize", + "body": [:] + ]) + try session.launch(DebugLaunchConfiguration( + name: "Broken Main", + request: .launch, + arguments: ["program": .string("/tmp/missing-main")] + )) + let launch = try #require(transport.request(named: "launch")) + transport.emitJSON([ + "seq": 3, + "type": "response", + "request_seq": launch["seq"] as! Int, + "success": false, + "command": "launch", + "message": "main class was not found" + ]) + + #expect(session.state == .failed) + #expect(events.contains(.output( + category: "stderr", + output: "Debug launch failed: launch failed: main class was not found\n" + ))) + } + @Test func disabledDebugDoesNotConstructGraph() async throws { let recorder = Recorder() @@ -2116,6 +2198,11 @@ private final class RecordingTransport: DebugAdapterTransport, DebugAdapterChild isRunning = false } + func terminate(_ exitCode: Int) { + isRunning = false + onTermination?(exitCode) + } + func makeChildTransport() -> (any DebugAdapterTransport)? { let child = RecordingTransport() children.append(child) From cbd96dde803d13bf0ab2eb1dbbd94094c8b2fc87 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sun, 30 Aug 2026 11:34:45 +0800 Subject: [PATCH 45/66] test(debug): stabilize Spring integration coverage --- .../RealJavaDebugIntegrationTests.swift | 52 +++++++++++-------- .../RunConfigurationIntegrationTests.swift | 6 ++- 2 files changed, 35 insertions(+), 23 deletions(-) diff --git a/macos/Tests/LitheTests/RealJavaDebugIntegrationTests.swift b/macos/Tests/LitheTests/RealJavaDebugIntegrationTests.swift index b46b235b..5c2569fc 100644 --- a/macos/Tests/LitheTests/RealJavaDebugIntegrationTests.swift +++ b/macos/Tests/LitheTests/RealJavaDebugIntegrationTests.swift @@ -123,6 +123,9 @@ struct RealJavaDebugIntegrationTests { let feature = GenericDebugFeatureModel(sessions: debugManager) let debugTerminals = RealJavaDebugTerminalOwner(workspaceURL: rootURL) feature.onRunInTerminalRequest = debugTerminals.handle + let portAllocator = MacJavaTestResultServer() + let springPort = try await portAllocator.start() + portAllocator.stop() var requestTask: Task<(Data, URLResponse), Error>? defer { requestTask?.cancel() @@ -156,7 +159,7 @@ struct RealJavaDebugIntegrationTests { "mainClass": .string(target.mainClass), "cwd": .string(rootURL.path), "console": .string("integratedTerminal"), - "args": .string("--server.port=0") + "args": .string("--server.port=\(springPort)") ] if let projectName = target.projectName { arguments["projectName"] = .string(projectName) @@ -183,11 +186,14 @@ struct RealJavaDebugIntegrationTests { #expect(await Self.waitUntil(timeout: .seconds(120)) { feature.breakpoints.first?.verified == true }, "The Java breakpoint was not verified. Output:\n\(feature.output)") - let port = await Self.waitForSpringPort(feature: feature, timeout: .seconds(120)) - let resolvedPort = try #require(port) + guard await Self.waitForSpringServer(port: springPort, timeout: .seconds(120)) else { + throw RealJavaDebugIntegrationError.springServerDidNotStart( + "expectedPort=\(springPort)\n" + Self.debugSnapshot(feature, protocolTrace: protocolTrace) + ) + } var request = URLRequest( - url: URL(string: "http://127.0.0.1:\(resolvedPort)/api/users")! + url: URL(string: "http://127.0.0.1:\(springPort)/api/users")! ) request.timeoutInterval = 60 requestTask = Task { try await URLSession.shared.data(for: request) } @@ -463,26 +469,27 @@ struct RealJavaDebugIntegrationTests { """ } - private static func waitForSpringPort( - feature: GenericDebugFeatureModel, + private static func waitForSpringServer( + port: UInt16, timeout: Duration - ) async -> Int? { - var port: Int? - _ = await waitUntil(timeout: timeout) { - port = springPort(in: feature.output) - return port != nil + ) async -> Bool { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + let url = URL(string: "http://127.0.0.1:\(port)/")! + while clock.now < deadline { + var request = URLRequest(url: url) + request.timeoutInterval = 2 + do { + _ = try await URLSession.shared.data(for: request) + return true + } catch { + // Spring Boot may still be starting while the debuggee is + // already attached and accepting debugger requests. + } + // test-stability: allow(swift-real-sleep) reason: The external Spring process exposes readiness only through its loopback listener. + try? await Task.sleep(for: .milliseconds(100)) } - return port - } - - private static func springPort(in output: String) -> Int? { - let expression = try? NSRegularExpression( - pattern: "Tomcat started on port ([0-9]+)" - ) - let range = NSRange(output.startIndex.. Date: Sun, 30 Aug 2026 11:56:37 +0800 Subject: [PATCH 46/66] feat(debug): mirror debuggee output in console --- .../Models/AppModel/AppModel+Terminal.swift | 4 +- .../MacOS/Terminal/MacTerminalTransport.swift | 10 +++ .../GenericDebugFeatureModel.swift | 85 ++++++++++++++++++- .../Application/TerminalFeatureModel.swift | 4 +- .../Ports/TerminalTransport.swift | 3 + .../Runtime/TerminalSession.swift | 7 ++ .../DebugModuleTests.swift | 22 +++++ .../TerminalModuleTests.swift | 29 +++++++ .../LitheTests/LitheCoreLogicTests.swift | 1 + .../TerminalPlacementFeatureModelTests.swift | 1 + 10 files changed, 163 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift index 00dfc390..35074c44 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift @@ -150,7 +150,9 @@ extension AppModel { TerminalEnvironmentChange(name: $0.name, value: $0.value) } ) - let created = try feature.createProcessSession(launch) + let created = try feature.createProcessSession(launch) { [weak self] output in + self?.genericDebugFeatureIfActive?.appendDebuggeeOutput(output) + } configureTerminalSession(created.session) terminalPlacementFeature.registerSession(created.session.id) debugTerminalSessionIDs.insert(created.session.id) diff --git a/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift b/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift index f349de4d..35337043 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift @@ -7,6 +7,7 @@ import LitheTerminalModule /// link event so workspace-relative paths can open in its own editor instead. final class LitheTerminalView: LocalProcessTerminalView { var onOpenLink: ((String, [String: String]) -> Void)? + var onProcessOutput: ((Data) -> Void)? private var showsWorkbenchBackground = false private weak var metalActivationFailedWindow: NSWindow? @@ -109,6 +110,11 @@ final class LitheTerminalView: LocalProcessTerminalView { override func requestOpenLink(source: SwiftTerm.TerminalView, link: String, params: [String: String]) { onOpenLink?(link, params) } + + override func dataReceived(slice: ArraySlice) { + onProcessOutput?(Data(slice)) + super.dataReceived(slice: slice) + } } extension LitheTerminalView: WorkbenchBackgroundRendering {} @@ -136,6 +142,7 @@ final class MacTerminalTransport: NSObject, TerminalTransport, @preconcurrency L let view: LitheTerminalView var onTermination: ((Int32?) -> Void)? + var onOutput: ((Data) -> Void)? var onTitle: ((String) -> Void)? var onDirectoryUpdate: ((String?) -> Void)? var onLink: ((String, [String: String]) -> Void)? @@ -167,6 +174,9 @@ final class MacTerminalTransport: NSObject, TerminalTransport, @preconcurrency L view.onOpenLink = { [weak self] link, params in self?.onLink?(link, params) } + view.onProcessOutput = { [weak self] data in + self?.onOutput?(data) + } view.font = Self.preferredTerminalFont() view.applyThemeColors() view.allowMouseReporting = true diff --git a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift index 9dbc7386..091aec67 100644 --- a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift +++ b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift @@ -125,6 +125,73 @@ private struct GenericDebugStartRequest: Equatable, Sendable { let configuration: DebugLaunchConfiguration } +private struct GenericDebugOutputNormalizer { + private enum State { + case normal + case escape + case controlSequence + case operatingSystemCommand + case operatingSystemCommandEscape + } + + private var state = State.normal + private var sawCarriageReturn = false + + mutating func normalize(_ rawOutput: String) -> String { + var normalized = String() + normalized.reserveCapacity(rawOutput.count) + for scalar in rawOutput.unicodeScalars { + switch state { + case .normal: + if scalar.value == 0x1B { + sawCarriageReturn = false + state = .escape + } else if scalar.value == 0x0D { + normalized.append("\n") + sawCarriageReturn = true + } else if scalar.value == 0x0A { + if !sawCarriageReturn { normalized.append("\n") } + sawCarriageReturn = false + } else if scalar.value == 0x09 || scalar.value >= 0x20 { + normalized.unicodeScalars.append(scalar) + sawCarriageReturn = false + } + case .escape: + sawCarriageReturn = false + switch scalar.value { + case 0x5B: // CSI: ESC [ ... final byte + state = .controlSequence + case 0x5D: // OSC: ESC ] ... BEL or ST + state = .operatingSystemCommand + default: + state = .normal + } + case .controlSequence: + sawCarriageReturn = false + if (0x40...0x7E).contains(scalar.value) { + state = .normal + } + case .operatingSystemCommand: + sawCarriageReturn = false + if scalar.value == 0x07 { + state = .normal + } else if scalar.value == 0x1B { + state = .operatingSystemCommandEscape + } + case .operatingSystemCommandEscape: + sawCarriageReturn = false + state = scalar.value == 0x5C ? .normal : .operatingSystemCommand + } + } + return normalized + } + + mutating func reset() { + state = .normal + sawCarriageReturn = false + } +} + @MainActor public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatureTarget { @Published public private(set) var providerID: String? @@ -183,6 +250,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu private var watchGeneration = 0 private var inspectionGeneration = 0 private static let rootVariablePageID = "__lithe_debug_root_variables__" + private var debuggeeOutputNormalizer = GenericDebugOutputNormalizer() public init( sessions: DebugAdapterSessionManager, @@ -281,6 +349,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu providerID = sessionsProviderID(for: fileURL) targetTitle = configuration.name output = "" + debuggeeOutputNormalizer.reset() errorMessage = nil stoppedReason = nil exceptionInfo = nil @@ -361,6 +430,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu invalidateWatchResults() capabilities = .unknown activeFileURL = nil + debuggeeOutputNormalizer.reset() } public func reset() { @@ -368,6 +438,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu providerID = nil targetTitle = nil output = "" + debuggeeOutputNormalizer.reset() errorMessage = nil breakpoints = [] exceptionBreakpoints = [] @@ -1091,7 +1162,19 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } } - public func clearOutput() { output = "" } + public func clearOutput() { + output = "" + debuggeeOutputNormalizer.reset() + } + + /// Mirrors output emitted by a debuggee running in the host terminal into + /// the Debug Console. PTY control sequences are meaningful to the terminal + /// emulator but would otherwise render as noise in the text console. + public func appendDebuggeeOutput(_ rawOutput: String) { + let normalized = debuggeeOutputNormalizer.normalize(rawOutput) + guard !normalized.isEmpty else { return } + append(normalized) + } private var activeSession: (any DebugAdapterControllingSession)? { guard let providerID else { return nil } diff --git a/macos/Sources/LitheTerminalModule/Application/TerminalFeatureModel.swift b/macos/Sources/LitheTerminalModule/Application/TerminalFeatureModel.swift index 7c48300d..b6e0f190 100644 --- a/macos/Sources/LitheTerminalModule/Application/TerminalFeatureModel.swift +++ b/macos/Sources/LitheTerminalModule/Application/TerminalFeatureModel.swift @@ -42,9 +42,11 @@ public final class TerminalFeatureModel: ObservableObject { @discardableResult public func createProcessSession( - _ launch: TerminalProcessLaunch + _ launch: TerminalProcessLaunch, + onOutput: ((String) -> Void)? = nil ) throws -> (session: TerminalSession, processID: Int32) { let session = TerminalSession(transport: terminalFactory()) + session.onOutput = onOutput let processID = try session.startProcess(launch) terminalSessions.append(session) activeTerminalSessionID = session.id diff --git a/macos/Sources/LitheTerminalModule/Ports/TerminalTransport.swift b/macos/Sources/LitheTerminalModule/Ports/TerminalTransport.swift index 095f8c2e..0170a203 100644 --- a/macos/Sources/LitheTerminalModule/Ports/TerminalTransport.swift +++ b/macos/Sources/LitheTerminalModule/Ports/TerminalTransport.swift @@ -42,6 +42,9 @@ public protocol TerminalTransport: AnyObject { var shellName: String { get } var nativeView: AnyObject { get } var onTermination: ((Int32?) -> Void)? { get set } + /// Raw bytes received from the child process before terminal emulation. + /// Hosts may mirror this output into another product surface. + var onOutput: ((Data) -> Void)? { get set } var onTitle: ((String) -> Void)? { get set } var onDirectoryUpdate: ((String?) -> Void)? { get set } var onLink: ((String, [String: String]) -> Void)? { get set } diff --git a/macos/Sources/LitheTerminalModule/Runtime/TerminalSession.swift b/macos/Sources/LitheTerminalModule/Runtime/TerminalSession.swift index ca8cfb5f..1e68bf72 100644 --- a/macos/Sources/LitheTerminalModule/Runtime/TerminalSession.swift +++ b/macos/Sources/LitheTerminalModule/Runtime/TerminalSession.swift @@ -14,6 +14,9 @@ public final class TerminalSession: ObservableObject, Identifiable { @Published public private(set) var startedAt: Date? @Published public private(set) var endedAt: Date? public var onLink: ((String, [String: String]) -> Void)? + /// Receives decoded child-process output without taking ownership of the + /// terminal surface. + public var onOutput: ((String) -> Void)? private let transport: any TerminalTransport private var workspaceURL: URL? @@ -25,6 +28,10 @@ public final class TerminalSession: ObservableObject, Identifiable { guard let self else { return } isRunning = false; isReady = false; lastExitCode = exitCode; endedAt = Date() } + transport.onOutput = { [weak self] data in + guard let self, !data.isEmpty else { return } + self.onOutput?(String(decoding: data, as: UTF8.self)) + } transport.onTitle = { [weak self] title in let value = title.trimmingCharacters(in: .whitespacesAndNewlines) self?.processTitle = value.isEmpty ? nil : value diff --git a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift index 72658d17..a152f2a2 100644 --- a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift +++ b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift @@ -7,6 +7,28 @@ import Testing @MainActor struct DebugModuleTests { + @Test + func debuggeeOutputIsMirroredIntoConsoleWithoutTerminalControlSequences() { + let manager = DebugAdapterSessionManager(providers: []) { _, _ in nil } + let feature = GenericDebugFeatureModel(sessions: manager) + + feature.appendDebuggeeOutput("\u{001B}[31mready\u{001B}[0m\r\n") + + #expect(feature.output == "ready\n") + } + + @Test + func debuggeeOutputNormalizationPreservesStateAcrossOutputChunks() { + let manager = DebugAdapterSessionManager(providers: []) { _, _ in nil } + let feature = GenericDebugFeatureModel(sessions: manager) + + feature.appendDebuggeeOutput("\u{001B}") + feature.appendDebuggeeOutput("[31mready\u{001B}[0m\r") + feature.appendDebuggeeOutput("\nnext\n") + + #expect(feature.output == "ready\nnext\n") + } + @Test func staleSessionCallbacksCannotOverwriteAReplacementSession() throws { let descriptor = DebugProviderDescriptor( diff --git a/macos/Tests/LitheTerminalModuleTests/TerminalModuleTests.swift b/macos/Tests/LitheTerminalModuleTests/TerminalModuleTests.swift index 30481a3c..d0a44d92 100644 --- a/macos/Tests/LitheTerminalModuleTests/TerminalModuleTests.swift +++ b/macos/Tests/LitheTerminalModuleTests/TerminalModuleTests.swift @@ -87,6 +87,29 @@ struct TerminalModuleTests { fileExists: { _ in false } ) == .external(URL(string: "https://example.com")!)) } + + @Test + func processOutputIsForwardedBeforeAndAfterProcessStart() throws { + let transport = TestTransport() + transport.outputOnStart = "early\n" + let feature = TerminalFeatureModel(terminalFactory: { transport }) + var output: [String] = [] + + let created = try feature.createProcessSession( + TerminalProcessLaunch( + title: "Debug Main", + executablePath: "/usr/bin/java", + arguments: ["Main"], + workingDirectory: "/tmp" + ), + onOutput: { output.append($0) } + ) + transport.emitOutput("late\n") + + #expect(created.session.isRunning) + #expect(output == ["early\n", "late\n"]) + feature.stopAllSessions() + } } @MainActor @@ -96,6 +119,7 @@ private final class TestTransport: TerminalTransport { var processID: Int32? { isRunning ? 1234 : nil } var shellName = "Shell" var onTermination: ((Int32?) -> Void)? + var onOutput: ((Data) -> Void)? var onTitle: ((String) -> Void)? var onDirectoryUpdate: ((String?) -> Void)? var onLink: ((String, [String: String]) -> Void)? @@ -103,6 +127,7 @@ private final class TestTransport: TerminalTransport { var processLaunches: [TerminalProcessLaunch] = [] var processEnvironments: [[String: String]] = [] var sentInputs: [String] = [] + var outputOnStart: String? func defaultShellPath() -> String { "/bin/zsh" } func defaultEnvironment() -> [String: String] { ["REMOVE_ME": "old"] } func start(workingDirectory: String, shellPath: String, environment: [String: String]) throws { isRunning = true } @@ -113,8 +138,12 @@ private final class TestTransport: TerminalTransport { processLaunches.append(launch) processEnvironments.append(environment) isRunning = true + if let outputOnStart { + onOutput?(Data(outputOnStart.utf8)) + } return 1234 } + func emitOutput(_ value: String) { onOutput?(Data(value.utf8)) } func send(_ input: Data) throws { sentInputs.append(String(decoding: input, as: UTF8.self)) } diff --git a/macos/Tests/LitheTests/LitheCoreLogicTests.swift b/macos/Tests/LitheTests/LitheCoreLogicTests.swift index 58cc31b6..17d575b3 100644 --- a/macos/Tests/LitheTests/LitheCoreLogicTests.swift +++ b/macos/Tests/LitheTests/LitheCoreLogicTests.swift @@ -4023,6 +4023,7 @@ private final class TestTerminalTransport: TerminalTransport { var processID: Int32? { isRunning ? 1234 : nil } var shellName = "Shell" var onTermination: ((Int32?) -> Void)? + var onOutput: ((Data) -> Void)? var onTitle: ((String) -> Void)? var onDirectoryUpdate: ((String?) -> Void)? var onLink: ((String, [String: String]) -> Void)? diff --git a/macos/Tests/LitheTests/TerminalPlacementFeatureModelTests.swift b/macos/Tests/LitheTests/TerminalPlacementFeatureModelTests.swift index ee62f5a2..1a8c85b1 100644 --- a/macos/Tests/LitheTests/TerminalPlacementFeatureModelTests.swift +++ b/macos/Tests/LitheTests/TerminalPlacementFeatureModelTests.swift @@ -102,6 +102,7 @@ private final class PlacementTestTerminalTransport: TerminalTransport { var processID: Int32? { isRunning ? 1234 : nil } var shellName = "Shell" var onTermination: ((Int32?) -> Void)? + var onOutput: ((Data) -> Void)? var onTitle: ((String) -> Void)? var onDirectoryUpdate: ((String?) -> Void)? var onLink: ((String, [String: String]) -> Void)? From 8cb16a557c6f5d34c5c630c92c9bcff857a7d23c Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sun, 30 Aug 2026 12:45:20 +0800 Subject: [PATCH 47/66] feat(debug): manage independent macOS debug sessions --- .../Composition/DebugFeatureGraph.swift | 13 +- .../Lithe/Views/Debug/GenericDebugView.swift | 51 +++ .../GenericDebugFeatureModel.swift | 322 ++++++++++++++++- .../Runtime/DebugAdapterSessionManager.swift | 324 +++++++++++++++--- .../DebugModuleTests.swift | 181 ++++++++++ 5 files changed, 837 insertions(+), 54 deletions(-) diff --git a/macos/Sources/Lithe/Application/Composition/DebugFeatureGraph.swift b/macos/Sources/Lithe/Application/Composition/DebugFeatureGraph.swift index 1e207610..f07601c6 100644 --- a/macos/Sources/Lithe/Application/Composition/DebugFeatureGraph.swift +++ b/macos/Sources/Lithe/Application/Composition/DebugFeatureGraph.swift @@ -28,7 +28,9 @@ final class DebugFeatureGraph: NSObject, DebugServiceGraph { ) } - var isActive: Bool { !adapterSessions.activeAdapterIDs.isEmpty } + var isActive: Bool { + adapterSessions.sessionSummaries.contains(where: \.isRunning) + } var genericFeatureTarget: any GenericDebugFeatureTarget { genericFeature } var hasActiveDebugWork: Bool { isActive } func activate(context: ModuleContext) { @@ -39,7 +41,14 @@ final class DebugFeatureGraph: NSObject, DebugServiceGraph { } func configureModuleLeases(acquire: @escaping @MainActor (String) -> ModuleLease) { - genericFeature.$state.map { ![.idle, .terminated, .failed].contains($0) } + let activeFeature = genericFeature.$state.map { + ![.idle, .terminated, .failed].contains($0) + } + let activeSessions = adapterSessions.$sessionSummaries.map { summaries in + summaries.contains(where: \.isRunning) + } + Publishers.CombineLatest(activeFeature, activeSessions) + .map { $0 || $1 } .removeDuplicates().sink { [weak self] active in guard let self else { return } if active, adapterLease == nil { adapterLease = acquire("Debug adapter session is active") } diff --git a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift index f5f7be18..147a65e7 100644 --- a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift +++ b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift @@ -155,6 +155,9 @@ struct GenericDebugView: View { .foregroundStyle(LitheTheme.secondaryText) .lineLimit(1) } + if feature.sessionSummaries.count > 1 { + sessionPicker + } Spacer() Button { model.showDebugBreakpointManager() } label: { Image(systemName: "list.bullet.rectangle") @@ -189,6 +192,54 @@ struct GenericDebugView: View { } } + private var sessionPicker: some View { + Menu { + Section("Sessions") { + ForEach(feature.sessionSummaries) { summary in + Button { + _ = feature.selectSession(summary.id) + } label: { + HStack(spacing: 6) { + Image(systemName: summary.id == feature.activeSessionID + ? "checkmark.circle.fill" : "circle") + VStack(alignment: .leading, spacing: 1) { + Text(sessionLabel(summary)) + Text(summary.state.title) + .font(.system(size: 9)) + .foregroundStyle(LitheTheme.secondaryText) + } + } + } + } + } + if feature.sessionSummaries.count > 1 { + Divider() + Section("Close other sessions") { + ForEach(feature.sessionSummaries.filter { $0.id != feature.activeSessionID }) { summary in + Button("Stop \(sessionLabel(summary))", role: .destructive) { + feature.stopSession(summary.id) + } + } + } + } + } label: { + Image(systemName: "square.stack.3d.up") + } + .litheIconButton() + .help("Debug sessions") + .accessibilityLabel("Debug sessions") + } + + private func sessionLabel(_ summary: DebugSessionSummary) -> String { + let rootName = summary.rootURL.lastPathComponent.isEmpty + ? summary.rootURL.path + : summary.rootURL.lastPathComponent + if let targetTitle = summary.targetTitle, !targetTitle.isEmpty { + return "\(targetTitle) · \(rootName)" + } + return "\(summary.providerDisplayName) · \(rootName)" + } + private var debugToolbar: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 3) { diff --git a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift index 091aec67..57720af0 100644 --- a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift +++ b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift @@ -192,9 +192,28 @@ private struct GenericDebugOutputNormalizer { } } +/// Cached presentation state for an inactive session. Inspection data is +/// refreshed when the session becomes active so stale frame references are +/// never reused across adapter sessions. +private struct GenericDebugSessionSnapshot { + let providerID: String + let targetTitle: String? + var state: DebugAdapterState + var output: String + var errorMessage: String? + var stoppedReason: String? + var exceptionInfo: DebugExceptionInfo? + var capabilities: DebugAdapterCapabilities + var activeFileURL: URL? + var lastStartRequest: GenericDebugStartRequest? + var normalizer: GenericDebugOutputNormalizer +} + @MainActor public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatureTarget { @Published public private(set) var providerID: String? + @Published public private(set) var activeSessionID: DebugSessionID? + @Published public private(set) var sessionSummaries: [DebugSessionSummary] = [] @Published public private(set) var targetTitle: String? @Published public private(set) var state: DebugAdapterState = .idle @Published public private(set) var output = "" @@ -245,6 +264,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu private var workspaceURL: URL? private var activeFileURL: URL? private var lastStartRequest: GenericDebugStartRequest? + private var sessionSnapshots: [DebugSessionID: GenericDebugSessionSnapshot] = [:] private let maximumOutputCharacters = 400_000 private let variablePageSize = 100 private var watchGeneration = 0 @@ -264,13 +284,35 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu self.breakpointRelocator = breakpointRelocator self.steppingFilterResolver = steppingFilterResolver self.steppingFilterPersistence = steppingFilterPersistence - sessions.onStateChange = { [weak self] providerID, state in - guard self?.providerID == providerID else { return } - self?.state = state + sessionSummaries = sessions.sessionSummaries + sessions.onSessionStateChange = { [weak self] sessionID, providerID, state in + guard let self else { return } + self.sessionSummaries = self.sessions.sessionSummaries + if self.activeSessionID == sessionID { + self.providerID = providerID + self.state = state + self.saveActiveSessionSnapshot() + } else { + self.updateInactiveSessionState( + sessionID, + providerID: providerID, + state: state + ) + } } - sessions.onEvent = { [weak self] providerID, event in - guard self?.providerID == providerID else { return } - self?.consume(event) + sessions.onSessionEvent = { [weak self] sessionID, providerID, event in + guard let self else { return } + self.sessionSummaries = self.sessions.sessionSummaries + if self.activeSessionID == sessionID { + self.consume(event) + self.saveActiveSessionSnapshot() + } else { + self.consumeInactiveSessionEvent( + sessionID, + providerID: providerID, + event: event + ) + } } loadJavaSteppingFilters() } @@ -339,6 +381,92 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu configuration: DebugLaunchConfiguration ) -> Bool { stop() + return startSession( + fileURL: fileURL, + rootURL: rootURL, + configuration: configuration + ) + } + + /// Starts another debug session while keeping existing sessions alive. + /// The new session becomes the active session shown by the Debug tool + /// window. + @discardableResult + public func startAdditional( + fileURL: URL, + rootURL: URL, + configuration: DebugLaunchConfiguration + ) -> Bool { + let previousSessionID = activeSessionID + let previousSnapshot = previousSessionID.flatMap { sessionSnapshots[$0] } + saveActiveSessionSnapshot() + let started = startSession( + fileURL: fileURL, + rootURL: rootURL, + configuration: configuration + ) + guard !started, + let previousSessionID, + let previousSnapshot, + let previousSummary = sessions.sessionSummaries.first(where: { $0.id == previousSessionID }) + else { + return started + } + _ = sessions.select(sessionID: previousSessionID) + activeSessionID = previousSessionID + providerID = previousSnapshot.providerID + sessionSnapshots[previousSessionID] = previousSnapshot + restoreSessionSnapshot( + previousSessionID, + summary: previousSummary + ) + sessionSummaries = sessions.sessionSummaries + return false + } + + /// Makes a registered session active without starting or stopping it. + /// Paused sessions refresh their inspection context after the switch. + @discardableResult + public func selectSession(_ sessionID: DebugSessionID) -> Bool { + guard sessionID != activeSessionID, + let summary = sessions.sessionSummaries.first(where: { $0.id == sessionID }) + else { return false } + saveActiveSessionSnapshot() + invalidateInspectionRequests() + guard sessions.select(sessionID: sessionID) else { return false } + activeSessionID = sessionID + providerID = summary.providerID + restoreSessionSnapshot(sessionID, summary: summary) + sessionSummaries = sessions.sessionSummaries + resetInspectionState() + if state == .paused { + let generation = inspectionGeneration + loadStoppedContext( + threadID: nil, + generation: generation, + shouldLoadExceptionInfo: false + ) + } + return true + } + + /// Stops one session. Inactive sessions do not disturb the currently + /// displayed debugger state. + public func stopSession(_ sessionID: DebugSessionID) { + if activeSessionID == sessionID { + stop() + return + } + sessions.stop(sessionID: sessionID) + sessionSnapshots[sessionID] = nil + sessionSummaries = sessions.sessionSummaries + } + + private func startSession( + fileURL: URL, + rootURL: URL, + configuration: DebugLaunchConfiguration + ) -> Bool { let request = GenericDebugStartRequest( fileURL: fileURL.standardizedFileURL, rootURL: rootURL.standardizedFileURL, @@ -381,12 +509,15 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } else { effectiveConfiguration = request.configuration } - let session = try sessions.launch( + let launched = try sessions.launchNew( for: request.fileURL, rootURL: request.rootURL, configuration: effectiveConfiguration ) - state = session.state + activeSessionID = launched.id + state = launched.session.state + sessionSummaries = sessions.sessionSummaries + saveActiveSessionSnapshot() return true } catch { state = .failed @@ -412,9 +543,11 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu dataBreakpoints.removeAll { !$0.canPersist } try? sessions.setDataBreakpoints(coreDataBreakpoints, for: activeFileURL) } - if let providerID { - sessions.stop(providerID: providerID) + if let activeSessionID { + sessions.stop(sessionID: activeSessionID) + sessionSnapshots[activeSessionID] = nil } + activeSessionID = nil state = .idle stoppedReason = nil exceptionInfo = nil @@ -431,10 +564,31 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu capabilities = .unknown activeFileURL = nil debuggeeOutputNormalizer.reset() + sessionSummaries = sessions.sessionSummaries } public func reset() { - stop() + invalidateInspectionRequests() + sessions.stopAll() + sessionSnapshots.removeAll() + activeSessionID = nil + sessionSummaries = [] + state = .idle + stoppedReason = nil + exceptionInfo = nil + selectedThreadID = nil + selectedFrameID = nil + stoppedFrame = nil + selectedFrame = nil + threads = [] + stackFrames = [] + areFilteredStackFramesExpanded = false + scopes = [] + resetVariableTree() + invalidateWatchResults() + capabilities = .unknown + activeFileURL = nil + debuggeeOutputNormalizer.reset() providerID = nil targetTitle = nil output = "" @@ -791,8 +945,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } public func execute(_ command: DebugExecutionCommand) { - guard let providerID, - let session = sessions.session(providerID: providerID) else { return } + guard let session = activeSession else { return } session.execute(command, threadID: selectedThreadID) } @@ -1176,7 +1329,142 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu append(normalized) } + private func resetInspectionState() { + stoppedReason = state == .paused ? stoppedReason : nil + exceptionInfo = nil + selectedThreadID = nil + selectedFrameID = nil + stoppedFrame = nil + selectedFrame = nil + threads = [] + stackFrames = [] + areFilteredStackFramesExpanded = false + scopes = [] + resetVariableTree() + invalidateWatchResults() + } + + private func saveActiveSessionSnapshot() { + guard let activeSessionID, let providerID else { return } + sessionSnapshots[activeSessionID] = GenericDebugSessionSnapshot( + providerID: providerID, + targetTitle: targetTitle, + state: state, + output: output, + errorMessage: errorMessage, + stoppedReason: stoppedReason, + exceptionInfo: exceptionInfo, + capabilities: capabilities, + activeFileURL: activeFileURL, + lastStartRequest: lastStartRequest, + normalizer: debuggeeOutputNormalizer + ) + } + + private func restoreSessionSnapshot( + _ sessionID: DebugSessionID, + summary: DebugSessionSummary + ) { + guard let snapshot = sessionSnapshots[sessionID] else { + providerID = summary.providerID + targetTitle = summary.targetTitle + state = summary.state + output = "" + errorMessage = nil + stoppedReason = nil + exceptionInfo = nil + capabilities = .unknown + activeFileURL = nil + lastStartRequest = nil + debuggeeOutputNormalizer.reset() + return + } + providerID = snapshot.providerID + targetTitle = snapshot.targetTitle + state = snapshot.state + output = snapshot.output + errorMessage = snapshot.errorMessage + stoppedReason = snapshot.stoppedReason + exceptionInfo = snapshot.exceptionInfo + capabilities = snapshot.capabilities + activeFileURL = snapshot.activeFileURL + lastStartRequest = snapshot.lastStartRequest + debuggeeOutputNormalizer = snapshot.normalizer + } + + private func updateInactiveSessionState( + _ sessionID: DebugSessionID, + providerID: String, + state: DebugAdapterState + ) { + var snapshot = sessionSnapshots[sessionID] ?? GenericDebugSessionSnapshot( + providerID: providerID, + targetTitle: nil, + state: state, + output: "", + errorMessage: nil, + stoppedReason: nil, + exceptionInfo: nil, + capabilities: .unknown, + activeFileURL: nil, + lastStartRequest: nil, + normalizer: GenericDebugOutputNormalizer() + ) + snapshot.state = state + sessionSnapshots[sessionID] = snapshot + } + + private func consumeInactiveSessionEvent( + _ sessionID: DebugSessionID, + providerID: String, + event: DebugAdapterEvent + ) { + var snapshot = sessionSnapshots[sessionID] ?? GenericDebugSessionSnapshot( + providerID: providerID, + targetTitle: nil, + state: sessions.sessionSummaries.first(where: { $0.id == sessionID })?.state ?? .idle, + output: "", + errorMessage: nil, + stoppedReason: nil, + exceptionInfo: nil, + capabilities: .unknown, + activeFileURL: nil, + lastStartRequest: nil, + normalizer: GenericDebugOutputNormalizer() + ) + switch event { + case .initialized: + break + case .capabilities(let capabilities): + snapshot.capabilities = capabilities + case .output(_, let text): + let normalized = snapshot.normalizer.normalize(text) + append(normalized, to: &snapshot.output) + case .stopped(let reason, _, let description): + snapshot.state = .paused + snapshot.stoppedReason = description ?? reason + snapshot.exceptionInfo = nil + case .continued: + snapshot.state = .running + snapshot.stoppedReason = nil + snapshot.exceptionInfo = nil + case .terminated(let exitCode): + snapshot.state = .terminated + snapshot.stoppedReason = nil + snapshot.exceptionInfo = nil + if let exitCode { + append("Debug session exited with code \(exitCode).\n", to: &snapshot.output) + } + case .breakpoint: + break + } + sessionSnapshots[sessionID] = snapshot + } + private var activeSession: (any DebugAdapterControllingSession)? { + if let activeSessionID { + return sessions.session(id: activeSessionID) + } guard let providerID else { return nil } return sessions.session(providerID: providerID) } @@ -1860,5 +2148,13 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu if output.count > maximumOutputCharacters { output.removeFirst(output.count - maximumOutputCharacters) } + saveActiveSessionSnapshot() + } + + private func append(_ text: String, to output: inout String) { + output += text + if output.count > maximumOutputCharacters { + output.removeFirst(output.count - maximumOutputCharacters) + } } } diff --git a/macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift b/macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift index 0c6222f3..c89634c6 100644 --- a/macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift +++ b/macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift @@ -1,6 +1,58 @@ import Foundation import LitheCoreContracts +/// Identifies one independently managed debug adapter session. +public struct DebugSessionID: Hashable, Codable, Sendable, CustomStringConvertible { + public let rawValue: UUID + + public init(rawValue: UUID = UUID()) { + self.rawValue = rawValue + } + + public var description: String { rawValue.uuidString.lowercased() } +} + +/// A stable, UI-safe projection of one debug session. +public struct DebugSessionSummary: Identifiable, Equatable, Sendable { + public let id: DebugSessionID + public let providerID: String + public let providerDisplayName: String + public let rootURL: URL + public let state: DebugAdapterState + public let targetTitle: String? + + public var isRunning: Bool { + ![.idle, .terminated, .failed].contains(state) + } + + public init( + id: DebugSessionID, + providerID: String, + providerDisplayName: String, + rootURL: URL, + state: DebugAdapterState, + targetTitle: String? = nil + ) { + self.id = id + self.providerID = providerID + self.providerDisplayName = providerDisplayName + self.rootURL = rootURL + self.state = state + self.targetTitle = targetTitle + } +} + +/// The adapter and its public identity returned when a new session is created. +public struct DebugSessionHandle { + public let id: DebugSessionID + public let session: any DebugAdapterSession + + public init(id: DebugSessionID, session: any DebugAdapterSession) { + self.id = id + self.session = session + } +} + /// Owns every DAP session, breakpoint projection, and debug callback. /// /// This is deliberately separate from `LanguageToolingSessionManager`: an LSP @@ -11,13 +63,16 @@ public final class DebugAdapterSessionManager: ObservableObject { @Published public private(set) var states: [String: DebugAdapterState] = [:] @Published public private(set) var lastEvents: [String: DebugAdapterEvent] = [:] @Published public private(set) var verifiedBreakpoints: [String: [DebugBreakpoint]] = [:] + @Published public private(set) var sessionSummaries: [DebugSessionSummary] = [] public var onStateChange: ((String, DebugAdapterState) -> Void)? public var onEvent: ((String, DebugAdapterEvent) -> Void)? + public var onSessionStateChange: ((DebugSessionID, String, DebugAdapterState) -> Void)? + public var onSessionEvent: ((DebugSessionID, String, DebugAdapterEvent) -> Void)? public var onRunInTerminalRequest: DebugRunInTerminalRequestHandler? { didSet { - for session in sessions.values { - configureRunInTerminalHandler(session) + for managed in sessions.values { + configureRunInTerminalHandler(managed.session) } } } @@ -27,7 +82,18 @@ public final class DebugAdapterSessionManager: ObservableObject { DebugProviderDescriptor, URL ) -> (any DebugAdapterSession)? - private var sessions: [String: any DebugAdapterSession] = [:] + private struct ManagedSession { + let id: DebugSessionID + let descriptor: DebugProviderDescriptor + let rootURL: URL + let session: any DebugAdapterSession + let activationToken: UUID + var targetTitle: String? + } + + private var sessions: [DebugSessionID: ManagedSession] = [:] + private var sessionOrder: [DebugSessionID] = [] + private var activeSessionIDsByProvider: [String: DebugSessionID] = [:] private var roots: [String: URL] = [:] private var activationTokens: [String: UUID] = [:] private var requestedBreakpoints: [String: [URL: [DebugSourceBreakpoint]]] = [:] @@ -46,7 +112,11 @@ public final class DebugAdapterSessionManager: ObservableObject { self.makeSession = makeSession } - public var activeAdapterIDs: Set { Set(sessions.keys) } + /// Provider IDs with a currently selected compatibility session. + public var activeAdapterIDs: Set { Set(activeSessionIDsByProvider.keys) } + + /// IDs for every session still registered with the manager. + public var activeSessionIDs: Set { Set(sessions.keys) } public func provider(for fileURL: URL) -> DebugProviderDescriptor? { providers.first { $0.matches(fileURL) } @@ -54,44 +124,66 @@ public final class DebugAdapterSessionManager: ObservableObject { @discardableResult public func activate(for fileURL: URL, rootURL: URL) throws -> any DebugAdapterSession { - guard let descriptor = provider(for: fileURL) else { - throw DebugProviderError.noProvider( - fileExtension: fileURL.pathExtension.lowercased() - ) + let providerID = try descriptor(for: fileURL).id + if let sessionID = activeSessionIDsByProvider[providerID], + let managed = sessions[sessionID], + managed.session.isRunning, + managed.rootURL == rootURL.standardizedFileURL { + return managed.session } - - let normalizedRoot = rootURL.standardizedFileURL - if let active = sessions[descriptor.id] { - if active.isRunning, roots[descriptor.id] == normalizedRoot { - return active - } - active.stop() - sessions[descriptor.id] = nil - roots[descriptor.id] = nil - activationTokens[descriptor.id] = nil + if activeSessionIDsByProvider[providerID] != nil { + stop(providerID: providerID) } + return try activateNew(for: fileURL, rootURL: rootURL).session + } + /// Creates a new independent session without replacing another session for + /// the same provider. Existing provider-level APIs continue to use the + /// latest session as their compatibility target. + @discardableResult + public func activateNew(for fileURL: URL, rootURL: URL) throws -> DebugSessionHandle { + let descriptor = try descriptor(for: fileURL) + let normalizedRoot = rootURL.standardizedFileURL guard let session = makeSession(descriptor, normalizedRoot) else { throw DebugProviderError.adapterUnavailable(descriptor.displayName) } + let sessionID = DebugSessionID() let activationToken = UUID() - activationTokens[descriptor.id] = activationToken configureCallbacks( session, + sessionID: sessionID, providerID: descriptor.id, activationToken: activationToken ) + sessions[sessionID] = ManagedSession( + id: sessionID, + descriptor: descriptor, + rootURL: normalizedRoot, + session: session, + activationToken: activationToken, + targetTitle: nil + ) + sessionOrder.append(sessionID) + activeSessionIDsByProvider[descriptor.id] = sessionID + roots[descriptor.id] = normalizedRoot + states[descriptor.id] = session.state + activationTokens[descriptor.id] = activationToken + updateSessionSummary(sessionID) do { try session.start(rootURL: normalizedRoot) } catch { - if activationTokens[descriptor.id] == activationToken { - activationTokens[descriptor.id] = nil + session.stop() + sessions.removeValue(forKey: sessionID) + sessionOrder.removeAll { $0 == sessionID } + if activeSessionIDsByProvider[descriptor.id] == sessionID { + promoteLatestSession(for: descriptor.id) } throw error } - sessions[descriptor.id] = session - roots[descriptor.id] = normalizedRoot - states[descriptor.id] = session.state + if activeSessionIDsByProvider[descriptor.id] == sessionID { + states[descriptor.id] = session.state + } + updateSessionSummary(sessionID) if let controlling = session as? any DebugAdapterControllingSession { for (source, breakpoints) in requestedBreakpoints[descriptor.id] ?? [:] { controlling.setBreakpoints(breakpoints, in: source) @@ -106,7 +198,7 @@ public final class DebugAdapterSessionManager: ObservableObject { controlling.setDataBreakpoints(breakpoints) } } - return session + return DebugSessionHandle(id: sessionID, session: session) } @discardableResult @@ -126,6 +218,31 @@ public final class DebugAdapterSessionManager: ObservableObject { return controlling } + /// Starts a new independent adapter session and returns its identity. + @discardableResult + public func launchNew( + for fileURL: URL, + rootURL: URL, + configuration: DebugLaunchConfiguration + ) throws -> (id: DebugSessionID, session: any DebugAdapterControllingSession) { + let handle = try activateNew(for: fileURL, rootURL: rootURL) + guard let controlling = handle.session as? any DebugAdapterControllingSession else { + stop(sessionID: handle.id) + throw DebugProviderError.capabilityUnavailable( + provider: provider(for: fileURL)?.displayName ?? fileURL.pathExtension, + capability: "DAP launch control" + ) + } + do { + try controlling.launch(configuration) + } catch { + stop(sessionID: handle.id) + throw error + } + updateSessionTargetTitle(handle.id, title: configuration.name) + return (handle.id, controlling) + } + public func setBreakpoints(_ breakpoints: [DebugSourceBreakpoint], in fileURL: URL) throws { guard let descriptor = provider(for: fileURL) else { throw DebugProviderError.noProvider( @@ -135,7 +252,10 @@ public final class DebugAdapterSessionManager: ObservableObject { var values = requestedBreakpoints[descriptor.id] ?? [:] values[fileURL.standardizedFileURL] = breakpoints requestedBreakpoints[descriptor.id] = values - session(providerID: descriptor.id)?.setBreakpoints(breakpoints, in: fileURL) + sessions.values + .filter { $0.descriptor.id == descriptor.id } + .compactMap { $0.session as? any DebugAdapterControllingSession } + .forEach { $0.setBreakpoints(breakpoints, in: fileURL) } } public func setExceptionBreakpoints( @@ -148,7 +268,10 @@ public final class DebugAdapterSessionManager: ObservableObject { ) } requestedExceptionBreakpoints[descriptor.id] = breakpoints - session(providerID: descriptor.id)?.setExceptionBreakpoints(breakpoints) + sessions.values + .filter { $0.descriptor.id == descriptor.id } + .compactMap { $0.session as? any DebugAdapterControllingSession } + .forEach { $0.setExceptionBreakpoints(breakpoints) } } public func setFunctionBreakpoints( @@ -161,7 +284,10 @@ public final class DebugAdapterSessionManager: ObservableObject { ) } requestedFunctionBreakpoints[descriptor.id] = breakpoints - session(providerID: descriptor.id)?.setFunctionBreakpoints(breakpoints) + sessions.values + .filter { $0.descriptor.id == descriptor.id } + .compactMap { $0.session as? any DebugAdapterControllingSession } + .forEach { $0.setFunctionBreakpoints(breakpoints) } } public func setDataBreakpoints( @@ -174,28 +300,68 @@ public final class DebugAdapterSessionManager: ObservableObject { ) } requestedDataBreakpoints[descriptor.id] = breakpoints - session(providerID: descriptor.id)?.setDataBreakpoints(breakpoints) + sessions.values + .filter { $0.descriptor.id == descriptor.id } + .compactMap { $0.session as? any DebugAdapterControllingSession } + .forEach { $0.setDataBreakpoints(breakpoints) } } public func session(providerID: String) -> (any DebugAdapterControllingSession)? { - sessions[providerID] as? any DebugAdapterControllingSession + guard let sessionID = activeSessionIDsByProvider[providerID] else { return nil } + return sessions[sessionID]?.session as? any DebugAdapterControllingSession + } + + public func session(id: DebugSessionID) -> (any DebugAdapterControllingSession)? { + sessions[id]?.session as? any DebugAdapterControllingSession + } + + /// Selects which session is addressed by the compatibility provider-level + /// APIs and callbacks. Selection does not start or stop a session. + @discardableResult + public func select(sessionID: DebugSessionID) -> Bool { + guard let managed = sessions[sessionID] else { return false } + let providerID = managed.descriptor.id + activeSessionIDsByProvider[providerID] = sessionID + roots[providerID] = managed.rootURL + activationTokens[providerID] = managed.activationToken + states[providerID] = managed.session.state + return true + } + + public func selectedSessionID(providerID: String) -> DebugSessionID? { + activeSessionIDsByProvider[providerID] } public func stop(providerID: String) { - activationTokens[providerID] = nil - sessions.removeValue(forKey: providerID)?.stop() - roots[providerID] = nil - states[providerID] = .idle + guard let sessionID = activeSessionIDsByProvider[providerID] else { + states[providerID] = .idle + return + } + stop(sessionID: sessionID) + } + + public func stop(sessionID: DebugSessionID) { + guard let managed = sessions.removeValue(forKey: sessionID) else { return } + managed.session.stop() + sessionOrder.removeAll { $0 == sessionID } + if activeSessionIDsByProvider[managed.descriptor.id] == sessionID { + promoteLatestSession(for: managed.descriptor.id) + } + sessionSummaries.removeAll { $0.id == sessionID } + onSessionStateChange?(sessionID, managed.descriptor.id, .idle) } public func stopAll() { - for session in sessions.values { session.stop() } + for session in sessions.values { session.session.stop() } sessions.removeAll() + sessionOrder.removeAll() + activeSessionIDsByProvider.removeAll() roots.removeAll() activationTokens.removeAll() states.removeAll() lastEvents.removeAll() verifiedBreakpoints.removeAll() + sessionSummaries.removeAll() requestedBreakpoints.removeAll() requestedExceptionBreakpoints.removeAll() requestedFunctionBreakpoints.removeAll() @@ -204,19 +370,26 @@ public final class DebugAdapterSessionManager: ObservableObject { private func configureCallbacks( _ session: any DebugAdapterSession, + sessionID: DebugSessionID, providerID: String, activationToken: UUID ) { configureRunInTerminalHandler(session) guard let controlling = session as? any DebugAdapterControllingSession else { return } controlling.onStateChange = { [weak self] state in - guard self?.activationTokens[providerID] == activationToken else { return } - self?.states[providerID] = state - self?.onStateChange?(providerID, state) + guard let self, + self.sessions[sessionID]?.activationToken == activationToken else { return } + self.updateSessionState(sessionID, state: state) + self.onSessionStateChange?(sessionID, providerID, state) + guard self.activeSessionIDsByProvider[providerID] == sessionID else { return } + self.states[providerID] = state + self.onStateChange?(providerID, state) } controlling.onEvent = { [weak self] event in guard let self, - self.activationTokens[providerID] == activationToken else { return } + self.sessions[sessionID]?.activationToken == activationToken else { return } + self.onSessionEvent?(sessionID, providerID, event) + guard self.activeSessionIDsByProvider[providerID] == sessionID else { return } lastEvents[providerID] = event onEvent?(providerID, event) if case .breakpoint(let breakpoint) = event { @@ -234,6 +407,79 @@ public final class DebugAdapterSessionManager: ObservableObject { } } + private func descriptor(for fileURL: URL) throws -> DebugProviderDescriptor { + guard let descriptor = provider(for: fileURL) else { + throw DebugProviderError.noProvider( + fileExtension: fileURL.pathExtension.lowercased() + ) + } + return descriptor + } + + private func updateSessionState(_ sessionID: DebugSessionID, state: DebugAdapterState) { + guard let index = sessionSummaries.firstIndex(where: { $0.id == sessionID }), + let existing = sessions[sessionID] else { return } + sessionSummaries[index] = DebugSessionSummary( + id: sessionID, + providerID: existing.descriptor.id, + providerDisplayName: existing.descriptor.displayName, + rootURL: existing.rootURL, + state: state, + targetTitle: existing.targetTitle + ) + } + + private func promoteLatestSession(for providerID: String) { + guard let replacementID = sessionOrder.reversed().first(where: { + sessions[$0]?.descriptor.id == providerID + }), let replacement = sessions[replacementID] else { + activeSessionIDsByProvider[providerID] = nil + roots[providerID] = nil + activationTokens[providerID] = nil + states[providerID] = .idle + lastEvents[providerID] = nil + verifiedBreakpoints[providerID] = nil + return + } + activeSessionIDsByProvider[providerID] = replacementID + roots[providerID] = replacement.rootURL + activationTokens[providerID] = replacement.activationToken + states[providerID] = replacement.session.state + lastEvents[providerID] = nil + verifiedBreakpoints[providerID] = nil + } + + private func updateSessionSummary(_ sessionID: DebugSessionID) { + guard let managed = sessions[sessionID] else { return } + let summary = DebugSessionSummary( + id: sessionID, + providerID: managed.descriptor.id, + providerDisplayName: managed.descriptor.displayName, + rootURL: managed.rootURL, + state: managed.session.state, + targetTitle: managed.targetTitle + ) + if let index = sessionSummaries.firstIndex(where: { $0.id == sessionID }) { + sessionSummaries[index] = summary + } else { + sessionSummaries.append(summary) + } + sessionSummaries.sort { left, right in + guard let leftIndex = sessionOrder.firstIndex(of: left.id), + let rightIndex = sessionOrder.firstIndex(of: right.id) else { + return left.id.description < right.id.description + } + return leftIndex < rightIndex + } + } + + private func updateSessionTargetTitle(_ sessionID: DebugSessionID, title: String?) { + guard var managed = sessions[sessionID] else { return } + managed.targetTitle = title + sessions[sessionID] = managed + updateSessionSummary(sessionID) + } + private func configureRunInTerminalHandler(_ session: any DebugAdapterSession) { guard let session = session as? any DebugAdapterRunInTerminalSession else { return } session.onRunInTerminalRequest = onRunInTerminalRequest diff --git a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift index a152f2a2..2c1fff95 100644 --- a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift +++ b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift @@ -60,6 +60,178 @@ struct DebugModuleTests { manager.stopAll() } + @Test + func independentSessionsShareBreakpointsButKeepStateAndCallbacksSeparate() throws { + let descriptor = DebugProviderDescriptor( + id: "java", + displayName: "Java", + fileExtensions: ["java"] + ) + var createdSessions: [DeferredInspectionDebugSession] = [] + let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in + let session = DeferredInspectionDebugSession() + createdSessions.append(session) + return session + } + var receivedEvents: [(DebugSessionID, String, DebugAdapterEvent)] = [] + manager.onSessionEvent = { sessionID, providerID, event in + receivedEvents.append((sessionID, providerID, event)) + } + + let root = URL(fileURLWithPath: "/tmp/java-debug-multiple", isDirectory: true) + let source = root.appendingPathComponent("src/Main.java") + let first = try manager.activateNew(for: source, rootURL: root) + let second = try manager.activateNew(for: source, rootURL: root) + let firstSession = try #require(createdSessions.first) + let secondSession = try #require(createdSessions.dropFirst().first) + + #expect(first.id != second.id) + #expect(manager.sessionSummaries.map(\.id) == [first.id, second.id]) + #expect(manager.activeAdapterIDs == ["java"]) + #expect(manager.session(providerID: "java") === secondSession) + + try manager.setBreakpoints([ + DebugSourceBreakpoint(line: 12, enabled: true) + ], in: source) + #expect(firstSession.breakpointUpdates.last?.first?.line == 12) + #expect(secondSession.breakpointUpdates.last?.first?.line == 12) + + firstSession.emit(.output(category: "stdout", output: "old\n")) + secondSession.emit(.output(category: "stdout", output: "new\n")) + #expect(receivedEvents.map { $0.0 } == [first.id, second.id]) + #expect(manager.lastEvents["java"] == .output(category: "stdout", output: "new\n")) + + #expect(manager.select(sessionID: first.id)) + firstSession.emit(.output(category: "stdout", output: "selected-first\n")) + #expect(manager.lastEvents["java"] == .output(category: "stdout", output: "selected-first\n")) + + manager.stop(sessionID: second.id) + #expect(manager.session(providerID: "java") === firstSession) + #expect(manager.activeSessionIDs == [first.id]) + #expect(manager.states["java"] == .ready) + #expect(manager.sessionSummaries.map(\.id) == [first.id]) + + manager.stop(sessionID: first.id) + #expect(manager.activeSessionIDs.isEmpty) + #expect(manager.activeAdapterIDs.isEmpty) + #expect(manager.states["java"] == .idle) + } + + @Test + func featureSwitchesSessionsWithoutMixingTheirConsoleState() throws { + let descriptor = DebugProviderDescriptor( + id: "java", + displayName: "Java", + fileExtensions: ["java"] + ) + var createdSessions: [DeferredInspectionDebugSession] = [] + let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in + let session = DeferredInspectionDebugSession() + createdSessions.append(session) + return session + } + let feature = GenericDebugFeatureModel(sessions: manager) + let root = URL(fileURLWithPath: "/tmp/java-debug-feature-sessions", isDirectory: true) + let source = root.appendingPathComponent("src/Main.java") + let firstConfiguration = DebugLaunchConfiguration( + name: "First", + request: .launch, + arguments: ["mainClass": .string("example.First")] + ) + let secondConfiguration = DebugLaunchConfiguration( + name: "Second", + request: .launch, + arguments: ["mainClass": .string("example.Second")] + ) + + #expect(feature.start( + fileURL: source, + rootURL: root, + configuration: firstConfiguration + )) + let first = try #require(createdSessions.first) + let firstID = try #require(feature.activeSessionID) + first.emit(.output(category: "stdout", output: "first\n")) + + #expect(feature.startAdditional( + fileURL: source, + rootURL: root, + configuration: secondConfiguration + )) + let second = try #require(createdSessions.dropFirst().first) + let secondID = try #require(feature.activeSessionID) + #expect(firstID != secondID) + second.emit(.output(category: "stdout", output: "second\n")) + first.emit(.output(category: "stdout", output: "first-late\n")) + #expect(feature.output == "second\n") + + #expect(feature.selectSession(firstID)) + #expect(feature.output == "first\nfirst-late\n") + #expect(feature.targetTitle == "First") + #expect(feature.activeSessionID == firstID) + + #expect(feature.selectSession(secondID)) + #expect(feature.output == "second\n") + #expect(feature.targetTitle == "Second") + #expect(feature.activeSessionID == secondID) + + feature.stopSession(firstID) + #expect(feature.sessionSummaries.map(\.id) == [secondID]) + feature.stop() + #expect(feature.sessionSummaries.isEmpty) + } + + @Test + func additionalSessionLaunchFailureRestoresTheOriginalActiveSession() throws { + let descriptor = DebugProviderDescriptor( + id: "java", + displayName: "Java", + fileExtensions: ["java"] + ) + var createdSessions: [DeferredInspectionDebugSession] = [] + let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in + let session = DeferredInspectionDebugSession() + if createdSessions.count == 1 { + session.failNextLaunch = true + } + createdSessions.append(session) + return session + } + let feature = GenericDebugFeatureModel(sessions: manager) + let root = URL(fileURLWithPath: "/tmp/java-debug-additional-failure", isDirectory: true) + let source = root.appendingPathComponent("src/Main.java") + let firstConfiguration = DebugLaunchConfiguration( + name: "First", + request: .launch, + arguments: ["mainClass": .string("example.First")] + ) + let secondConfiguration = DebugLaunchConfiguration( + name: "Second", + request: .launch, + arguments: ["mainClass": .string("example.Second")] + ) + + #expect(feature.start(fileURL: source, rootURL: root, configuration: firstConfiguration)) + let first = try #require(createdSessions.first) + let firstID = try #require(feature.activeSessionID) + first.emit(.output(category: "stdout", output: "first\n")) + + #expect(!feature.startAdditional( + fileURL: source, + rootURL: root, + configuration: secondConfiguration + )) + #expect(createdSessions.count == 2) + #expect(feature.activeSessionID == firstID) + #expect(feature.targetTitle == "First") + #expect(feature.output == "first\n") + #expect(feature.state == .paused) + #expect(feature.errorMessage == nil) + #expect(manager.activeSessionIDs == [firstID]) + + feature.stop() + } + @Test func coreProtocolSessionProjectsRustUpdatesThroughInjectedTransport() throws { let transport = RecordingTransport() @@ -2353,6 +2525,10 @@ private enum DebugSteppingFilterPersistenceTestError: Error { case unreadable } +private enum DeferredDebugSessionError: Error { + case launchFailed +} + private final class FailingDebugSteppingFilterPersistence: DebugSteppingFilterPersisting, @unchecked Sendable @@ -2390,6 +2566,7 @@ private final class DeferredInspectionDebugSession: DebugAdapterControllingSessi private(set) var startCount = 0 private(set) var launchConfigurations: [DebugLaunchConfiguration] = [] private(set) var breakpointUpdates: [[DebugSourceBreakpoint]] = [] + var failNextLaunch = false var onStateChange: ((DebugAdapterState) -> Void)? var onEvent: ((DebugAdapterEvent) -> Void)? @@ -2445,6 +2622,10 @@ private final class DeferredInspectionDebugSession: DebugAdapterControllingSessi func launch(_ configuration: DebugLaunchConfiguration) throws { launchConfigurations.append(configuration) + if failNextLaunch { + failNextLaunch = false + throw DeferredDebugSessionError.launchFailed + } state = .paused onStateChange?(.paused) } From 52782c6e046c349030a02233847eefaab028306c Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sun, 30 Aug 2026 12:54:39 +0800 Subject: [PATCH 48/66] fix(mac-debug): promote remaining session after stop --- .../GenericDebugFeatureModel.swift | 18 ++++++- .../DebugModuleTests.swift | 53 +++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift index 57720af0..b50fc788 100644 --- a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift +++ b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift @@ -547,6 +547,23 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu sessions.stop(sessionID: activeSessionID) sessionSnapshots[activeSessionID] = nil } + sessionSummaries = sessions.sessionSummaries + if let replacement = sessionSummaries.last, + sessions.select(sessionID: replacement.id) { + activeSessionID = replacement.id + providerID = replacement.providerID + restoreSessionSnapshot(replacement.id, summary: replacement) + resetInspectionState() + if state == .paused { + let generation = inspectionGeneration + loadStoppedContext( + threadID: nil, + generation: generation, + shouldLoadExceptionInfo: false + ) + } + return + } activeSessionID = nil state = .idle stoppedReason = nil @@ -564,7 +581,6 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu capabilities = .unknown activeFileURL = nil debuggeeOutputNormalizer.reset() - sessionSummaries = sessions.sessionSummaries } public func reset() { diff --git a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift index 2c1fff95..1d28ebbf 100644 --- a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift +++ b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift @@ -232,6 +232,59 @@ struct DebugModuleTests { feature.stop() } + @Test + func stoppingActiveSessionPromotesTheMostRecentRemainingSession() throws { + let descriptor = DebugProviderDescriptor( + id: "java", + displayName: "Java", + fileExtensions: ["java"] + ) + var createdSessions: [DeferredInspectionDebugSession] = [] + let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in + let session = DeferredInspectionDebugSession() + createdSessions.append(session) + return session + } + let feature = GenericDebugFeatureModel(sessions: manager) + let root = URL(fileURLWithPath: "/tmp/java-debug-stop-promotion", isDirectory: true) + let source = root.appendingPathComponent("src/Main.java") + let firstConfiguration = DebugLaunchConfiguration( + name: "First", + request: .launch, + arguments: ["mainClass": .string("example.First")] + ) + let secondConfiguration = DebugLaunchConfiguration( + name: "Second", + request: .launch, + arguments: ["mainClass": .string("example.Second")] + ) + + #expect(feature.start(fileURL: source, rootURL: root, configuration: firstConfiguration)) + let firstID = try #require(feature.activeSessionID) + let first = try #require(createdSessions.first) + first.emit(.output(category: "stdout", output: "first\n")) + #expect(feature.startAdditional( + fileURL: source, + rootURL: root, + configuration: secondConfiguration + )) + let secondID = try #require(feature.activeSessionID) + #expect(secondID != firstID) + let second = try #require(createdSessions.dropFirst().first) + second.emit(.output(category: "stdout", output: "second\n")) + + feature.stop() + + #expect(feature.activeSessionID == firstID) + #expect(feature.targetTitle == "First") + #expect(feature.output == "first\n") + #expect(feature.state == .paused) + #expect(manager.activeSessionIDs == [firstID]) + #expect(second.isRunning == false) + + feature.stop() + } + @Test func coreProtocolSessionProjectsRustUpdatesThroughInjectedTransport() throws { let transport = RecordingTransport() From 69850df39ee7bb4ab7bdb652ead41554b38b0c06 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sun, 30 Aug 2026 13:09:02 +0800 Subject: [PATCH 49/66] fix(mac-debug): isolate integrated terminals by session --- .../AppModel/AppModel+Development.swift | 19 +++++-- .../AppModel/AppModel+ExecutionModules.swift | 19 ++++++- .../Models/AppModel/AppModel+Terminal.swift | 52 +++++++++++++++++-- .../Lithe/Models/AppModel/AppModel.swift | 2 + .../GenericDebugFeatureModel.swift | 17 ++++++ .../Runtime/DebugAdapterSessionManager.swift | 34 ++++++++++-- .../DebugModuleTests.swift | 45 +++++++++++++++- 7 files changed, 175 insertions(+), 13 deletions(-) diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift index 2ca18a14..2b75cf1d 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift @@ -653,8 +653,17 @@ extension AppModel { func stopDebugging() { cancelJavaTestDebugLaunch() - genericDebugFeatureIfActive?.stop() - stopDebugTerminalProcesses() + guard let feature = genericDebugFeatureIfActive else { + stopDebugTerminalProcesses() + return + } + let activeSessionID = feature.activeSessionID + feature.stop() + if let activeSessionID { + stopDebugTerminalProcesses(for: activeSessionID) + } else { + stopDebugTerminalProcesses() + } } func cancelJavaTestDebugLaunch() { @@ -686,7 +695,11 @@ extension AppModel { } guard state == .terminated || state == .failed else { return } stopJavaTestResultServer() - stopDebugTerminalProcesses() + if let activeSessionID = genericDebugFeatureIfActive?.activeSessionID { + stopDebugTerminalProcesses(for: activeSessionID) + } else { + stopDebugTerminalProcesses() + } } private func isCurrentJavaTestDebugLaunch(_ operationID: UUID) -> Bool { diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift index 22890767..60b2f86a 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift @@ -81,12 +81,29 @@ extension AppModel { } private func configureDebugRunInTerminalHandler(_ feature: GenericDebugFeatureModel) { + feature.onSessionSelectionChanged = { [weak self] debugSessionID in + guard let self else { return } + self.activeDebugTerminalSessionID = debugSessionID.flatMap { + self.activeDebugTerminalSessionIDsByDebugSession[$0] + } + } + feature.onSessionRunInTerminalRequest = { [weak self] debugSessionID, request, completion in + guard let self else { + completion(.failure(DebugAdapterCapabilityError.unsupported("run in terminal"))) + return + } + handleDebugRunInTerminalRequest( + request, + debugSessionID: debugSessionID, + completion: completion + ) + } feature.onRunInTerminalRequest = { [weak self] request, completion in guard let self else { completion(.failure(DebugAdapterCapabilityError.unsupported("run in terminal"))) return } - handleDebugRunInTerminalRequest(request, completion: completion) + handleDebugRunInTerminalRequest(request, debugSessionID: nil, completion: completion) } } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift index 35074c44..8acbb003 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift @@ -1,5 +1,6 @@ import Combine import Foundation +import LitheDebugModule import LitheCoreContracts import LitheTerminalModule @@ -102,6 +103,7 @@ extension AppModel { func handleDebugRunInTerminalRequest( _ request: DebugRunInTerminalRequest, + debugSessionID: DebugSessionID? = nil, completion: @escaping DebugRunInTerminalCompletion ) { Task { @MainActor [weak self] in @@ -110,7 +112,10 @@ extension AppModel { return } do { - completion(.success(try await startDebugProcessInTerminal(request))) + completion(.success(try await startDebugProcessInTerminal( + request, + debugSessionID: debugSessionID + ))) } catch { completion(.failure(error)) } @@ -118,7 +123,8 @@ extension AppModel { } private func startDebugProcessInTerminal( - _ request: DebugRunInTerminalRequest + _ request: DebugRunInTerminalRequest, + debugSessionID: DebugSessionID? ) async throws -> DebugRunInTerminalResponse { guard request.kind == .integrated else { throw DebugTerminalLaunchError.externalTerminalUnsupported @@ -157,6 +163,10 @@ extension AppModel { terminalPlacementFeature.registerSession(created.session.id) debugTerminalSessionIDs.insert(created.session.id) activeDebugTerminalSessionID = created.session.id + if let debugSessionID { + debugTerminalSessionIDsByDebugSession[debugSessionID, default: []].insert(created.session.id) + activeDebugTerminalSessionIDsByDebugSession[debugSessionID] = created.session.id + } isTerminalVisible = true isTestsVisible = false isGitLogVisible = false @@ -175,12 +185,31 @@ extension AppModel { } debugTerminalSessionIDs.removeAll() activeDebugTerminalSessionID = nil + debugTerminalSessionIDsByDebugSession.removeAll() + activeDebugTerminalSessionIDsByDebugSession.removeAll() + } + + func stopDebugTerminalProcesses(for debugSessionID: DebugSessionID) { + let sessionIDs = debugTerminalSessionIDsByDebugSession.removeValue(forKey: debugSessionID) ?? [] + for sessionID in sessionIDs.sorted(by: { $0.uuidString < $1.uuidString }) { + terminalSessions.first(where: { $0.id == sessionID })?.stop() + debugTerminalSessionIDs.remove(sessionID) + } + activeDebugTerminalSessionIDsByDebugSession.removeValue(forKey: debugSessionID) + if let activeDebugTerminalSessionID, + sessionIDs.contains(activeDebugTerminalSessionID) { + self.activeDebugTerminalSessionID = nil + } } var isDebugStandardInputAvailable: Bool { guard let terminalFeature else { return false } - let candidateIDs = [activeDebugTerminalSessionID] + let debugSessionID = genericDebugFeatureIfActive?.activeSessionID + let scopedIDs = debugSessionID.flatMap { debugTerminalSessionIDsByDebugSession[$0] } ?? [] + let candidateIDs = [debugSessionID.flatMap { activeDebugTerminalSessionIDsByDebugSession[$0] }] .compactMap { $0 } + + scopedIDs.sorted(by: { $0.uuidString < $1.uuidString }) + + [activeDebugTerminalSessionID].compactMap { $0 } + debugTerminalSessionIDs.sorted(by: { $0.uuidString < $1.uuidString }) return candidateIDs.contains { sessionID in guard let session = terminalFeature.terminalSessions.first(where: { $0.id == sessionID }) else { @@ -196,8 +225,12 @@ extension AppModel { showNotification("No running debug process accepts standard input") return false } - let candidateIDs = [activeDebugTerminalSessionID] + let debugSessionID = genericDebugFeatureIfActive?.activeSessionID + let scopedIDs = debugSessionID.flatMap { debugTerminalSessionIDsByDebugSession[$0] } ?? [] + let candidateIDs = [debugSessionID.flatMap { activeDebugTerminalSessionIDsByDebugSession[$0] }] .compactMap { $0 } + + scopedIDs.sorted(by: { $0.uuidString < $1.uuidString }) + + [activeDebugTerminalSessionID].compactMap { $0 } + debugTerminalSessionIDs.sorted(by: { $0.uuidString < $1.uuidString }) guard let sessionID = candidateIDs.first(where: { sessionID in guard let session = terminalFeature.terminalSessions.first(where: { $0.id == sessionID }) else { @@ -299,6 +332,15 @@ extension AppModel { func closeTerminalSession(_ session: TerminalSession) { guard terminalSessions.contains(where: { $0.id == session.id }) else { return } debugTerminalSessionIDs.remove(session.id) + for debugSessionID in debugTerminalSessionIDsByDebugSession.keys { + debugTerminalSessionIDsByDebugSession[debugSessionID]?.remove(session.id) + if debugTerminalSessionIDsByDebugSession[debugSessionID]?.isEmpty == true { + debugTerminalSessionIDsByDebugSession[debugSessionID] = nil + } + if activeDebugTerminalSessionIDsByDebugSession[debugSessionID] == session.id { + activeDebugTerminalSessionIDsByDebugSession[debugSessionID] = nil + } + } if activeDebugTerminalSessionID == session.id { activeDebugTerminalSessionID = nil } @@ -316,6 +358,8 @@ extension AppModel { func stopTerminalSessions() { debugTerminalSessionIDs.removeAll() activeDebugTerminalSessionID = nil + debugTerminalSessionIDsByDebugSession.removeAll() + activeDebugTerminalSessionIDsByDebugSession.removeAll() editorTabOrderFeature.removeAllTerminals() terminalPlacementFeature.reset() terminalFeature?.stopAllSessions() diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 6b41eb14..d7de467c 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -165,6 +165,8 @@ final class AppModel: ObservableObject, Identifiable { let terminalPlacementFeature: TerminalPlacementFeatureModel var debugTerminalSessionIDs: Set = [] var activeDebugTerminalSessionID: UUID? + var debugTerminalSessionIDsByDebugSession: [DebugSessionID: Set] = [:] + var activeDebugTerminalSessionIDsByDebugSession: [DebugSessionID: UUID] = [:] private struct CachedModuleCapability { let moduleID: ModuleID let value: AnyObject diff --git a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift index b50fc788..080d90fc 100644 --- a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift +++ b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift @@ -255,6 +255,19 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu set { sessions.onRunInTerminalRequest = newValue } } + /// Routes an integrated-terminal reverse request with its owning session. + public var onSessionRunInTerminalRequest: (( + DebugSessionID, + DebugRunInTerminalRequest, + @escaping DebugRunInTerminalCompletion + ) -> Void)? { + get { sessions.onSessionRunInTerminalRequest } + set { sessions.onSessionRunInTerminalRequest = newValue } + } + + /// Notifies the host when the visible debugger session changes. + public var onSessionSelectionChanged: ((DebugSessionID?) -> Void)? + private let sessions: DebugAdapterSessionManager private let breakpointPersistence: (any DebugBreakpointPersisting)? private let breakpointRelocator: (any DebugBreakpointRelocating)? @@ -415,6 +428,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu _ = sessions.select(sessionID: previousSessionID) activeSessionID = previousSessionID providerID = previousSnapshot.providerID + onSessionSelectionChanged?(previousSessionID) sessionSnapshots[previousSessionID] = previousSnapshot restoreSessionSnapshot( previousSessionID, @@ -436,6 +450,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu guard sessions.select(sessionID: sessionID) else { return false } activeSessionID = sessionID providerID = summary.providerID + onSessionSelectionChanged?(sessionID) restoreSessionSnapshot(sessionID, summary: summary) sessionSummaries = sessions.sessionSummaries resetInspectionState() @@ -552,6 +567,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu sessions.select(sessionID: replacement.id) { activeSessionID = replacement.id providerID = replacement.providerID + onSessionSelectionChanged?(replacement.id) restoreSessionSnapshot(replacement.id, summary: replacement) resetInspectionState() if state == .paused { @@ -565,6 +581,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu return } activeSessionID = nil + onSessionSelectionChanged?(nil) state = .idle stoppedReason = nil exceptionInfo = nil diff --git a/macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift b/macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift index c89634c6..20027d55 100644 --- a/macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift +++ b/macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift @@ -72,7 +72,20 @@ public final class DebugAdapterSessionManager: ObservableObject { public var onRunInTerminalRequest: DebugRunInTerminalRequestHandler? { didSet { for managed in sessions.values { - configureRunInTerminalHandler(managed.session) + configureRunInTerminalHandler(managed.session, sessionID: managed.id) + } + } + } + /// Session-aware reverse request routing used by hosts that own one + /// integrated terminal per debugger session. + public var onSessionRunInTerminalRequest: (( + DebugSessionID, + DebugRunInTerminalRequest, + @escaping DebugRunInTerminalCompletion + ) -> Void)? { + didSet { + for managed in sessions.values { + configureRunInTerminalHandler(managed.session, sessionID: managed.id) } } } @@ -374,7 +387,7 @@ public final class DebugAdapterSessionManager: ObservableObject { providerID: String, activationToken: UUID ) { - configureRunInTerminalHandler(session) + configureRunInTerminalHandler(session, sessionID: sessionID) guard let controlling = session as? any DebugAdapterControllingSession else { return } controlling.onStateChange = { [weak self] state in guard let self, @@ -480,8 +493,21 @@ public final class DebugAdapterSessionManager: ObservableObject { updateSessionSummary(sessionID) } - private func configureRunInTerminalHandler(_ session: any DebugAdapterSession) { + private func configureRunInTerminalHandler( + _ session: any DebugAdapterSession, + sessionID: DebugSessionID + ) { guard let session = session as? any DebugAdapterRunInTerminalSession else { return } - session.onRunInTerminalRequest = onRunInTerminalRequest + session.onRunInTerminalRequest = { [weak self] request, completion in + guard let self else { + completion(.failure(DebugAdapterProtocolError.stopped)) + return + } + if let onSessionRunInTerminalRequest { + onSessionRunInTerminalRequest(sessionID, request, completion) + } else { + onRunInTerminalRequest?(request, completion) + } + } } } diff --git a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift index 1d28ebbf..568bee58 100644 --- a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift +++ b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift @@ -117,6 +117,44 @@ struct DebugModuleTests { #expect(manager.states["java"] == .idle) } + @Test + func sessionAwareRunInTerminalRequestsKeepTheirOwningSessionIdentity() throws { + let descriptor = DebugProviderDescriptor( + id: "java", + displayName: "Java", + fileExtensions: ["java"] + ) + var createdSessions: [DeferredInspectionDebugSession] = [] + let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in + let session = DeferredInspectionDebugSession() + createdSessions.append(session) + return session + } + var requests: [(DebugSessionID, DebugRunInTerminalRequest)] = [] + manager.onSessionRunInTerminalRequest = { sessionID, request, completion in + requests.append((sessionID, request)) + completion(.success(DebugRunInTerminalResponse(processID: 42))) + } + let root = URL(fileURLWithPath: "/tmp/java-debug-terminal-routing", isDirectory: true) + let source = root.appendingPathComponent("src/Main.java") + let first = try manager.activateNew(for: source, rootURL: root) + let second = try manager.activateNew(for: source, rootURL: root) + let request = DebugRunInTerminalRequest( + kind: .integrated, + title: "Second", + cwd: root.path, + args: ["/usr/bin/java", "example.Second"], + environment: [], + argsCanBeInterpretedByShell: false + ) + + createdSessions[0].emitRunInTerminalRequest(request) + createdSessions[1].emitRunInTerminalRequest(request) + + #expect(requests.map(\.0) == [first.id, second.id]) + manager.stopAll() + } + @Test func featureSwitchesSessionsWithoutMixingTheirConsoleState() throws { let descriptor = DebugProviderDescriptor( @@ -2612,7 +2650,7 @@ private struct RecordingDebugVariablePageRequest: Equatable { } @MainActor -private final class DeferredInspectionDebugSession: DebugAdapterControllingSession { +private final class DeferredInspectionDebugSession: DebugAdapterControllingSession, DebugAdapterRunInTerminalSession { let capabilities: DebugAdapterCapabilities private(set) var isRunning = false private(set) var state: DebugAdapterState = .idle @@ -2622,6 +2660,7 @@ private final class DeferredInspectionDebugSession: DebugAdapterControllingSessi var failNextLaunch = false var onStateChange: ((DebugAdapterState) -> Void)? var onEvent: ((DebugAdapterEvent) -> Void)? + var onRunInTerminalRequest: DebugRunInTerminalRequestHandler? private var stackTraceRequests: [( threadID: Int, @@ -2753,6 +2792,10 @@ private final class DeferredInspectionDebugSession: DebugAdapterControllingSessi onEvent?(event) } + func emitRunInTerminalRequest(_ request: DebugRunInTerminalRequest) { + onRunInTerminalRequest?(request) { _ in } + } + func completeStackTrace(at index: Int, with frames: [DebugStackFrame]) { stackTraceRequests[index].completion(.success(frames)) } From 8dc758e8b8d702096d4bfe7fc66332487646aa48 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sun, 30 Aug 2026 13:10:36 +0800 Subject: [PATCH 50/66] fix(mac-debug): clean terminals when sessions stop --- .../Lithe/Models/AppModel/AppModel+ExecutionModules.swift | 3 +++ .../Application/GenericDebugFeatureModel.swift | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift index 60b2f86a..c70181e3 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift @@ -87,6 +87,9 @@ extension AppModel { self.activeDebugTerminalSessionIDsByDebugSession[$0] } } + feature.onSessionStopped = { [weak self] debugSessionID in + self?.stopDebugTerminalProcesses(for: debugSessionID) + } feature.onSessionRunInTerminalRequest = { [weak self] debugSessionID, request, completion in guard let self else { completion(.failure(DebugAdapterCapabilityError.unsupported("run in terminal"))) diff --git a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift index 080d90fc..65d7447b 100644 --- a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift +++ b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift @@ -267,6 +267,9 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu /// Notifies the host when the visible debugger session changes. public var onSessionSelectionChanged: ((DebugSessionID?) -> Void)? + /// Notifies the host after a debugger session has been stopped and its + /// adapter resources have been released. + public var onSessionStopped: ((DebugSessionID) -> Void)? private let sessions: DebugAdapterSessionManager private let breakpointPersistence: (any DebugBreakpointPersisting)? @@ -475,6 +478,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu sessions.stop(sessionID: sessionID) sessionSnapshots[sessionID] = nil sessionSummaries = sessions.sessionSummaries + onSessionStopped?(sessionID) } private func startSession( @@ -561,6 +565,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu if let activeSessionID { sessions.stop(sessionID: activeSessionID) sessionSnapshots[activeSessionID] = nil + onSessionStopped?(activeSessionID) } sessionSummaries = sessions.sessionSummaries if let replacement = sessionSummaries.last, From 5d4a63375ac5489eb7c81902a8197ff413ea1316 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sun, 30 Aug 2026 13:16:54 +0800 Subject: [PATCH 51/66] feat(mac-debug): add console expression history --- .../Lithe/Views/Debug/GenericDebugView.swift | 20 +++++++ .../GenericDebugFeatureModel.swift | 60 +++++++++++++++++++ .../DebugModuleTests.swift | 45 ++++++++++++++ 3 files changed, 125 insertions(+) diff --git a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift index 147a65e7..1ccdabc0 100644 --- a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift +++ b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift @@ -814,6 +814,16 @@ struct GenericDebugView: View { Image(systemName: "chevron.right") .font(.system(size: 10, weight: .semibold)) .foregroundStyle(LitheTheme.accent) + Button { + if let expression = feature.previousConsoleExpression(current: consoleExpression) { + consoleExpression = expression + } + } label: { + Image(systemName: "chevron.up") + } + .litheIconButton() + .disabled(feature.consoleHistory.isEmpty || feature.state != .paused) + .help("Previous console expression") TextField("Evaluate expression while paused", text: $consoleExpression) .textFieldStyle(.plain) .font(.system(size: 11.5, design: .monospaced)) @@ -826,6 +836,16 @@ struct GenericDebugView: View { .litheIconButton() .disabled(feature.state != .paused || consoleExpression.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) .help("Evaluate expression") + Button { + if let expression = feature.nextConsoleExpression() { + consoleExpression = expression + } + } label: { + Image(systemName: "chevron.down") + } + .litheIconButton() + .disabled(feature.consoleHistory.isEmpty || feature.state != .paused) + .help("Next console expression") } .padding(.horizontal, 10) .frame(height: 34) diff --git a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift index 65d7447b..777e5193 100644 --- a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift +++ b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift @@ -243,6 +243,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu /// the frame that initially caused the stop. @Published public private(set) var selectedFrame: DebugStackFrame? @Published public private(set) var javaSteppingFilters: DebugSteppingFilters? + @Published public private(set) var consoleHistory: [String] = [] @Published public private(set) var areFilteredStackFramesExpanded = false /// Delivers the selected stopped frame to the host editor for source @@ -281,6 +282,10 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu private var activeFileURL: URL? private var lastStartRequest: GenericDebugStartRequest? private var sessionSnapshots: [DebugSessionID: GenericDebugSessionSnapshot] = [:] + private var consoleHistoryBySession: [DebugSessionID: [String]] = [:] + private var consoleHistoryCursorBySession: [DebugSessionID: Int] = [:] + private var consoleHistoryDraftBySession: [DebugSessionID: String] = [:] + private let maximumConsoleHistoryEntries = 100 private let maximumOutputCharacters = 400_000 private let variablePageSize = 100 private var watchGeneration = 0 @@ -454,6 +459,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu activeSessionID = sessionID providerID = summary.providerID onSessionSelectionChanged?(sessionID) + publishConsoleHistory(for: sessionID) restoreSessionSnapshot(sessionID, summary: summary) sessionSummaries = sessions.sessionSummaries resetInspectionState() @@ -536,6 +542,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu activeSessionID = launched.id state = launched.session.state sessionSummaries = sessions.sessionSummaries + publishConsoleHistory(for: launched.id) saveActiveSessionSnapshot() return true } catch { @@ -573,6 +580,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu activeSessionID = replacement.id providerID = replacement.providerID onSessionSelectionChanged?(replacement.id) + publishConsoleHistory(for: replacement.id) restoreSessionSnapshot(replacement.id, summary: replacement) resetInspectionState() if state == .paused { @@ -587,6 +595,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } activeSessionID = nil onSessionSelectionChanged?(nil) + consoleHistory = [] state = .idle stoppedReason = nil exceptionInfo = nil @@ -611,6 +620,10 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu sessionSnapshots.removeAll() activeSessionID = nil sessionSummaries = [] + consoleHistory = [] + consoleHistoryBySession.removeAll() + consoleHistoryCursorBySession.removeAll() + consoleHistoryDraftBySession.removeAll() state = .idle stoppedReason = nil exceptionInfo = nil @@ -1321,6 +1334,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu public func evaluate(_ expression: String) { let value = expression.trimmingCharacters(in: .whitespacesAndNewlines) guard !value.isEmpty, let session = activeSession else { return } + recordConsoleExpression(value) session.evaluate(value, frameID: selectedFrameID) { [weak self] result in switch result { case .success(let variable): @@ -1330,6 +1344,52 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } } + /// Returns the previous expression for the active session's console. + public func previousConsoleExpression(current: String) -> String? { + guard let activeSessionID else { return nil } + let history = consoleHistoryBySession[activeSessionID] ?? [] + guard !history.isEmpty else { return nil } + let cursor = consoleHistoryCursorBySession[activeSessionID] ?? history.count + if cursor == history.count, !current.isEmpty { + consoleHistoryDraftBySession[activeSessionID] = current + } + let next = max(0, cursor - 1) + consoleHistoryCursorBySession[activeSessionID] = next + return history[next] + } + + /// Returns the next expression for the active session's console. + public func nextConsoleExpression() -> String? { + guard let activeSessionID else { return nil } + let history = consoleHistoryBySession[activeSessionID] ?? [] + guard !history.isEmpty else { return nil } + let cursor = consoleHistoryCursorBySession[activeSessionID] ?? history.count + let next = min(history.count, cursor + 1) + consoleHistoryCursorBySession[activeSessionID] = next + if next < history.count { return history[next] } + return consoleHistoryDraftBySession[activeSessionID] ?? "" + } + + private func recordConsoleExpression(_ expression: String) { + guard let activeSessionID else { return } + var history = consoleHistoryBySession[activeSessionID] ?? [] + history.removeAll { $0 == expression } + history.append(expression) + if history.count > maximumConsoleHistoryEntries { + history.removeFirst(history.count - maximumConsoleHistoryEntries) + } + consoleHistoryBySession[activeSessionID] = history + consoleHistoryCursorBySession[activeSessionID] = history.count + consoleHistoryDraftBySession[activeSessionID] = nil + consoleHistory = history + } + + private func publishConsoleHistory(for sessionID: DebugSessionID) { + consoleHistory = consoleHistoryBySession[sessionID] ?? [] + consoleHistoryCursorBySession[sessionID] = consoleHistory.count + consoleHistoryDraftBySession[sessionID] = nil + } + public func evaluateForHover( _ expression: String, completion: @escaping (DebugVariable?) -> Void diff --git a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift index 568bee58..cd0f75d4 100644 --- a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift +++ b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift @@ -155,6 +155,51 @@ struct DebugModuleTests { manager.stopAll() } + @Test + func consoleHistoryIsBoundedDeduplicatedAndScopedToTheSelectedSession() throws { + let descriptor = DebugProviderDescriptor( + id: "java", + displayName: "Java", + fileExtensions: ["java"] + ) + var createdSessions: [DeferredInspectionDebugSession] = [] + let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in + let session = DeferredInspectionDebugSession() + createdSessions.append(session) + return session + } + let feature = GenericDebugFeatureModel(sessions: manager) + let root = URL(fileURLWithPath: "/tmp/java-debug-console-history", isDirectory: true) + let source = root.appendingPathComponent("src/Main.java") + let configuration = DebugLaunchConfiguration( + name: "Main", + request: .launch, + arguments: ["mainClass": .string("example.Main")] + ) + + #expect(feature.start(fileURL: source, rootURL: root, configuration: configuration)) + for index in 0..<105 { + feature.evaluate("value\(index)") + } + feature.evaluate("value104") + + #expect(feature.consoleHistory.count == 100) + #expect(feature.consoleHistory.first == "value5") + #expect(feature.consoleHistory.last == "value104") + #expect(feature.previousConsoleExpression(current: "") == "value104") + #expect(feature.previousConsoleExpression(current: "value104") == "value103") + #expect(feature.nextConsoleExpression() == "value104") + #expect(feature.nextConsoleExpression() == "") + + #expect(feature.startAdditional( + fileURL: source, + rootURL: root, + configuration: configuration + )) + #expect(feature.consoleHistory.isEmpty) + feature.stop() + } + @Test func featureSwitchesSessionsWithoutMixingTheirConsoleState() throws { let descriptor = DebugProviderDescriptor( From b34ccda3ff3e99e697a6a89de3b0e37592a25665 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sun, 30 Aug 2026 13:24:16 +0800 Subject: [PATCH 52/66] feat(macos-debug): add scope inspection and variable copy actions --- .../Lithe/Views/Debug/GenericDebugView.swift | 45 +++++++++++++++++++ .../GenericDebugFeatureModel.swift | 32 ++++++++++--- .../DebugModuleTests.swift | 10 +++++ 3 files changed, 80 insertions(+), 7 deletions(-) diff --git a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift index 1ccdabc0..880cb122 100644 --- a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift +++ b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppKit import LitheCoreContracts import LitheDebugModule @@ -529,6 +530,10 @@ struct GenericDebugView: View { exceptionInspector(exceptionInfo) divider } + if !feature.scopes.isEmpty { + scopePicker + divider + } sectionHeader("Variables", count: feature.variables.count) if feature.visibleVariableRows.isEmpty { placeholder("Select a stack frame to inspect variables") @@ -568,6 +573,12 @@ struct GenericDebugView: View { feature.requestDataBreakpoint(for: variable) } } + Divider() + Button("Copy Value") { copyToPasteboard(variable.value) } + Button("Copy Expression") { + copyToPasteboard(variable.evaluateName ?? variable.name) + } + Button("Copy Name") { copyToPasteboard(variable.name) } } case .loadMore(let parentVariableID, let nextCount, let remainingCount): variableLoadMoreRow( @@ -641,6 +652,40 @@ struct GenericDebugView: View { .litheWorkbenchSurface(LitheTheme.sidebar) } + private var scopePicker: some View { + VStack(alignment: .leading, spacing: 0) { + sectionHeader("Scopes", count: feature.scopes.count) + ForEach(feature.scopes) { scope in + Button { + feature.selectScope(scope) + } label: { + HStack(spacing: 7) { + Image(systemName: feature.selectedScopeID == scope.id ? "circle.inset.filled" : "circle") + .font(.system(size: 9)) + Text(scope.name) + .font(.system(size: 10.5)) + if scope.expensive { + Text("expensive") + .font(.system(size: 9)) + .foregroundStyle(LitheTheme.secondaryText) + } + Spacer(minLength: 0) + } + .foregroundStyle(feature.selectedScopeID == scope.id ? LitheTheme.accent : LitheTheme.primaryText) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + } + + private func copyToPasteboard(_ value: String) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(value, forType: .string) + } + private func exceptionInspector(_ info: DebugExceptionInfo) -> some View { VStack(alignment: .leading, spacing: 7) { HStack(spacing: 7) { diff --git a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift index 777e5193..48c770db 100644 --- a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift +++ b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift @@ -227,6 +227,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu @Published public private(set) var threads: [DebugThread] = [] @Published public private(set) var stackFrames: [DebugStackFrame] = [] @Published public private(set) var scopes: [DebugScope] = [] + @Published public private(set) var selectedScopeID: Int? @Published public private(set) var variables: [DebugVariable] = [] @Published public private(set) var variableChildren: [String: [DebugVariable]] = [:] @Published public private(set) var expandedVariableIDs: Set = [] @@ -509,6 +510,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu threads = [] stackFrames = [] scopes = [] + selectedScopeID = nil resetVariableTree() invalidateWatchResults() capabilities = .unknown @@ -607,6 +609,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu stackFrames = [] areFilteredStackFramesExpanded = false scopes = [] + selectedScopeID = nil resetVariableTree() invalidateWatchResults() capabilities = .unknown @@ -635,6 +638,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu stackFrames = [] areFilteredStackFramesExpanded = false scopes = [] + selectedScopeID = nil resetVariableTree() invalidateWatchResults() capabilities = .unknown @@ -1119,6 +1123,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu stackFrames = [] areFilteredStackFramesExpanded = false scopes = [] + selectedScopeID = nil resetVariableTree() invalidateWatchResults() guard let session = activeSession else { return } @@ -1150,6 +1155,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu selectedFrameID = frame.id selectedFrame = frame scopes = [] + selectedScopeID = nil resetVariableTree() invalidateWatchResults() publishStoppedLocation(frame) @@ -1163,13 +1169,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu case .success(let scopes): self.scopes = scopes if let scope = scopes.first(where: { !$0.expensive }) ?? scopes.first { - self.loadVariables( - reference: scope.variablesReference, - namedVariables: scope.namedVariables, - indexedVariables: scope.indexedVariables, - frameID: frame.id, - generation: generation - ) + self.selectScope(scope, frameID: frame.id, generation: generation) } else { self.resetVariableTree() } @@ -1178,6 +1178,24 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } } + /// Selects the stack-frame scope whose variables are shown in the inspector. + public func selectScope(_ scope: DebugScope) { + guard let frameID = selectedFrameID else { return } + selectScope(scope, frameID: frameID, generation: inspectionGeneration) + } + + private func selectScope(_ scope: DebugScope, frameID: Int, generation: Int) { + guard selectedFrameID == frameID, inspectionGeneration == generation else { return } + selectedScopeID = scope.id + loadVariables( + reference: scope.variablesReference, + namedVariables: scope.namedVariables, + indexedVariables: scope.indexedVariables, + frameID: frameID, + generation: generation + ) + } + public func loadVariables(reference: Int) { loadVariables( reference: reference, diff --git a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift index cd0f75d4..9bdf674b 100644 --- a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift +++ b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift @@ -861,6 +861,10 @@ struct DebugModuleTests { "name": "Locals", "variablesReference": 200, "expensive": false + ], [ + "name": "Fields", + "variablesReference": 201, + "expensive": true ]] ] ]]) @@ -869,6 +873,12 @@ struct DebugModuleTests { "exceptionInfo", "threads", "stackTrace", "scopes", "variables" ]) #expect(core.inspectionRequests.last?.variablesReference == 200) + #expect(feature.selectedScopeID == feature.scopes.first?.id) + + let fieldsScope = try #require(feature.scopes.last) + feature.selectScope(fieldsScope) + #expect(core.inspectionRequests.last?.kind == "variables") + #expect(core.inspectionRequests.last?.variablesReference == 201) let variablesOperationID = try #require( core.inspectionRequests.first(where: { $0.kind == "variables" })?.operationID From 4960c8b8bd8d895f43f816f5d6dfe5833221c386 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sun, 30 Aug 2026 13:27:15 +0800 Subject: [PATCH 53/66] feat(macos-debug): enrich variable inspection actions --- .../Lithe/Views/Debug/GenericDebugView.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift index 880cb122..b3ded245 100644 --- a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift +++ b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift @@ -553,6 +553,12 @@ struct GenericDebugView: View { .font(.system(size: 10.5, design: .monospaced)) .foregroundStyle(LitheTheme.accent) .lineLimit(2) + if let type = variable.type, !type.isEmpty { + Text(": (type)") + .font(.system(size: 9.5, design: .monospaced)) + .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(1) + } Spacer(minLength: 0) } .contentShape(Rectangle()) @@ -579,6 +585,13 @@ struct GenericDebugView: View { copyToPasteboard(variable.evaluateName ?? variable.name) } Button("Copy Name") { copyToPasteboard(variable.name) } + if let expression = variable.evaluateName, + !expression.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Divider() + Button("Add to Watches") { + feature.addWatch(expression) + } + } } case .loadMore(let parentVariableID, let nextCount, let remainingCount): variableLoadMoreRow( From c5b688c2a527029c5b38eb6a91777cba2100ccef Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sun, 30 Aug 2026 13:32:24 +0800 Subject: [PATCH 54/66] feat(macos-debug): show stopped thread state in inspector --- .../Lithe/Views/Debug/GenericDebugView.swift | 18 ++++++++++++++- .../GenericDebugFeatureModel.swift | 22 ++++++++++++++++++- .../DebugModuleTests.swift | 9 ++++++++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift index b3ded245..93cb109d 100644 --- a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift +++ b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift @@ -438,7 +438,8 @@ struct GenericDebugView: View { rowButton(selected: feature.selectedThreadID == thread.id) { feature.selectThread(thread) } label: { - Image(systemName: "circle") + Image(systemName: threadIcon(thread)) + .foregroundStyle(threadColor(thread)) Text(thread.name).lineLimit(1) } .contextMenu { @@ -999,6 +1000,21 @@ struct GenericDebugView: View { return feature.isVariableExpanded(variable) ? "chevron.down" : "chevron.right" } + private func threadIcon(_ thread: DebugThread) -> String { + if feature.stoppedThreadIDs.contains(thread.id) { + return feature.selectedThreadID == thread.id + ? "pause.circle.fill" + : "pause.circle" + } + return "play.circle" + } + + private func threadColor(_ thread: DebugThread) -> Color { + feature.stoppedThreadIDs.contains(thread.id) + ? LitheTheme.warning + : LitheTheme.secondaryText + } + private func variableLoadMoreRow( parentVariableID: String?, nextCount: Int, diff --git a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift index 48c770db..c671a50e 100644 --- a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift +++ b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift @@ -225,6 +225,10 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu @Published public private(set) var functionBreakpoints: [GenericDebugFunctionBreakpoint] = [] @Published public private(set) var dataBreakpoints: [GenericDebugDataBreakpoint] = [] @Published public private(set) var threads: [DebugThread] = [] + /// Thread IDs reported stopped by the adapter while the debuggee is paused. + /// A missing set means the adapter stopped all threads or did not provide a + /// thread ID, so the UI should show the session-level paused state instead. + @Published public private(set) var stoppedThreadIDs: Set = [] @Published public private(set) var stackFrames: [DebugStackFrame] = [] @Published public private(set) var scopes: [DebugScope] = [] @Published public private(set) var selectedScopeID: Int? @@ -508,6 +512,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu stoppedReason = nil exceptionInfo = nil threads = [] + stoppedThreadIDs = [] stackFrames = [] scopes = [] selectedScopeID = nil @@ -606,6 +611,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu stoppedFrame = nil selectedFrame = nil threads = [] + stoppedThreadIDs = [] stackFrames = [] areFilteredStackFramesExpanded = false scopes = [] @@ -635,6 +641,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu stoppedFrame = nil selectedFrame = nil threads = [] + stoppedThreadIDs = [] stackFrames = [] areFilteredStackFramesExpanded = false scopes = [] @@ -1453,6 +1460,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu stoppedFrame = nil selectedFrame = nil threads = [] + stoppedThreadIDs = [] stackFrames = [] areFilteredStackFramesExpanded = false scopes = [] @@ -1630,6 +1638,11 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu append(text) case .stopped(let reason, let threadID, let description): let generation = beginInspectionTransition() + if let threadID { + stoppedThreadIDs.insert(threadID) + } else { + stoppedThreadIDs = Set(threads.map(\.id)) + } stoppedReason = description ?? reason exceptionInfo = nil selectedThreadID = threadID @@ -1649,7 +1662,12 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu generation: generation, shouldLoadExceptionInfo: reason == "exception" && threadID == nil ) - case .continued: + case .continued(let threadID): + if let threadID { + stoppedThreadIDs.remove(threadID) + } else { + stoppedThreadIDs = [] + } invalidateInspectionRequests() stoppedReason = nil exceptionInfo = nil @@ -1658,6 +1676,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu stoppedFrame = nil selectedFrame = nil threads = [] + stoppedThreadIDs = [] stackFrames = [] areFilteredStackFramesExpanded = false scopes = [] @@ -1672,6 +1691,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu stoppedFrame = nil selectedFrame = nil threads = [] + stoppedThreadIDs = [] stackFrames = [] areFilteredStackFramesExpanded = false scopes = [] diff --git a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift index 9bdf674b..d1c51a40 100644 --- a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift +++ b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift @@ -900,6 +900,7 @@ struct DebugModuleTests { transport.emitData(Data("variables-response".utf8)) #expect(feature.selectedThreadID == 13) + #expect(feature.stoppedThreadIDs == [13]) #expect(feature.threads.map(\.id) == [2, 13]) #expect(feature.selectedFrame?.id == 70) #expect(feature.selectedFrame?.isFiltered == false) @@ -927,6 +928,14 @@ struct DebugModuleTests { ]]) transport.emitData(Data("late-exception-response".utf8)) #expect(feature.exceptionInfo?.exceptionID == "java.lang.IllegalStateException") + + core.enqueueReceive(sessionID: "java-stopped-context", state: "running", events: [[ + "sequence": 7, + "type": "continued", + "threadId": 13 + ]]) + transport.emitData(Data("continued-event".utf8)) + #expect(feature.stoppedThreadIDs.isEmpty) } @Test From ab70349a4921ad6a19286392c3fd67c6faa23739 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sun, 30 Aug 2026 13:34:53 +0800 Subject: [PATCH 55/66] feat(macos-debug): improve thread and stack inspection actions --- .../Lithe/Views/Debug/GenericDebugView.swift | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift index 93cb109d..9cb95d2d 100644 --- a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift +++ b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift @@ -443,7 +443,11 @@ struct GenericDebugView: View { Text(thread.name).lineLimit(1) } .contextMenu { + Button("Copy Thread Name") { + copyToPasteboard(thread.name) + } if feature.capabilities.supportsSingleThreadExecutionRequests { + Divider() Button(feature.state == .paused ? "Resume Thread" : "Pause Thread") { feature.executeThread( feature.state == .paused ? .continueExecution : .pause, @@ -487,7 +491,16 @@ struct GenericDebugView: View { ) } } label: { - Image(systemName: frame.isFiltered ? "ellipsis" : "chevron.right") + Image(systemName: frame.isFiltered + ? "ellipsis" + : feature.selectedFrameID == frame.id + ? "pause.fill" + : "chevron.right") + .foregroundStyle( + feature.selectedFrameID == frame.id + ? LitheTheme.warning + : LitheTheme.secondaryText + ) VStack(alignment: .leading, spacing: 1) { Text(frame.name).lineLimit(1) if let sourceURL = frame.sourceURL { @@ -498,6 +511,24 @@ struct GenericDebugView: View { } } .opacity(frame.isFiltered ? 0.58 : 1) + .contextMenu { + Button("Copy Method Name") { + copyToPasteboard(frame.name) + } + if let sourceURL = frame.sourceURL { + Divider() + Button("Copy Source Location") { + copyToPasteboard( + "\(sourceURL.path):\(frame.line):\(frame.column)" + ) + } + Button("Copy Relative Location") { + copyToPasteboard( + "\(sourceURL.lastPathComponent):\(frame.line):\(frame.column)" + ) + } + } + } } else { Button { feature.expandFilteredStackFrames() From 83c4eaa0a920484c783b51aa76f374bfb8b4445d Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sun, 30 Aug 2026 14:56:08 +0800 Subject: [PATCH 56/66] feat(macos-debug): preview executable line breakpoints --- .../Lithe/Views/Editor/CodeEditorView.swift | 89 ++++++++++++++++--- 1 file changed, 77 insertions(+), 12 deletions(-) diff --git a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift index 68962653..5906b897 100644 --- a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift +++ b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift @@ -1,6 +1,7 @@ import AppKit import SwiftUI import LitheGitModule +import LitheDebugModule struct CodeEditorPalette { private static let propertyRGB: (red: CGFloat, green: CGFloat, blue: CGFloat) = (79, 148, 250) @@ -1469,6 +1470,14 @@ struct CodeEditorView: NSViewRepresentable { line: EditorDebugBreakpointLocation.productLine(forEditorLine: line) ) }, + canAdd: { [weak textView, fileExtension] line in + guard fileExtension.lowercased() == "java", + let textView else { return false } + return DebugBreakpointLocationValidator.isExecutableJavaLine( + source: textView.string, + line: EditorDebugBreakpointLocation.productLine(forEditorLine: line) + ) + }, onEdit: { [weak model] line in model?.editDebugBreakpoint( fileURL: url, @@ -3502,6 +3511,7 @@ final class LineNumberGutterView: NSView { private var onSetDebugBreakpointEnabled: ((Int, Bool) -> Void)? private var onToggleAllDebugBreakpoints: (() -> Void)? private var onRunToCursor: ((Int) -> Void)? + private var canAddDebugBreakpoint: ((Int) -> Bool)? private var isRunToCursorEnabled = false private var areBreakpointsMuted = false private var contextGutterLine: Int? @@ -3511,6 +3521,7 @@ final class LineNumberGutterView: NSView { private var foldIndicatorOpacities: [String: CGFloat] = [:] private var foldIndicatorAnimationTimer: Timer? private var trackingArea: NSTrackingArea? + private var hoveredDebugBreakpointLine: Int? private var palette = CodeEditorPalette.dark private var gitLineChangeMarkersByLine: [Int: GitLineChangeMarker] = [:] private var onShowGitLineChange: ((GitLineChangeMarker) -> Void)? @@ -3663,6 +3674,7 @@ final class LineNumberGutterView: NSView { func updateDebugBreakpointLines( _ states: [Int: EditorDebugBreakpointState], onToggle: @escaping (Int) -> Void, + canAdd: ((Int) -> Bool)? = nil, onEdit: ((Int) -> Void)? = nil, onRemove: ((Int) -> Void)? = nil, onSetEnabled: ((Int, Bool) -> Void)? = nil, @@ -3676,6 +3688,7 @@ final class LineNumberGutterView: NSView { uniqueKeysWithValues: states.map { (max(0, $0.key - 1), $0.value) } ) onToggleDebugBreakpoint = onToggle + canAddDebugBreakpoint = canAdd onEditDebugBreakpoint = onEdit onRemoveDebugBreakpoint = onRemove onSetDebugBreakpointEnabled = onSetEnabled @@ -3914,6 +3927,10 @@ final class LineNumberGutterView: NSView { } if !isBlameVisible, let state = debugBreakpointStatesByLine[lineNumber - 1] { drawDebugBreakpoint(y: y, height: lineRect.height, state: state) + } else if !isBlameVisible, + hoveredDebugBreakpointLine == lineNumber - 1, + canAddDebugBreakpoint?(lineNumber - 1) == true { + drawDebugBreakpointHover(y: y, height: lineRect.height) } else { let markers = implementationMarkers.filter { $0.line == lineNumber - 1 } for marker in markers { @@ -4088,7 +4105,7 @@ final class LineNumberGutterView: NSView { height: CGFloat, state: EditorDebugBreakpointState ) { - let markerSize: CGFloat = 9 + let markerSize: CGFloat = 10 let path = NSBezierPath( ovalIn: NSRect( x: editorGutterOriginX + gutterLayout.breakpointRange.lowerBound @@ -4108,6 +4125,19 @@ final class LineNumberGutterView: NSView { } } + private func drawDebugBreakpointHover(y: CGFloat, height: CGFloat) { + let markerSize: CGFloat = 10 + let rect = NSRect( + x: editorGutterOriginX + gutterLayout.breakpointRange.lowerBound + + (EditorGutterLayout.width(of: gutterLayout.breakpointRange) - markerSize) / 2, + y: y + max(0, (height - markerSize) / 2), + width: markerSize, + height: markerSize + ) + NSColor(calibratedRed: 0.92, green: 0.28, blue: 0.30, alpha: 0.55).setFill() + NSBezierPath(ovalIn: rect).fill() + } + private func drawCurrentExecutionLine(y: CGFloat, height: CGFloat) { let markerSize: CGFloat = 10 let centerY = y + height / 2 @@ -4183,8 +4213,9 @@ final class LineNumberGutterView: NSView { super.mouseMoved(with: event) let point = convert(event.locationInWindow, from: nil) updateFoldHover(at: point) + updateBreakpointHover(at: point) updateBreakpointToolTip(at: point) - if foldRegion(at: point) != nil { + if foldRegion(at: point) != nil || isBreakpointTarget(at: point) { NSCursor.pointingHand.set() } } @@ -4195,31 +4226,64 @@ final class LineNumberGutterView: NSView { NSCursor.pointingHand.set() return } + if isBreakpointTarget(at: point) { + NSCursor.pointingHand.set() + return + } super.cursorUpdate(with: event) } override func mouseExited(with event: NSEvent) { super.mouseExited(with: event) updateFoldHover(at: nil) + updateBreakpointHover(at: nil) toolTip = nil } + private func updateBreakpointHover(at point: NSPoint?) { + let nextLine = point.flatMap { point -> Int? in + guard !isBlameVisible, + isBreakpointTarget(at: point), + let line = editorLine(at: point), + canAddDebugBreakpoint?(line) == true else { return nil } + return line + } + guard hoveredDebugBreakpointLine != nextLine else { return } + hoveredDebugBreakpointLine = nextLine + needsDisplay = true + } + + private func isBreakpointTarget(at point: NSPoint) -> Bool { + let localX = point.x - editorGutterOriginX + guard gutterLayout.breakpointRange.contains(localX), + let line = editorLine(at: point) else { return false } + if debugBreakpointStatesByLine[line] != nil { return true } + return canAddDebugBreakpoint?(line) == true + } + private func updateBreakpointToolTip(at point: NSPoint) { let localX = point.x - editorGutterOriginX guard gutterLayout.breakpointRange.contains(localX), - let line = editorLine(at: point), - let state = debugBreakpointStatesByLine[line] else { + let line = editorLine(at: point) else { toolTip = nil return } - let stateLabel: String - if !state.enabled { - stateLabel = "Breakpoint disabled" - } else { - stateLabel = state.verified ? "Breakpoint verified" : "Breakpoint not verified" + if let state = debugBreakpointStatesByLine[line] { + let stateLabel: String + if !state.enabled { + stateLabel = "Breakpoint disabled" + } else { + stateLabel = state.verified ? "Breakpoint verified" : "Breakpoint not verified" + } + let detail = debugBreakpointMessagesByLine[line].map { " — \($0)" } ?? "" + toolTip = "Line \(line + 1): \(stateLabel)\(detail)" + return + } + guard canAddDebugBreakpoint?(line) == true else { + toolTip = nil + return } - let detail = debugBreakpointMessagesByLine[line].map { " — \($0)" } ?? "" - toolTip = "Line \(line + 1): \(stateLabel)\(detail)" + toolTip = "Line \(line + 1): Click to set breakpoint" } private func updateFoldHover(at point: NSPoint?) { @@ -4335,7 +4399,8 @@ final class LineNumberGutterView: NSView { guard let marker = markers.first(where: { $0.direction == preferredDirection }) ?? markers.first else { return } onSelectImplementation?(marker) - case .breakpoint where !isBlameVisible: + case .breakpoint where !isBlameVisible + && (debugBreakpointStatesByLine[line] != nil || canAddDebugBreakpoint?(line) == true): onToggleDebugBreakpoint?(line) case .lineNumber, .breakpoint, nil: textView.window?.makeFirstResponder(textView) From a96be1935699815004cb289a19b04bdabcde21b6 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Mon, 31 Aug 2026 09:14:38 +0800 Subject: [PATCH 57/66] feat(macos-debug): complete debug workflow polish --- AGENTS.md | 12 + .../IDEAIcons/debugger/attachToProcess.svg | 7 + .../debugger/attachToProcess_dark.svg | 7 + .../debugger/db_disabled_breakpoint.svg | 9 + .../debugger/db_disabled_breakpoint_dark.svg | 9 + .../debugger/db_muted_breakpoint.svg | 9 + .../debugger/db_muted_breakpoint_dark.svg | 9 + .../IDEAIcons/debugger/db_set_breakpoint.svg | 9 + .../debugger/db_set_breakpoint_dark.svg | 9 + .../debugger/db_verified_breakpoint.svg | 7 + .../debugger/db_verified_breakpoint_dark.svg | 7 + macos/Resources/IDEAIcons/debugger/debug.svg | 6 + .../IDEAIcons/debugger/debug_dark.svg | 6 + .../IDEAIcons/debugger/evaluateExpression.svg | 11 + .../debugger/evaluateExpression_dark.svg | 11 + macos/Resources/IDEAIcons/debugger/frame.svg | 7 + .../IDEAIcons/debugger/frame_dark.svg | 7 + .../IDEAIcons/debugger/muteBreakpoints.svg | 6 + .../debugger/muteBreakpoints_dark.svg | 6 + macos/Resources/IDEAIcons/debugger/pause.svg | 5 + .../IDEAIcons/debugger/pause_dark.svg | 5 + macos/Resources/IDEAIcons/debugger/rerun.svg | 6 + .../IDEAIcons/debugger/rerun_dark.svg | 6 + .../IDEAIcons/debugger/restartDebug.svg | 14 + .../IDEAIcons/debugger/restartDebug_dark.svg | 19 + macos/Resources/IDEAIcons/debugger/resume.svg | 5 + .../IDEAIcons/debugger/resume_dark.svg | 5 + macos/Resources/IDEAIcons/debugger/run.svg | 4 + .../IDEAIcons/debugger/runToCursor.svg | 10 + .../IDEAIcons/debugger/runToCursor_dark.svg | 10 + .../Resources/IDEAIcons/debugger/run_dark.svg | 4 + .../IDEAIcons/debugger/smartStepInto.svg | 8 + .../IDEAIcons/debugger/smartStepInto_dark.svg | 8 + .../Resources/IDEAIcons/debugger/stepInto.svg | 5 + .../IDEAIcons/debugger/stepInto_dark.svg | 5 + .../Resources/IDEAIcons/debugger/stepOut.svg | 5 + .../IDEAIcons/debugger/stepOut_dark.svg | 5 + .../Resources/IDEAIcons/debugger/stepOver.svg | 5 + .../IDEAIcons/debugger/stepOver_dark.svg | 5 + macos/Resources/IDEAIcons/debugger/stop.svg | 4 + .../IDEAIcons/debugger/stop_dark.svg | 4 + .../IDEAIcons/debugger/threadAtBreakpoint.svg | 4 + .../IDEAIcons/debugger/threadCurrent.svg | 4 + .../IDEAIcons/debugger/threadFrozen.svg | 7 + .../IDEAIcons/debugger/threadRunning.svg | 4 + .../IDEAIcons/debugger/threadSuspended.svg | 9 + .../Resources/IDEAIcons/debugger/threads.svg | 9 + .../IDEAIcons/debugger/threads_dark.svg | 9 + .../IDEAIcons/debugger/viewBreakpoints.svg | 6 + .../debugger/viewBreakpoints_dark.svg | 6 + macos/Resources/IDEAIcons/debugger/watch.svg | 4 + macos/Resources/IDEAIcons/nodes/field.svg | 5 + macos/Resources/IDEAIcons/nodes/variable.svg | 5 + .../zh-Hans.lproj/Localizable.strings | 2 + .../Application/Composition/AppServices.swift | 4 + .../DebugAutomaticExpressionProjection.swift | 104 ++ .../Sources/Lithe/Core/Ports/PlatformUI.swift | 2 + .../AppModel/AppModel+Development.swift | 135 +- .../AppModel/AppModel+ExecutionModules.swift | 50 +- .../AppModel/AppModel+FeatureState.swift | 2 +- .../Lithe/Models/AppModel/AppModel.swift | 2 + .../Models/Keymap/LitheCommandCatalog.swift | 1 + macos/Sources/Lithe/Models/LitheAction.swift | 1 + .../MacDebugPortAvailabilityChecker.swift | 25 + .../Platform/MacOS/MacServiceContainer.swift | 6 +- .../MacOS/Process/MacRawProcessSession.swift | 12 +- .../MacStoppedChildProcessReaper.swift | 73 + .../MacOS/Runtime/MacJdtWorkspaceState.swift | 23 +- .../MacOS/Terminal/MacTerminalTransport.swift | 8 +- .../Platform/MacOS/UI/MacPlatformUI.swift | 4 + .../Debug/DebugLaunchSourceResolver.swift | 102 ++ .../Debug/DebugPortAvailabilityChecker.swift | 14 + macos/Sources/Lithe/Theme/LitheIcons.swift | 59 +- .../Debug/DebugToolbarPresentation.swift | 107 ++ .../Lithe/Views/Debug/GenericDebugView.swift | 1201 +++++++++++------ .../Lithe/Views/Editor/CodeEditorView.swift | 261 +++- .../Lithe/Views/Workbench/WorkbenchView.swift | 102 +- .../Execution/RunModels.swift | 2 +- .../DebugBreakpointLocationValidator.swift | 19 + .../GenericDebugFeatureModel.swift | 257 +++- .../Application/ExecutionFeatureModels.swift | 4 + .../Services/RunService.swift | 7 + .../LanguageToolingSessionManager.swift | 6 +- .../DebugModuleTests.swift | 257 +++- .../ExecutionModuleTests.swift | 122 +- .../LanguageIntelligenceModuleTests.swift | 39 + .../DebugToolbarPresentationTests.swift | 137 ++ .../LitheTests/EditorGutterLayoutTests.swift | 58 +- .../LitheTests/EditorLayoutMetricsTests.swift | 4 +- .../JavaTestDebugLaunchServiceTests.swift | 40 + .../LitheTests/KeyboardShortcutTests.swift | 12 +- ...MacDebugPortAvailabilityCheckerTests.swift | 47 + .../MacJdtWorkspaceStateTests.swift | 8 +- .../LitheTests/MacProcessRunnerTests.swift | 57 + .../RealJavaDebugIntegrationTests.swift | 123 +- .../RunConfigurationIntegrationTests.swift | 240 ++++ rust/lithe-core/src/debug/engine.rs | 3 + rust/lithe-core/src/debug/types.rs | 1 + scripts/test-macos.sh | 16 + shared/contracts/rust-core-api.md | 2 + .../fixtures/debug/stepping-filters-v1.json | 1 + 101 files changed, 3558 insertions(+), 577 deletions(-) create mode 100644 macos/Resources/IDEAIcons/debugger/attachToProcess.svg create mode 100644 macos/Resources/IDEAIcons/debugger/attachToProcess_dark.svg create mode 100644 macos/Resources/IDEAIcons/debugger/db_disabled_breakpoint.svg create mode 100644 macos/Resources/IDEAIcons/debugger/db_disabled_breakpoint_dark.svg create mode 100644 macos/Resources/IDEAIcons/debugger/db_muted_breakpoint.svg create mode 100644 macos/Resources/IDEAIcons/debugger/db_muted_breakpoint_dark.svg create mode 100644 macos/Resources/IDEAIcons/debugger/db_set_breakpoint.svg create mode 100644 macos/Resources/IDEAIcons/debugger/db_set_breakpoint_dark.svg create mode 100644 macos/Resources/IDEAIcons/debugger/db_verified_breakpoint.svg create mode 100644 macos/Resources/IDEAIcons/debugger/db_verified_breakpoint_dark.svg create mode 100644 macos/Resources/IDEAIcons/debugger/debug.svg create mode 100644 macos/Resources/IDEAIcons/debugger/debug_dark.svg create mode 100644 macos/Resources/IDEAIcons/debugger/evaluateExpression.svg create mode 100644 macos/Resources/IDEAIcons/debugger/evaluateExpression_dark.svg create mode 100644 macos/Resources/IDEAIcons/debugger/frame.svg create mode 100644 macos/Resources/IDEAIcons/debugger/frame_dark.svg create mode 100644 macos/Resources/IDEAIcons/debugger/muteBreakpoints.svg create mode 100644 macos/Resources/IDEAIcons/debugger/muteBreakpoints_dark.svg create mode 100644 macos/Resources/IDEAIcons/debugger/pause.svg create mode 100644 macos/Resources/IDEAIcons/debugger/pause_dark.svg create mode 100644 macos/Resources/IDEAIcons/debugger/rerun.svg create mode 100644 macos/Resources/IDEAIcons/debugger/rerun_dark.svg create mode 100644 macos/Resources/IDEAIcons/debugger/restartDebug.svg create mode 100644 macos/Resources/IDEAIcons/debugger/restartDebug_dark.svg create mode 100644 macos/Resources/IDEAIcons/debugger/resume.svg create mode 100644 macos/Resources/IDEAIcons/debugger/resume_dark.svg create mode 100644 macos/Resources/IDEAIcons/debugger/run.svg create mode 100644 macos/Resources/IDEAIcons/debugger/runToCursor.svg create mode 100644 macos/Resources/IDEAIcons/debugger/runToCursor_dark.svg create mode 100644 macos/Resources/IDEAIcons/debugger/run_dark.svg create mode 100644 macos/Resources/IDEAIcons/debugger/smartStepInto.svg create mode 100644 macos/Resources/IDEAIcons/debugger/smartStepInto_dark.svg create mode 100644 macos/Resources/IDEAIcons/debugger/stepInto.svg create mode 100644 macos/Resources/IDEAIcons/debugger/stepInto_dark.svg create mode 100644 macos/Resources/IDEAIcons/debugger/stepOut.svg create mode 100644 macos/Resources/IDEAIcons/debugger/stepOut_dark.svg create mode 100644 macos/Resources/IDEAIcons/debugger/stepOver.svg create mode 100644 macos/Resources/IDEAIcons/debugger/stepOver_dark.svg create mode 100644 macos/Resources/IDEAIcons/debugger/stop.svg create mode 100644 macos/Resources/IDEAIcons/debugger/stop_dark.svg create mode 100644 macos/Resources/IDEAIcons/debugger/threadAtBreakpoint.svg create mode 100644 macos/Resources/IDEAIcons/debugger/threadCurrent.svg create mode 100644 macos/Resources/IDEAIcons/debugger/threadFrozen.svg create mode 100644 macos/Resources/IDEAIcons/debugger/threadRunning.svg create mode 100644 macos/Resources/IDEAIcons/debugger/threadSuspended.svg create mode 100644 macos/Resources/IDEAIcons/debugger/threads.svg create mode 100644 macos/Resources/IDEAIcons/debugger/threads_dark.svg create mode 100644 macos/Resources/IDEAIcons/debugger/viewBreakpoints.svg create mode 100644 macos/Resources/IDEAIcons/debugger/viewBreakpoints_dark.svg create mode 100644 macos/Resources/IDEAIcons/debugger/watch.svg create mode 100644 macos/Resources/IDEAIcons/nodes/field.svg create mode 100644 macos/Resources/IDEAIcons/nodes/variable.svg create mode 100644 macos/Sources/Lithe/Application/Features/DebugAutomaticExpressionProjection.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/Debug/MacDebugPortAvailabilityChecker.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/Process/MacStoppedChildProcessReaper.swift create mode 100644 macos/Sources/Lithe/Services/Debug/DebugLaunchSourceResolver.swift create mode 100644 macos/Sources/Lithe/Services/Debug/DebugPortAvailabilityChecker.swift create mode 100644 macos/Sources/Lithe/Views/Debug/DebugToolbarPresentation.swift create mode 100644 macos/Tests/LitheTests/DebugToolbarPresentationTests.swift create mode 100644 macos/Tests/LitheTests/MacDebugPortAvailabilityCheckerTests.swift diff --git a/AGENTS.md b/AGENTS.md index 4f32d3a4..60333b18 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,3 +17,15 @@ release notes, version metadata, tags, or release workflows. If the task involves building, running, diagnosing, or transferring files to the Windows product through a Parallels guest VM, additionally load `.agents/skills/debug-windows-on-parallels/SKILL.md` before proceeding. + +## Test process lifecycle and cleanup + +Unless the user gives a specific instruction to keep a process running, any +Lithe application started for building, testing, debugging, previewing, or +verification must be shut down when the task or test run is complete. Clean up +all child processes, helper processes, temporary app instances, and related +resources, then verify that no Lithe processes remain before handing the work +back. Do not launch duplicate Lithe instances during repeated checks, and do +not leave test-built applications open in the user's application list. If a +process cannot be stopped cleanly, report it explicitly and make a bounded +best-effort cleanup before continuing. diff --git a/macos/Resources/IDEAIcons/debugger/attachToProcess.svg b/macos/Resources/IDEAIcons/debugger/attachToProcess.svg new file mode 100644 index 00000000..a68a7163 --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/attachToProcess.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/macos/Resources/IDEAIcons/debugger/attachToProcess_dark.svg b/macos/Resources/IDEAIcons/debugger/attachToProcess_dark.svg new file mode 100644 index 00000000..d498d92f --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/attachToProcess_dark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/macos/Resources/IDEAIcons/debugger/db_disabled_breakpoint.svg b/macos/Resources/IDEAIcons/debugger/db_disabled_breakpoint.svg new file mode 100644 index 00000000..931866f0 --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/db_disabled_breakpoint.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/macos/Resources/IDEAIcons/debugger/db_disabled_breakpoint_dark.svg b/macos/Resources/IDEAIcons/debugger/db_disabled_breakpoint_dark.svg new file mode 100644 index 00000000..da739eb1 --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/db_disabled_breakpoint_dark.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/macos/Resources/IDEAIcons/debugger/db_muted_breakpoint.svg b/macos/Resources/IDEAIcons/debugger/db_muted_breakpoint.svg new file mode 100644 index 00000000..dd1d8109 --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/db_muted_breakpoint.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/macos/Resources/IDEAIcons/debugger/db_muted_breakpoint_dark.svg b/macos/Resources/IDEAIcons/debugger/db_muted_breakpoint_dark.svg new file mode 100644 index 00000000..90631714 --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/db_muted_breakpoint_dark.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/macos/Resources/IDEAIcons/debugger/db_set_breakpoint.svg b/macos/Resources/IDEAIcons/debugger/db_set_breakpoint.svg new file mode 100644 index 00000000..ae82e642 --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/db_set_breakpoint.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/macos/Resources/IDEAIcons/debugger/db_set_breakpoint_dark.svg b/macos/Resources/IDEAIcons/debugger/db_set_breakpoint_dark.svg new file mode 100644 index 00000000..f7a5c29f --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/db_set_breakpoint_dark.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/macos/Resources/IDEAIcons/debugger/db_verified_breakpoint.svg b/macos/Resources/IDEAIcons/debugger/db_verified_breakpoint.svg new file mode 100644 index 00000000..8729ad6a --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/db_verified_breakpoint.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/macos/Resources/IDEAIcons/debugger/db_verified_breakpoint_dark.svg b/macos/Resources/IDEAIcons/debugger/db_verified_breakpoint_dark.svg new file mode 100644 index 00000000..4df608d5 --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/db_verified_breakpoint_dark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/macos/Resources/IDEAIcons/debugger/debug.svg b/macos/Resources/IDEAIcons/debugger/debug.svg new file mode 100644 index 00000000..c1279337 --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/debug.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/macos/Resources/IDEAIcons/debugger/debug_dark.svg b/macos/Resources/IDEAIcons/debugger/debug_dark.svg new file mode 100644 index 00000000..757d0b50 --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/debug_dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/macos/Resources/IDEAIcons/debugger/evaluateExpression.svg b/macos/Resources/IDEAIcons/debugger/evaluateExpression.svg new file mode 100644 index 00000000..20f9922c --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/evaluateExpression.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/macos/Resources/IDEAIcons/debugger/evaluateExpression_dark.svg b/macos/Resources/IDEAIcons/debugger/evaluateExpression_dark.svg new file mode 100644 index 00000000..07cb2091 --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/evaluateExpression_dark.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/macos/Resources/IDEAIcons/debugger/frame.svg b/macos/Resources/IDEAIcons/debugger/frame.svg new file mode 100644 index 00000000..f875eb8f --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/frame.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/macos/Resources/IDEAIcons/debugger/frame_dark.svg b/macos/Resources/IDEAIcons/debugger/frame_dark.svg new file mode 100644 index 00000000..65bdd6ad --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/frame_dark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/macos/Resources/IDEAIcons/debugger/muteBreakpoints.svg b/macos/Resources/IDEAIcons/debugger/muteBreakpoints.svg new file mode 100644 index 00000000..54700838 --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/muteBreakpoints.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/macos/Resources/IDEAIcons/debugger/muteBreakpoints_dark.svg b/macos/Resources/IDEAIcons/debugger/muteBreakpoints_dark.svg new file mode 100644 index 00000000..189e09c7 --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/muteBreakpoints_dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/macos/Resources/IDEAIcons/debugger/pause.svg b/macos/Resources/IDEAIcons/debugger/pause.svg new file mode 100644 index 00000000..4d12f24f --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/pause.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/macos/Resources/IDEAIcons/debugger/pause_dark.svg b/macos/Resources/IDEAIcons/debugger/pause_dark.svg new file mode 100644 index 00000000..1907d98f --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/pause_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/macos/Resources/IDEAIcons/debugger/rerun.svg b/macos/Resources/IDEAIcons/debugger/rerun.svg new file mode 100644 index 00000000..316cbd98 --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/rerun.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/macos/Resources/IDEAIcons/debugger/rerun_dark.svg b/macos/Resources/IDEAIcons/debugger/rerun_dark.svg new file mode 100644 index 00000000..51e754ca --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/rerun_dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/macos/Resources/IDEAIcons/debugger/restartDebug.svg b/macos/Resources/IDEAIcons/debugger/restartDebug.svg new file mode 100644 index 00000000..81c12c9a --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/restartDebug.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/macos/Resources/IDEAIcons/debugger/restartDebug_dark.svg b/macos/Resources/IDEAIcons/debugger/restartDebug_dark.svg new file mode 100644 index 00000000..affff369 --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/restartDebug_dark.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/macos/Resources/IDEAIcons/debugger/resume.svg b/macos/Resources/IDEAIcons/debugger/resume.svg new file mode 100644 index 00000000..1ff41e9c --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/resume.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/macos/Resources/IDEAIcons/debugger/resume_dark.svg b/macos/Resources/IDEAIcons/debugger/resume_dark.svg new file mode 100644 index 00000000..bf2da043 --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/resume_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/macos/Resources/IDEAIcons/debugger/run.svg b/macos/Resources/IDEAIcons/debugger/run.svg new file mode 100644 index 00000000..dd11f50d --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/run.svg @@ -0,0 +1,4 @@ + + + + diff --git a/macos/Resources/IDEAIcons/debugger/runToCursor.svg b/macos/Resources/IDEAIcons/debugger/runToCursor.svg new file mode 100644 index 00000000..b84cd852 --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/runToCursor.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/macos/Resources/IDEAIcons/debugger/runToCursor_dark.svg b/macos/Resources/IDEAIcons/debugger/runToCursor_dark.svg new file mode 100644 index 00000000..a3e1ed69 --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/runToCursor_dark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/macos/Resources/IDEAIcons/debugger/run_dark.svg b/macos/Resources/IDEAIcons/debugger/run_dark.svg new file mode 100644 index 00000000..0c199c7d --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/run_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/macos/Resources/IDEAIcons/debugger/smartStepInto.svg b/macos/Resources/IDEAIcons/debugger/smartStepInto.svg new file mode 100644 index 00000000..bf66ecd0 --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/smartStepInto.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/macos/Resources/IDEAIcons/debugger/smartStepInto_dark.svg b/macos/Resources/IDEAIcons/debugger/smartStepInto_dark.svg new file mode 100644 index 00000000..bdc25606 --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/smartStepInto_dark.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/macos/Resources/IDEAIcons/debugger/stepInto.svg b/macos/Resources/IDEAIcons/debugger/stepInto.svg new file mode 100644 index 00000000..16de2906 --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/stepInto.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/macos/Resources/IDEAIcons/debugger/stepInto_dark.svg b/macos/Resources/IDEAIcons/debugger/stepInto_dark.svg new file mode 100644 index 00000000..5cdeefbf --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/stepInto_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/macos/Resources/IDEAIcons/debugger/stepOut.svg b/macos/Resources/IDEAIcons/debugger/stepOut.svg new file mode 100644 index 00000000..dc21a3fe --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/stepOut.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/macos/Resources/IDEAIcons/debugger/stepOut_dark.svg b/macos/Resources/IDEAIcons/debugger/stepOut_dark.svg new file mode 100644 index 00000000..9bd18dd7 --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/stepOut_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/macos/Resources/IDEAIcons/debugger/stepOver.svg b/macos/Resources/IDEAIcons/debugger/stepOver.svg new file mode 100644 index 00000000..9d614125 --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/stepOver.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/macos/Resources/IDEAIcons/debugger/stepOver_dark.svg b/macos/Resources/IDEAIcons/debugger/stepOver_dark.svg new file mode 100644 index 00000000..134a761a --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/stepOver_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/macos/Resources/IDEAIcons/debugger/stop.svg b/macos/Resources/IDEAIcons/debugger/stop.svg new file mode 100644 index 00000000..845ab762 --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/stop.svg @@ -0,0 +1,4 @@ + + + + diff --git a/macos/Resources/IDEAIcons/debugger/stop_dark.svg b/macos/Resources/IDEAIcons/debugger/stop_dark.svg new file mode 100644 index 00000000..d9ee43c3 --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/stop_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/macos/Resources/IDEAIcons/debugger/threadAtBreakpoint.svg b/macos/Resources/IDEAIcons/debugger/threadAtBreakpoint.svg new file mode 100644 index 00000000..699019da --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/threadAtBreakpoint.svg @@ -0,0 +1,4 @@ + + + + diff --git a/macos/Resources/IDEAIcons/debugger/threadCurrent.svg b/macos/Resources/IDEAIcons/debugger/threadCurrent.svg new file mode 100644 index 00000000..cd2d85e0 --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/threadCurrent.svg @@ -0,0 +1,4 @@ + + + + diff --git a/macos/Resources/IDEAIcons/debugger/threadFrozen.svg b/macos/Resources/IDEAIcons/debugger/threadFrozen.svg new file mode 100644 index 00000000..46d578ee --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/threadFrozen.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/macos/Resources/IDEAIcons/debugger/threadRunning.svg b/macos/Resources/IDEAIcons/debugger/threadRunning.svg new file mode 100644 index 00000000..24283035 --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/threadRunning.svg @@ -0,0 +1,4 @@ + + + + diff --git a/macos/Resources/IDEAIcons/debugger/threadSuspended.svg b/macos/Resources/IDEAIcons/debugger/threadSuspended.svg new file mode 100644 index 00000000..b8950ce0 --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/threadSuspended.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/macos/Resources/IDEAIcons/debugger/threads.svg b/macos/Resources/IDEAIcons/debugger/threads.svg new file mode 100644 index 00000000..685d0333 --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/threads.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/macos/Resources/IDEAIcons/debugger/threads_dark.svg b/macos/Resources/IDEAIcons/debugger/threads_dark.svg new file mode 100644 index 00000000..c84342a8 --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/threads_dark.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/macos/Resources/IDEAIcons/debugger/viewBreakpoints.svg b/macos/Resources/IDEAIcons/debugger/viewBreakpoints.svg new file mode 100644 index 00000000..e2773b68 --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/viewBreakpoints.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/macos/Resources/IDEAIcons/debugger/viewBreakpoints_dark.svg b/macos/Resources/IDEAIcons/debugger/viewBreakpoints_dark.svg new file mode 100644 index 00000000..31c76058 --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/viewBreakpoints_dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/macos/Resources/IDEAIcons/debugger/watch.svg b/macos/Resources/IDEAIcons/debugger/watch.svg new file mode 100644 index 00000000..dca586da --- /dev/null +++ b/macos/Resources/IDEAIcons/debugger/watch.svg @@ -0,0 +1,4 @@ + + + + diff --git a/macos/Resources/IDEAIcons/nodes/field.svg b/macos/Resources/IDEAIcons/nodes/field.svg new file mode 100644 index 00000000..d1dce9d8 --- /dev/null +++ b/macos/Resources/IDEAIcons/nodes/field.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/macos/Resources/IDEAIcons/nodes/variable.svg b/macos/Resources/IDEAIcons/nodes/variable.svg new file mode 100644 index 00000000..23a35d51 --- /dev/null +++ b/macos/Resources/IDEAIcons/nodes/variable.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index b512c708..0bc7db47 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -960,6 +960,8 @@ "Toggle Run" = "切换运行窗口"; "Show or hide run output" = "显示或隐藏运行输出"; "Toggle Debug" = "切换调试窗口"; +"Toggle Line Breakpoint" = "切换行断点"; +"Add or remove a breakpoint at the caret" = "在光标所在行添加或移除断点"; "Show or hide the Debug tool window" = "显示或隐藏调试工具窗口"; "Search text across the workspace" = "搜索整个工作区的文本"; "Find in File" = "在文件中查找"; diff --git a/macos/Sources/Lithe/Application/Composition/AppServices.swift b/macos/Sources/Lithe/Application/Composition/AppServices.swift index ceee972a..bf380afc 100644 --- a/macos/Sources/Lithe/Application/Composition/AppServices.swift +++ b/macos/Sources/Lithe/Application/Composition/AppServices.swift @@ -20,6 +20,7 @@ final class AppServices { /// Metadata-only provider catalog; providers are activated on demand. let languageProviderCatalog: LanguageProviderCatalog let debugLaunchConfigurationResolver: DebugLaunchConfigurationResolver + let debugPortAvailabilityChecker: any DebugPortAvailabilityChecking let javaTestDebugLaunchService: JavaTestDebugLaunchService let debugBreakpointPersistence: (any DebugBreakpointPersisting)? let workspaceOperations: any WorkspaceOperations @@ -55,6 +56,7 @@ final class AppServices { languageProviderCatalogSource: any LanguageProviderCatalogSource, languageProviderCatalogSnapshot: LanguageProviderCatalogSnapshot? = nil, debugLaunchConfigurationResolver: DebugLaunchConfigurationResolver? = nil, + debugPortAvailabilityChecker: (any DebugPortAvailabilityChecking)? = nil, javaTestResultServerFactory: @escaping @MainActor () -> any JavaTestResultServing, debugBreakpointPersistence: (any DebugBreakpointPersisting)? = nil, workspaceOperations: any WorkspaceOperations, @@ -93,6 +95,8 @@ final class AppServices { self.languageProviderCatalog = resolvedCatalog self.debugLaunchConfigurationResolver = debugLaunchConfigurationResolver ?? DebugLaunchConfigurationResolver(fileStorage: fileStorage) + self.debugPortAvailabilityChecker = debugPortAvailabilityChecker + ?? AlwaysAvailableDebugPortChecker() self.javaTestDebugLaunchService = JavaTestDebugLaunchService( configurationResolver: self.debugLaunchConfigurationResolver, resultServerFactory: javaTestResultServerFactory diff --git a/macos/Sources/Lithe/Application/Features/DebugAutomaticExpressionProjection.swift b/macos/Sources/Lithe/Application/Features/DebugAutomaticExpressionProjection.swift new file mode 100644 index 00000000..fcdcb4c0 --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/DebugAutomaticExpressionProjection.swift @@ -0,0 +1,104 @@ +import Foundation + +/// Extracts source-referenced Java expressions for automatic debugger +/// inspection. The projection is intentionally deterministic and bounded so +/// selecting a stack frame cannot trigger an unbounded batch of evaluations. +enum DebugAutomaticExpressionProjection { + static let maximumExpressions = 8 + + private static let javaKeywords: Set = [ + "abstract", "assert", "boolean", "break", "byte", "case", "catch", + "char", "class", "const", "continue", "default", "do", "double", + "else", "enum", "extends", "false", "final", "finally", "float", + "for", "goto", "if", "implements", "import", "instanceof", "int", + "interface", "long", "native", "new", "null", "package", "private", + "protected", "public", "record", "return", "short", "static", "strictfp", + "super", "switch", "synchronized", "this", "throw", "throws", "transient", + "true", "try", "var", "void", "volatile", "while", "yield" + ] + + static func javaExpressions(forLine line: Int, in source: NSString) -> [String] { + guard let lineRange = lineRange(for: line, in: source) else { return [] } + let lineSource = source.substring(with: lineRange) as NSString + var values: [String] = [] + var known = Set() + var location = 0 + while location < lineSource.length, values.count < maximumExpressions { + guard isIdentifierStart(at: location, in: lineSource) else { + location += 1 + continue + } + let start = location + location += 1 + while location < lineSource.length, + isIdentifierCharacter(at: location, in: lineSource) { + location += 1 + } + let range = NSRange(location: start, length: location - start) + let token = lineSource.substring(with: range) + let previous = previousNonWhitespaceCharacter(before: start, in: lineSource) + let next = nextNonWhitespaceCharacter(after: location, in: lineSource) + guard !javaKeywords.contains(token), previous != ".", next != "(" else { continue } + if known.insert(token).inserted { values.append(token) } + } + return values + } + + private static func isIdentifierStart(at location: Int, in source: NSString) -> Bool { + guard let scalar = scalar(at: location, in: source) else { return false } + return CharacterSet.letters.union(CharacterSet(charactersIn: "_$")) + .contains(scalar) + } + + private static func isIdentifierCharacter(at location: Int, in source: NSString) -> Bool { + guard let scalar = scalar(at: location, in: source) else { return false } + return CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "_$")) + .contains(scalar) + } + + private static func scalar(at location: Int, in source: NSString) -> UnicodeScalar? { + guard location >= 0, location < source.length else { return nil } + return UnicodeScalar(source.character(at: location)) + } + + private static func previousNonWhitespaceCharacter( + before location: Int, + in source: NSString + ) -> Character? { + var cursor = location - 1 + while cursor >= 0 { + guard let scalar = scalar(at: cursor, in: source) else { return nil } + if !CharacterSet.whitespacesAndNewlines.contains(scalar) { return Character(scalar) } + cursor -= 1 + } + return nil + } + + private static func nextNonWhitespaceCharacter( + after location: Int, + in source: NSString + ) -> Character? { + var cursor = location + while cursor < source.length { + guard let scalar = scalar(at: cursor, in: source) else { return nil } + if !CharacterSet.whitespacesAndNewlines.contains(scalar) { return Character(scalar) } + cursor += 1 + } + return nil + } + + private static func lineRange(for line: Int, in source: NSString) -> NSRange? { + guard line >= 0, source.length > 0 else { return nil } + var location = 0 + var currentLine = 0 + while currentLine < line, location < source.length { + let range = source.lineRange(for: NSRange(location: location, length: 0)) + let next = NSMaxRange(range) + guard next > location else { return nil } + location = next + currentLine += 1 + } + guard currentLine == line, location < source.length else { return nil } + return source.lineRange(for: NSRange(location: location, length: 0)) + } +} diff --git a/macos/Sources/Lithe/Core/Ports/PlatformUI.swift b/macos/Sources/Lithe/Core/Ports/PlatformUI.swift index 50b47a20..565ede54 100644 --- a/macos/Sources/Lithe/Core/Ports/PlatformUI.swift +++ b/macos/Sources/Lithe/Core/Ports/PlatformUI.swift @@ -4,6 +4,7 @@ import Foundation /// Implementations may use AppKit, Qt, or another native UI toolkit. @MainActor protocol PlatformUI: AnyObject { + func activateApplication() func chooseDirectory(title: String, prompt: String) -> URL? func chooseFile(title: String, prompt: String) -> URL? func revealInFileBrowser(_ url: URL) @@ -13,6 +14,7 @@ protocol PlatformUI: AnyObject { } extension PlatformUI { + func activateApplication() {} func startAccessingProject(_ url: URL) -> Bool { false } func stopAccessingProject(_ url: URL) {} } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift index 2b75cf1d..1d7b2a9e 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift @@ -127,6 +127,28 @@ extension AppModel { ) } + /// Reveals a stopped debugger frame without adding every step to the + /// user's editor back/forward history. Debug stepping is transient + /// inspection, unlike an explicit navigation from build output or a link. + func revealDebugLocation(url: URL, line: Int, column: Int?) { + let normalizedURL = url.standardizedFileURL + guard workspaceFeature.fileExists(at: normalizedURL) else { + showNotification("The stopped source file is no longer available: \(url.lastPathComponent)") + return + } + navigate( + to: EditorNavigationLocation( + url: normalizedURL, + line: max(0, line - 1), + utf16Column: max(0, (column ?? 1) - 1), + isReadOnly: false, + displayPath: nil, + virtualProviderID: nil + ), + recordsHistory: false + ) + } + func toggleProblems() { isProblemsVisible.toggle() guard isProblemsVisible else { return } @@ -341,6 +363,18 @@ extension AppModel { Task { [weak self] in await self?.startDebuggingAfterActivation() } } + func startOrRestartDebugging() { + guard let feature = genericDebugFeatureIfActive, + feature.isSessionActive else { + startDebugging() + return + } + showDebugToolWindow() + if feature.canRestart { + feature.execute(.restart) + } + } + func attachJavaDebugger(host: String, port: Int) { Task { [weak self] in await self?.attachJavaDebuggerAfterActivation(host: host, port: port) @@ -385,19 +419,57 @@ extension AppModel { } private func startDebuggingAfterActivation() async { - guard await activateExecutionModule() != nil, - await activateDebugModule() != nil else { return } - if let document = activeDocument, - languageProviderCatalog.provider(for: document.url)? - .capabilities.contains(.debugAdapter) == true { - startGenericDebugging(document) + guard let runFeature = await activateExecutionModule()?.runFeature, + await activateDebugModule() != nil, + let workspaceURL else { return } + guard runFeature.configurationStatus == .ready else { + runFeature.requestRunConfigurationGeneration(intent: .debug) return } - let language = activeDocument.flatMap { - languageProviderCatalog.provider(for: $0.url)?.displayName - } ?? "This file type" - showNotification("\(language) debugging is not available on this machine") - isDebugVisible = true + guard let selectedConfiguration = runFeature.selectedConfiguration else { + showNotification("Choose a Run configuration before starting Debug") + return + } + let configuration = DebugLaunchSourceResolver().configurationForDebug( + selected: selectedConfiguration, + activeDocumentText: activeDocument?.text, + configurations: runFeature.configurations + ) + runFeature.select(configuration) + + let sourceURL: URL + if configuration.usesCurrentEditorFile { + guard let document = activeDocument else { + showNotification("Open a source file or choose a project Run configuration") + return + } + sourceURL = document.url + } else if configuration.kind.capabilities.contains(.jdwpDebug) { + guard let resolved = DebugLaunchSourceResolver().resolve( + configuration: configuration, + activeDocumentURL: activeDocument?.url, + projectFiles: projectFiles, + workspaceURL: workspaceURL + ) else { + showNotification("Could not find the Java source for \(configuration.name)") + return + } + sourceURL = resolved + } else { + showNotification("\(configuration.name) does not support Debug yet") + return + } + + guard languageProviderCatalog.provider(for: sourceURL)? + .capabilities.contains(.debugAdapter) == true else { + showNotification("Debug support is not available for \(sourceURL.lastPathComponent)") + isDebugVisible = true + return + } + let document = openDocuments.first { + $0.url.standardizedFileURL == sourceURL.standardizedFileURL + } + await startGenericDebuggingAfterActivation(fileURL: sourceURL, document: document) } func toggleTests() { @@ -689,8 +761,17 @@ extension AppModel { } func handleDebugSessionStateChange(_ state: DebugAdapterState) { + // A Java launch may request an integrated terminal before the adapter + // reaches `running`. Once the debug session is live, the debugger is + // the primary tool window, matching IDEA's launch behavior; the + // terminal session remains available as a separate session tab. + if state == .launching || state == .running { + showDebugToolWindow() + return + } if state == .paused { showDebugToolWindow() + platformUI.activateApplication() return } guard state == .terminated || state == .failed else { return } @@ -876,13 +957,12 @@ extension AppModel { } } - private func startGenericDebugging(_ document: EditorDocument) { - Task { [weak self] in await self?.startGenericDebuggingAfterActivation(document) } - } - - private func startGenericDebuggingAfterActivation(_ document: EditorDocument) async { + private func startGenericDebuggingAfterActivation( + fileURL: URL, + document: EditorDocument? + ) async { guard let workspaceURL, - let provider = languageProviderCatalog.provider(for: document.url), + let provider = languageProviderCatalog.provider(for: fileURL), let runFeature = await activateExecutionModule()?.runFeature, let genericDebugFeature = await activateDebugModule()?.genericFeature else { showNotification("No language provider is available for this file") @@ -894,7 +974,7 @@ extension AppModel { if let selectedConfiguration = runFeature.selectedConfiguration { runFeature.select(selectedConfiguration) } - if document.isDirty { + if let document, document.isDirty { do { let previousText = document.savedText try saveDocument(document) @@ -904,13 +984,26 @@ extension AppModel { return } } + // Reject an occupied service port before asking JDT LS to resolve the + // launch target. A failed preflight therefore creates no language + // service, Debug Adapter, terminal, or Java child process. + if provider.id == "java", + let selectedConfiguration = runFeature.selectedConfiguration, + let port = runFeature.configuredServerPort(for: selectedConfiguration), + !debugPortAvailabilityChecker.isPortAvailable(port) { + showNotification( + "Port \(port) is already in use. Stop the process using it or change server.port in the Run configuration." + ) + isDebugVisible = true + return + } let configuration: DebugLaunchConfiguration do { let javaTarget: JavaDebugLaunchTarget? if provider.id == "java" { let sessions = try await languageSessionsForWorkspaceMaintenance() javaTarget = try await sessions.resolveJavaDebugLaunchTarget( - fileURL: document.url, + fileURL: fileURL, rootURL: workspaceURL ) } else { @@ -918,7 +1011,7 @@ extension AppModel { } configuration = try debugLaunchConfigurationResolver.resolve( provider: provider, - documentURL: document.url, + documentURL: fileURL, workspaceURL: workspaceURL, configurations: runFeature.configurations, selectedConfiguration: runFeature.selectedConfiguration, @@ -930,7 +1023,7 @@ extension AppModel { return } guard genericDebugFeature.start( - fileURL: document.url, + fileURL: fileURL, rootURL: workspaceURL, configuration: configuration ) else { diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift index c70181e3..89483485 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift @@ -51,7 +51,7 @@ extension AppModel { func activateDebugModule() async -> DebugFeatureAccess? { if let genericFeature = genericDebugFeatureIfActive { - configureDebugRunInTerminalHandler(genericFeature) + configureDebugHostHandlers(genericFeature) if let workspaceURL { genericFeature.openWorkspace(at: workspaceURL) } return DebugFeatureAccess(genericFeature: genericFeature) } @@ -59,11 +59,8 @@ extension AppModel { let value = try await services.moduleRuntime.activateCapability(.debugWorkspace) guard let capability = value as? LitheDebugModule.DebugModuleCapability, let genericFeature = capability.genericFeature as? GenericDebugFeatureModel else { return nil } - configureDebugRunInTerminalHandler(genericFeature) + configureDebugHostHandlers(genericFeature) cacheModuleCapability(capability, id: .debugWorkspace, moduleID: .debug) - genericFeature.onStoppedLocation = { [weak self] url, line, column in - self?.openSourceLocation(url: url, line: line, column: column) - } if let workspaceURL { genericFeature.openWorkspace(at: workspaceURL) } observeModuleFeature(.debug, observation: genericFeature.objectWillChange.sink { [weak self] _ in self?.scheduleObjectWillChangeRelay() @@ -80,6 +77,49 @@ extension AppModel { } } + private func configureDebugHostHandlers(_ feature: GenericDebugFeatureModel) { + feature.onStoppedLocation = { [weak self] url, line, column in + self?.revealDebugLocation(url: url, line: line, column: column) + } + feature.onAutomaticVariableInspectionRequest = { [weak self, weak feature] frame in + guard let self, let feature else { return } + requestAutomaticDebugVariables(for: frame, feature: feature) + } + configureDebugRunInTerminalHandler(feature) + } + + private func requestAutomaticDebugVariables( + for frame: DebugStackFrame, + feature: GenericDebugFeatureModel + ) { + guard feature.providerID == "java", + let sourceURL = frame.sourceURL?.standardizedFileURL, + let source = debugSourceText(at: sourceURL) else { + feature.requestAutomaticVariables([]) + return + } + let expressions = DebugAutomaticExpressionProjection.javaExpressions( + forLine: max(0, frame.line - 1), + in: source as NSString + ) + feature.requestAutomaticVariables(expressions) + } + + private func debugSourceText(at sourceURL: URL) -> String? { + if let document = openDocuments.first(where: { + $0.url.standardizedFileURL == sourceURL + }) { + return document.text + } + guard let metadata = services.fileStorage.metadata(for: sourceURL), + metadata.isRegularFile, + let byteCount = metadata.byteCount, + byteCount <= 2_000_000, + let data = try? services.fileStorage.readData(from: sourceURL, options: []), + let source = String(data: data, encoding: .utf8) else { return nil } + return source + } + private func configureDebugRunInTerminalHandler(_ feature: GenericDebugFeatureModel) { feature.onSessionSelectionChanged = { [weak self] debugSessionID in guard let self else { return } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift index 9a309dbb..b9c2d33f 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift @@ -330,7 +330,7 @@ extension AppModel { switch id { case "open-project", "settings": true - case "save", "find-in-file", "local-history", "reveal-in-finder": + case "save", "find-in-file", "local-history", "reveal-in-finder", "toggle-breakpoint": activeDocument != nil case "find-next", "find-previous": isFindBarVisible && findMatchCount > 0 diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index d7de467c..e27a9161 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -158,6 +158,7 @@ final class AppModel: ObservableObject, Identifiable { let runtimeFeature: RuntimeSettingsFeatureModel let languageToolingFeature: LanguageToolingFeatureModel let debugLaunchConfigurationResolver: DebugLaunchConfigurationResolver + let debugPortAvailabilityChecker: any DebugPortAvailabilityChecking let workspaceFeature: WorkspaceFeatureModel let githubFeature: GitHubFeatureModel let discourseCommunityFeature: DiscourseCommunityFeatureModel @@ -364,6 +365,7 @@ final class AppModel: ObservableObject, Identifiable { sessionsProvider: { nil } ) debugLaunchConfigurationResolver = services.debugLaunchConfigurationResolver + debugPortAvailabilityChecker = services.debugPortAvailabilityChecker documentFeature = DocumentFeatureModel( operations: services.workspaceOperations, documentLifecycleDecider: services.documentLifecycleDecider, diff --git a/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift b/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift index 12db3729..baa2dc07 100644 --- a/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift +++ b/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift @@ -25,6 +25,7 @@ enum LitheCommandCatalog { command("debug-step-over", "Debug: Step Over", "Execute the next source line", .run, "f8"), command("debug-step-into", "Debug: Step Into", "Enter the next function call", .run, "f7"), command("debug-step-out", "Debug: Step Out", "Return from the current function", .run, "f8", [.shift]), + command("toggle-breakpoint", "Toggle Line Breakpoint", "Add or remove a breakpoint at the caret", .run, "f8", [.command]), command("view-breakpoints", "View Breakpoints", "Manage all project breakpoints", .run, "f8", [.shift, .command]), LitheCommandDefinition( diff --git a/macos/Sources/Lithe/Models/LitheAction.swift b/macos/Sources/Lithe/Models/LitheAction.swift index 1b1ce9db..8656db0c 100644 --- a/macos/Sources/Lithe/Models/LitheAction.swift +++ b/macos/Sources/Lithe/Models/LitheAction.swift @@ -63,6 +63,7 @@ enum LitheActionRegistry { action("debug-step-over", model: model) { model.stepOverDebugging() }, action("debug-step-into", model: model) { model.stepIntoDebugging() }, action("debug-step-out", model: model) { model.stepOutDebugging() }, + action("toggle-breakpoint", model: model) { model.toggleDebugBreakpointAtCaret() }, action("view-breakpoints", model: model) { model.showDebugBreakpointManager() }, action("open-project", model: model) { model.chooseProject() }, action("close-project", model: model) { model.closeProject() }, diff --git a/macos/Sources/Lithe/Platform/MacOS/Debug/MacDebugPortAvailabilityChecker.swift b/macos/Sources/Lithe/Platform/MacOS/Debug/MacDebugPortAvailabilityChecker.swift new file mode 100644 index 00000000..f1ac435c --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Debug/MacDebugPortAvailabilityChecker.swift @@ -0,0 +1,25 @@ +import Darwin +import Foundation + +/// Probes loopback TCP ports without creating a long-lived listener. +@MainActor +final class MacDebugPortAvailabilityChecker: DebugPortAvailabilityChecking { + func isPortAvailable(_ port: Int) -> Bool { + guard (1...65_535).contains(port) else { return false } + let descriptor = socket(AF_INET, SOCK_STREAM, 0) + guard descriptor >= 0 else { return false } + defer { _ = close(descriptor) } + + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.stride) + address.sin_family = sa_family_t(AF_INET) + address.sin_port = in_port_t(port).bigEndian + address.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) + + return withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(descriptor, $0, socklen_t(MemoryLayout.stride)) == 0 + } + } + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 007f129e..a4b37f74 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -53,7 +53,8 @@ final class MacServiceContainer { moduleLaunchMode: ModuleLaunchMode = .normal, moduleStore providedModuleStore: MacModuleConfigurationStore? = nil, pluginRuntimeRecovery: MacPluginRuntimeRecoveryCoordinator? = nil, - authorizationCallbackRouter providedAuthorizationCallbackRouter: MacExternalAuthorizationCallbackRouter? = nil + authorizationCallbackRouter providedAuthorizationCallbackRouter: MacExternalAuthorizationCallbackRouter? = nil, + platformUI providedPlatformUI: (any PlatformUI)? = nil ) { let authorizationCallbackRouter = providedAuthorizationCallbackRouter ?? MacExternalAuthorizationCallbackRouter() @@ -89,7 +90,7 @@ final class MacServiceContainer { secureStore: MacKeychainSecureStore(service: "app.lithe.desktop.github"), git: MacGitHubGitOperations(core: rustCore) ) - let platformUI = MacPlatformUI() + let platformUI = providedPlatformUI ?? MacPlatformUI() let discourseCommunityService = DiscourseCommunityService( core: rustCore, credentialStore: MacKeychainSecureStore(service: "app.lithe.desktop.linux-do"), @@ -505,6 +506,7 @@ final class MacServiceContainer { fileStorage: fileStorage, javaTestLaunchResolver: rustCore ), + debugPortAvailabilityChecker: MacDebugPortAvailabilityChecker(), javaTestResultServerFactory: { MacJavaTestResultServer() }, debugBreakpointPersistence: debugBreakpointStore, workspaceOperations: workspaceOperations, diff --git a/macos/Sources/Lithe/Platform/MacOS/Process/MacRawProcessSession.swift b/macos/Sources/Lithe/Platform/MacOS/Process/MacRawProcessSession.swift index 25f7ca29..a2ea6b50 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Process/MacRawProcessSession.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Process/MacRawProcessSession.swift @@ -16,9 +16,15 @@ final class MacRawProcessSession: RawProcessSession, @unchecked Sendable { private var errorPipe: Pipe? private var timeoutTask: Task? private var activeOperationID: String? + // A stop followed immediately by a new start can leave the old + // termination callback queued on the process-source queue. Keep a + // generation token so that callback cannot clear or report the new run. + private var processGeneration = UUID() func start(_ request: ProcessRequest) throws { stop() + processGeneration = UUID() + let currentGeneration = processGeneration activeOperationID = request.operationID onStateChange?(ProcessLifecycleEvent( operationID: request.operationID, @@ -64,7 +70,9 @@ final class MacRawProcessSession: RawProcessSession, @unchecked Sendable { self.onError?(data) } process.terminationHandler = { [weak self] terminatedProcess in - guard let self, self.process === terminatedProcess else { return } + guard let self, + self.process === terminatedProcess, + self.processGeneration == currentGeneration else { return } self.outputPipe?.fileHandleForReading.readabilityHandler = nil self.errorPipe?.fileHandleForReading.readabilityHandler = nil self.process = nil @@ -146,6 +154,8 @@ final class MacRawProcessSession: RawProcessSession, @unchecked Sendable { closePipes() process = nil activeOperationID = nil + // Invalidate callbacks that may still be queued for the stopped run. + processGeneration = UUID() } private func closePipes() { diff --git a/macos/Sources/Lithe/Platform/MacOS/Process/MacStoppedChildProcessReaper.swift b/macos/Sources/Lithe/Platform/MacOS/Process/MacStoppedChildProcessReaper.swift new file mode 100644 index 00000000..7b2f8b30 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Process/MacStoppedChildProcessReaper.swift @@ -0,0 +1,73 @@ +import Darwin +import Foundation + +/// Reaps direct child processes after a platform-owned stop operation. +/// +/// Some native terminal libraries send the termination signal themselves but +/// can miss their eventual `waitpid` callback. Keeping a second process source +/// prevents an exited debuggee from remaining as a zombie under Lithe. +final class MacStoppedChildProcessReaper: @unchecked Sendable { + typealias Completion = @Sendable () -> Void + + private let lock = NSLock() + private let queue = DispatchQueue( + label: "app.lithe.stopped-child-process-reaper", + qos: .utility + ) + private var sources: [pid_t: DispatchSourceProcess] = [:] + private var completions: [pid_t: [Completion]] = [:] + + func reapWhenExited( + _ processID: pid_t, + completion: @escaping Completion = {} + ) { + guard processID > 0 else { + completion() + return + } + + var waitStatus: Int32 = 0 + errno = 0 + let immediateResult = Darwin.waitpid(processID, &waitStatus, WNOHANG) + if immediateResult == processID || (immediateResult == -1 && errno == ECHILD) { + completion() + return + } + + let processSource = DispatchSource.makeProcessSource( + identifier: processID, + eventMask: .exit, + queue: queue + ) + let shouldActivate = lock.withLock { () -> Bool in + completions[processID, default: []].append(completion) + guard sources[processID] == nil else { return false } + sources[processID] = processSource + return true + } + guard shouldActivate else { return } + + // Retaining self until the event fires keeps reaping alive even when a + // terminal session is closed immediately after stop(). + processSource.setEventHandler { [self] in + reap(processID) + } + processSource.activate() + } + + private func reap(_ processID: pid_t) { + var waitStatus: Int32 = 0 + var waitResult: pid_t + repeat { + waitResult = Darwin.waitpid(processID, &waitStatus, 0) + } while waitResult == -1 && errno == EINTR + + let state = lock.withLock { () -> (DispatchSourceProcess?, [Completion]) in + let source = sources.removeValue(forKey: processID) + let callbacks = completions.removeValue(forKey: processID) ?? [] + return (source, callbacks) + } + state.0?.cancel() + state.1.forEach { $0() } + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacJdtWorkspaceState.swift b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacJdtWorkspaceState.swift index df475b6b..e263e52b 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacJdtWorkspaceState.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacJdtWorkspaceState.swift @@ -125,7 +125,7 @@ struct MacJdtWorkspaceState { return try workspaceFingerprintResolver( buildFiles, modules, - languageServerVersion(for: languageServerExecutableURL) + languageServerCacheIdentity(for: languageServerExecutableURL) ) } @@ -342,6 +342,24 @@ struct MacJdtWorkspaceState { } throw MacJdtWorkspaceStateError.languageServerVersionUnavailable } + + private func languageServerCacheIdentity(for executableURL: URL?) throws -> String { + let version = try languageServerVersion(for: executableURL) + let installationPath: String + if let executableURL { + installationPath = executableURL.standardizedFileURL.path + } else if let resourceURL = Bundle.main.resourceURL { + installationPath = resourceURL + .appendingPathComponent("LanguageServers", isDirectory: true) + .appendingPathComponent("jdtls", isDirectory: true) + .standardizedFileURL.path + } else { + throw MacJdtWorkspaceStateError.languageServerInstallationUnavailable + } + // JDT LS persists absolute JRE and source paths inside its workspace. + // Relocating an app or Preview build must therefore select a fresh cache. + return "\(version)|installation=\(installationPath)" + } } private struct JdtManifest: Decodable { @@ -358,6 +376,7 @@ private enum MacJdtWorkspaceStateError: LocalizedError { case invalidBuildFileMetadata(String) case invalidLanguageServerManifest(String) case languageServerVersionUnavailable + case languageServerInstallationUnavailable case invalidWorkspaceKey case cacheRetentionUnavailable case invalidRetentionPlan @@ -373,6 +392,8 @@ private enum MacJdtWorkspaceStateError: LocalizedError { "The bundled JDT LS manifest is invalid at \(path)." case .languageServerVersionUnavailable: "The bundled JDT LS version could not be determined." + case .languageServerInstallationUnavailable: + "The bundled JDT LS installation could not be determined." case .invalidWorkspaceKey: "Rust Core returned an invalid Java workspace key." case .cacheRetentionUnavailable: diff --git a/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift b/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift index 35337043..3d9c9714 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift @@ -149,6 +149,7 @@ final class MacTerminalTransport: NSObject, TerminalTransport, @preconcurrency L private var selectedShellPath: String? private var suppressNextTermination = false + private let stoppedChildProcessReaper = MacStoppedChildProcessReaper() var isRunning: Bool { view.process.running @@ -296,9 +297,12 @@ final class MacTerminalTransport: NSObject, TerminalTransport, @preconcurrency L } func stop() { - guard view.process.running else { return } + guard let processID else { return } suppressNextTermination = true - view.terminate() + if view.process.running { + view.terminate() + } + stoppedChildProcessReaper.reapWhenExited(processID) } func sizeChanged(source: LocalProcessTerminalView, newCols: Int, newRows: Int) {} diff --git a/macos/Sources/Lithe/Platform/MacOS/UI/MacPlatformUI.swift b/macos/Sources/Lithe/Platform/MacOS/UI/MacPlatformUI.swift index 662b9b30..6509ebf7 100644 --- a/macos/Sources/Lithe/Platform/MacOS/UI/MacPlatformUI.swift +++ b/macos/Sources/Lithe/Platform/MacOS/UI/MacPlatformUI.swift @@ -3,6 +3,10 @@ import Foundation import UniformTypeIdentifiers final class MacPlatformUI: PlatformUI { + func activateApplication() { + NSApplication.shared.activate(ignoringOtherApps: true) + } + func chooseDirectory(title: String, prompt: String) -> URL? { let panel = NSOpenPanel() panel.title = title diff --git a/macos/Sources/Lithe/Services/Debug/DebugLaunchSourceResolver.swift b/macos/Sources/Lithe/Services/Debug/DebugLaunchSourceResolver.swift new file mode 100644 index 00000000..8dac652e --- /dev/null +++ b/macos/Sources/Lithe/Services/Debug/DebugLaunchSourceResolver.swift @@ -0,0 +1,102 @@ +import Foundation +import LitheCoreContracts + +/// Selects the source file that anchors a Debug launch without making the +/// selected Run configuration depend on whichever editor tab is currently open. +struct DebugLaunchSourceResolver { + /// Chooses a project-backed Java target when the remembered Current File + /// entry cannot represent a launchable Java application. IDEA keeps the + /// editor shortcut useful in this situation instead of trying to compile + /// an arbitrary controller, repository, or configuration class alone. + func configurationForDebug( + selected: RunConfiguration, + activeDocumentText: String?, + configurations: [RunConfiguration] + ) -> RunConfiguration { + guard selected.usesCurrentEditorFile else { + return selected + } + if activeDocumentText.map(containsJavaMainMethod) == true { + return selected + } + + return configurations.first { + !$0.usesCurrentEditorFile && $0.kind.mavenFramework != nil + && $0.kind.capabilities.contains(.jdwpDebug) + } ?? configurations.first { + !$0.usesCurrentEditorFile && $0.kind == .javaMain + && $0.kind.capabilities.contains(.jdwpDebug) + } ?? selected + } + + func resolve( + configuration: RunConfiguration, + activeDocumentURL: URL?, + projectFiles: [URL], + workspaceURL: URL + ) -> URL? { + if configuration.usesCurrentEditorFile { + return activeDocumentURL?.standardizedFileURL + } + + let javaFiles = projectFiles + .map(\.standardizedFileURL) + .filter { $0.pathExtension.lowercased() == "java" } + .sorted { $0.path < $1.path } + guard !javaFiles.isEmpty else { return nil } + + let moduleFiles = filesInSelectedModule( + javaFiles, + modulePath: configuration.modulePath, + workspaceURL: workspaceURL + ) + let preferredFiles = moduleFiles.isEmpty ? javaFiles : moduleFiles + + if let sourceSuffix = sourceSuffix(for: configuration.mainClass), + let exactMatch = preferredFiles.first(where: { $0.path.hasSuffix(sourceSuffix) }) + ?? javaFiles.first(where: { $0.path.hasSuffix(sourceSuffix) }) { + return exactMatch + } + + if let activeDocumentURL = activeDocumentURL?.standardizedFileURL, + preferredFiles.contains(activeDocumentURL) { + return activeDocumentURL + } + return preferredFiles.first + } + + private func filesInSelectedModule( + _ files: [URL], + modulePath: String?, + workspaceURL: URL + ) -> [URL] { + guard let modulePath = modulePath?.trimmingCharacters(in: .whitespacesAndNewlines), + !modulePath.isEmpty, + modulePath != "." else { return files } + let moduleURL = workspaceURL + .appendingPathComponent(modulePath, isDirectory: true) + .standardizedFileURL + let modulePrefix = moduleURL.path.hasSuffix("/") ? moduleURL.path : moduleURL.path + "/" + return files.filter { $0.path.hasPrefix(modulePrefix) } + } + + private func sourceSuffix(for mainClass: String?) -> String? { + guard var mainClass = mainClass?.trimmingCharacters(in: .whitespacesAndNewlines), + !mainClass.isEmpty else { return nil } + if let moduleSeparator = mainClass.lastIndex(of: "/") { + mainClass = String(mainClass[mainClass.index(after: moduleSeparator)...]) + } + if let nestedClassSeparator = mainClass.firstIndex(of: "$") { + mainClass = String(mainClass[.. Bool { + source.range( + of: #"(?m)\bstatic\s+(?:public\s+|protected\s+|private\s+)?void\s+main\s*\("#, + options: .regularExpression + ) != nil + } +} diff --git a/macos/Sources/Lithe/Services/Debug/DebugPortAvailabilityChecker.swift b/macos/Sources/Lithe/Services/Debug/DebugPortAvailabilityChecker.swift new file mode 100644 index 00000000..dd3dc0b0 --- /dev/null +++ b/macos/Sources/Lithe/Services/Debug/DebugPortAvailabilityChecker.swift @@ -0,0 +1,14 @@ +import Foundation + +/// Answers whether a local service port can be claimed before a debug launch. +/// The probing mechanism belongs to a platform adapter; application code only +/// consumes this small synchronous capability. +@MainActor +protocol DebugPortAvailabilityChecking: AnyObject { + func isPortAvailable(_ port: Int) -> Bool +} + +@MainActor +final class AlwaysAvailableDebugPortChecker: DebugPortAvailabilityChecking { + func isPortAvailable(_: Int) -> Bool { true } +} diff --git a/macos/Sources/Lithe/Theme/LitheIcons.swift b/macos/Sources/Lithe/Theme/LitheIcons.swift index b136e069..ecddf0b3 100644 --- a/macos/Sources/Lithe/Theme/LitheIcons.swift +++ b/macos/Sources/Lithe/Theme/LitheIcons.swift @@ -167,6 +167,33 @@ enum LitheIcons { ideaAssetPathsBySystemImage[systemImage] } + /// Returns the IntelliJ dark-theme sibling for an imported SVG path. + /// The caller still falls back to the base asset because not every + /// IntelliJ catalog entry ships a dedicated dark variant. + static func darkIdeaAssetPath(for resourcePath: String) -> String { + let path = resourcePath as NSString + let directory = path.deletingLastPathComponent + let filename = path.lastPathComponent as NSString + let resourceName = filename.deletingPathExtension + let darkFilename = "\(resourceName)_dark.\(filename.pathExtension)" + return directory.isEmpty ? darkFilename : "\(directory)/\(darkFilename)" + } + + /// Maps the editor gutter breakpoint state to the matching IntelliJ + /// debugger glyph. The catalog keeps the red set/verified marks and the + /// muted/disabled state visually distinct, just like IDEA's gutter. + static func debuggerBreakpointAssetPath( + enabled: Bool, + verified: Bool, + muted: Bool + ) -> String { + if muted { return "debugger/db_muted_breakpoint.svg" } + if !enabled { return "debugger/db_disabled_breakpoint.svg" } + return verified + ? "debugger/db_verified_breakpoint.svg" + : "debugger/db_set_breakpoint.svg" + } + /// src/main/java、src/test/kotlin 之类的源码根。资源根同样按这个布局 /// 判断,避免把任意一个叫 resources 的目录标成资源根。 static func isSourceRootDirectory(_ url: URL) -> Bool { @@ -521,17 +548,27 @@ struct LitheIcon: View { /// A small SwiftUI bridge for the imported IntelliJ SVG catalog. /// `fallbackSystemImage` keeps the UI usable in an unbundled debug preview. struct LitheIDEAIcon: View { + @Environment(\.colorScheme) private var colorScheme let resourcePath: String var size: CGFloat = 14 var fallbackSystemImage: String? + var preservesOriginalColors = false var body: some View { - if let image = LitheIcons.ideaImage(resourcePath: resourcePath) { - Image(nsImage: image) - .renderingMode(.template) - .resizable() - .interpolation(.high) - .frame(width: size, height: size) + if let image = resolvedImage { + if preservesOriginalColors { + Image(nsImage: image) + .renderingMode(.original) + .resizable() + .interpolation(.high) + .frame(width: size, height: size) + } else { + Image(nsImage: image) + .renderingMode(.template) + .resizable() + .interpolation(.high) + .frame(width: size, height: size) + } } else if let fallbackSystemImage { Image(systemName: fallbackSystemImage) .font(.system(size: size, weight: .medium)) @@ -540,6 +577,16 @@ struct LitheIDEAIcon: View { Color.clear.frame(width: size, height: size) } } + + private var resolvedImage: NSImage? { + if colorScheme == .dark, + let darkImage = LitheIcons.ideaImage( + resourcePath: LitheIcons.darkIdeaAssetPath(for: resourcePath) + ) { + return darkImage + } + return LitheIcons.ideaImage(resourcePath: resourcePath) + } } /// Compatibility wrapper for common existing SF Symbol call sites. It uses diff --git a/macos/Sources/Lithe/Views/Debug/DebugToolbarPresentation.swift b/macos/Sources/Lithe/Views/Debug/DebugToolbarPresentation.swift new file mode 100644 index 00000000..bf141cc5 --- /dev/null +++ b/macos/Sources/Lithe/Views/Debug/DebugToolbarPresentation.swift @@ -0,0 +1,107 @@ +import CoreGraphics +import LitheCoreContracts + +/// Stable IDEA-aligned ordering and icon catalog for the macOS Debug toolbar. +/// Keeping these values outside the view prevents platform symbols or ad-hoc +/// reordering from silently changing the debugger's visual language. +enum DebugToolbarActionID: String, CaseIterable, Identifiable { + case restartOrStart + case stop + case resume + case pause + case stepOver + case stepInto + case stepOut + case viewBreakpoints + case muteBreakpoints + + var id: Self { self } +} + +enum DebugToolbarPresentation { + static let primaryActions: [DebugToolbarActionID] = [ + .restartOrStart, + .stop, + .resume, + .pause, + .stepOver, + .stepInto, + .stepOut, + .viewBreakpoints, + .muteBreakpoints + ] + + static let separatorsAfter: Set = [.stop, .stepOut] + // Keep the primary controls legible at the compact tool-window scale; + // IDEA's debugger gives these actions a little more visual weight than + // ordinary tool-window buttons. + static let iconSize: CGFloat = 18 + static let toolbarHeight: CGFloat = 36 + static let sessionHeaderHeight: CGFloat = 34 + + static func statusText( + for state: DebugAdapterState, + stoppedReason: String? + ) -> String { + switch state { + case .paused: + let reason = stoppedReason?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !reason.isEmpty else { return "Paused" } + return "Paused · \(stopReasonLabel(reason))" + case .running: return "Running" + case .launching: return "Launching" + case .initializing: return "Initializing" + case .ready: return "Ready" + case .terminated: return "Finished" + case .failed: return "Failed" + case .idle: return "Ready" + } + } + + private static func stopReasonLabel(_ reason: String) -> String { + switch reason.lowercased() { + case "breakpoint": return "Breakpoint" + case "function breakpoint": return "Method breakpoint" + case "data breakpoint": return "Field breakpoint" + case "instruction breakpoint": return "Instruction breakpoint" + case "exception": return "Exception" + case "step": return "Step" + case "pause": return "Pause" + case "entry": return "Entry" + case "goto": return "Run to cursor" + default: return reason + } + } + + static func ideaAssetPath( + for action: DebugToolbarActionID, + isSessionActive: Bool = true + ) -> String { + switch action { + case .restartOrStart: + isSessionActive ? "debugger/restartDebug.svg" : "debugger/debug.svg" + case .stop: "debugger/stop.svg" + case .resume: "debugger/resume.svg" + case .pause: "debugger/pause.svg" + case .stepOver: "debugger/stepOver.svg" + case .stepInto: "debugger/stepInto.svg" + case .stepOut: "debugger/stepOut.svg" + case .viewBreakpoints: "debugger/viewBreakpoints.svg" + case .muteBreakpoints: "debugger/muteBreakpoints.svg" + } + } + + static func fallbackSystemImage(for action: DebugToolbarActionID) -> String { + switch action { + case .restartOrStart: "ladybug.fill" + case .stop: "stop.fill" + case .resume: "play.fill" + case .pause: "pause.fill" + case .stepOver: "arrow.right.to.line" + case .stepInto: "arrow.down.to.line" + case .stepOut: "arrow.up.to.line" + case .viewBreakpoints: "list.bullet.rectangle" + case .muteBreakpoints: "eye.slash" + } + } +} diff --git a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift index 9cb95d2d..2657074c 100644 --- a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift +++ b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift @@ -88,8 +88,6 @@ struct GenericDebugView: View { switch selectedContent { case .debugger: inspector - case .breakpoints: - DebugBreakpointManagerView(feature: feature) case .console: debugConsole } @@ -101,96 +99,113 @@ struct GenericDebugView: View { Button { selectedContent = content } label: { - HStack(spacing: 5) { - Image(systemName: content.systemImage) - .font(.system(size: 10, weight: .medium)) - Text(content.title) - .font(.system(size: 11, weight: .medium)) - } + Text(content.title) + .font(.system(size: 11.5, weight: .medium)) .foregroundStyle( selectedContent == content ? LitheTheme.primaryText : LitheTheme.secondaryText ) - .padding(.horizontal, 12) - .frame(height: 29) - .overlay(alignment: .bottom) { + .padding(.horizontal, 11) + .frame(height: 25) + .background( + RoundedRectangle(cornerRadius: 5) + .fill(selectedContent == content + ? LitheTheme.selection + : Color.clear) + ) + .overlay { if selectedContent == content { - Rectangle() - .fill(LitheTheme.accent) - .frame(height: 2) + RoundedRectangle(cornerRadius: 5) + .stroke(LitheTheme.accent.opacity(0.65), lineWidth: 1) } } .contentShape(Rectangle()) } .buttonStyle(.plain) + .lithePointer() } Spacer(minLength: 0) - if feature.state == .paused { - Label(feature.stoppedReason ?? "Paused", systemImage: "pause.circle.fill") - .font(.system(size: 10.5, weight: .medium)) - .foregroundStyle(LitheTheme.warning) - .lineLimit(1) - .padding(.trailing, 10) - } } + .padding(.horizontal, 8) + .frame(height: 32) .litheWorkbenchSurface(LitheTheme.toolHeader) } private var header: some View { - LitheToolWindowHeader( - title: "Debug", - systemImage: "ladybug", - ideaAssetPath: "toolwindows/toolWindowDebugger.svg", - subtitle: feature.state.title, - onMinimize: { model.isDebugVisible = false } - ) { - if let providerID = feature.providerID { - Text(providerID.uppercased()) - .font(.system(size: 10.5, weight: .semibold)) - .foregroundStyle(LitheTheme.secondaryText) - } - if let targetTitle = feature.targetTitle { - Text(targetTitle) - .font(.system(size: 11.5, weight: .medium)) - .foregroundStyle(LitheTheme.secondaryText) - .lineLimit(1) + HStack(spacing: 10) { + Text("Debug") + .font(.system(size: 12.5, weight: .semibold)) + .foregroundStyle(LitheTheme.toolWindowText) + if let sessionTitle = debugSessionTitle { + debugSessionTab(sessionTitle) } if feature.sessionSummaries.count > 1 { sessionPicker } - Spacer() - Button { model.showDebugBreakpointManager() } label: { - Image(systemName: "list.bullet.rectangle") + if !feature.isSessionActive { + debugConfigurationPicker } - .litheIconButton() - .help("View breakpoints (⌘⇧F8)") - .accessibilityLabel("View breakpoints") - Button { feature.toggleBreakpointMute() } label: { - Image(systemName: feature.areBreakpointsMuted ? "eye.slash" : "eye") + Spacer(minLength: 8) + debugOptionsMenu + Button { model.isDebugVisible = false } label: { + Image(systemName: "minus") } .litheIconButton() - .disabled(feature.breakpoints.isEmpty) - .help(feature.areBreakpointsMuted ? "Enable breakpoints" : "Mute breakpoints") - .accessibilityLabel(feature.areBreakpointsMuted ? "Enable breakpoints" : "Mute breakpoints") - if feature.javaSteppingFilters != nil { - Button { isJavaSteppingSettingsPresented = true } label: { - Image(systemName: "line.3.horizontal.decrease.circle") + .help("Hide Debug tool window") + } + .padding(.leading, 12) + .padding(.trailing, 7) + .frame(height: DebugToolbarPresentation.sessionHeaderHeight) + .litheWorkbenchSurface(LitheTheme.toolHeader) + } + + private var debugSessionTitle: String? { + if let targetTitle = feature.targetTitle, !targetTitle.isEmpty { + return targetTitle + } + if let providerID = feature.providerID, !providerID.isEmpty { + return providerID.uppercased() + } + if let selectedConfiguration = model.runFeatureIfActive?.selectedConfiguration, + !selectedConfiguration.name.isEmpty { + return selectedConfiguration.name + } + return nil + } + + private func debugSessionTab(_ title: String) -> some View { + HStack(spacing: 6) { + LitheIDEAIcon( + resourcePath: "debugger/debug.svg", + size: 14, + fallbackSystemImage: "ladybug.fill", + preservesOriginalColors: true + ) + Text(title) + .font(.system(size: 11.5, weight: .medium)) + .lineLimit(1) + if feature.isSessionActive { + Button(action: stopActiveDebugSession) { + Image(systemName: "xmark") + .font(.system(size: 8, weight: .semibold)) + .foregroundStyle(LitheTheme.secondaryText) + .frame(width: 16, height: 16) } - .litheIconButton() - .disabled(feature.isSessionActive) - .help("Java stepping filters") - } - Button { isJavaAttachPresented = true } label: { - Image(systemName: "link") - } - .litheIconButton() - .disabled(feature.isSessionActive) - .help("Connect to running JVM") - controlButton("trash", help: "Clear output", disabled: false) { - feature.clearOutput() + .buttonStyle(.plain) + .lithePointer() + .help("Stop debug session") } } + .foregroundStyle(LitheTheme.primaryText) + .padding(.leading, 8) + .padding(.trailing, feature.isSessionActive ? 4 : 8) + .frame(height: 25) + .background(RoundedRectangle(cornerRadius: 5).fill(LitheTheme.selection)) + .overlay { + RoundedRectangle(cornerRadius: 5) + .stroke(LitheTheme.accent.opacity(0.7), lineWidth: 1) + } } private var sessionPicker: some View { @@ -224,7 +239,12 @@ struct GenericDebugView: View { } } } label: { - Image(systemName: "square.stack.3d.up") + LitheIDEAIcon( + resourcePath: "debugger/threads.svg", + size: 14, + fallbackSystemImage: "square.stack.3d.up", + preservesOriginalColors: true + ) } .litheIconButton() .help("Debug sessions") @@ -241,175 +261,335 @@ struct GenericDebugView: View { return "\(summary.providerDisplayName) · \(rootName)" } - private var debugToolbar: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 3) { - toolbarGroup { + /// Keeps the Debug entry point visibly tied to the same Run configuration + /// used by the Run tool window. IDEA exposes this choice next to the + /// debugger session rather than hiding it behind a second, unrelated + /// launch flow. + private var debugConfigurationPicker: some View { + Menu { + if let runFeature = model.runFeatureIfActive, + !runFeature.configurations.isEmpty { + ForEach(runFeature.configurations) { configuration in Button { - if feature.isSessionActive { - if feature.canTerminate { - feature.execute(.terminate) - } else { - model.stopDebugging() + model.selectRunConfiguration(configuration) + } label: { + HStack(spacing: 7) { + RunConfigurationIcon(kind: configuration.kind, size: 14) + Text(configuration.name) + if configuration.id == runFeature.selectedConfiguration?.id { + Spacer(minLength: 8) + Image(systemName: "checkmark") } - } else if feature.canRetry { - _ = feature.retry() - } else { - model.startDebugging() } - } label: { - Image(systemName: feature.isSessionActive ? "stop.fill" : feature.canRetry ? "arrow.clockwise" : "play.fill") - } - .litheIconButton() - .foregroundStyle(feature.isSessionActive ? LitheTheme.warning : LitheTheme.success) - .help( - feature.isSessionActive - ? "Stop debugging" - : feature.canRetry ? "Retry debugging" : "Start debugging" - ) - - controlButton( - feature.state == .running ? "pause.fill" : "play.fill", - help: feature.state == .running ? "Pause" : "Resume", - disabled: !feature.canControl - ) { - feature.execute(feature.state == .running ? .pause : .continueExecution) } } - - toolbarDivider - - toolbarGroup { - controlButton("arrow.right.to.line", help: "Step over", disabled: feature.state != .paused) { - feature.execute(.next) - } - controlButton("arrow.down.to.line", help: "Step into", disabled: feature.state != .paused) { - feature.execute(.stepIn) - } - if feature.capabilities.supportsStepInTargetsRequest { - smartStepButton - } - controlButton("arrow.up.to.line", help: "Step out", disabled: feature.state != .paused) { - feature.execute(.stepOut) - } - if feature.capabilities.supportsStepBack { - controlButton("arrow.uturn.backward", help: "Step back", disabled: !feature.canStepBack) { - feature.execute(.stepBack) - } - } + } else { + Button("Current File") { + model.selectRunConfiguration(.currentFile) } + } + } label: { + HStack(spacing: 5) { + RunConfigurationIcon( + kind: model.runFeatureIfActive?.selectedConfiguration?.kind ?? .currentFile, + size: 13 + ) + Text(model.runFeatureIfActive?.selectedConfiguration?.name ?? "Current File") + .font(.system(size: 11, weight: .medium)) + .lineLimit(1) + Image(systemName: "chevron.down") + .font(.system(size: 8, weight: .bold)) + } + .foregroundStyle(LitheTheme.primaryText) + .padding(.horizontal, 8) + .frame(maxWidth: 210, minHeight: 25) + .background(RoundedRectangle(cornerRadius: 5).fill(LitheTheme.selection.opacity(0.72))) + .contentShape(Rectangle()) + } + .menuStyle(.borderlessButton) + .fixedSize(horizontal: true, vertical: false) + .help("Select the Run configuration used by Debug") + .accessibilityLabel("Debug run configuration") + .accessibilityIdentifier("debug-run-configuration-picker") + } - if feature.capabilities.supportsRestartRequest { - toolbarDivider - controlButton("arrow.clockwise", help: "Rerun", disabled: !feature.canRestart) { - feature.execute(.restart) + private var debugToolbar: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 2) { + ForEach(DebugToolbarPresentation.primaryActions) { action in + debugToolbarActionButton(action) + if DebugToolbarPresentation.separatorsAfter.contains(action) { + toolbarDivider } } - + debugOptionsMenu + debugExecutionStatus Spacer(minLength: 8) - - if let frame = feature.selectedFrame { - HStack(spacing: 5) { - Image(systemName: feature.state == .paused ? "pause.circle.fill" : "circle") - .foregroundStyle(feature.state == .paused ? LitheTheme.warning : LitheTheme.secondaryText) - VStack(alignment: .leading, spacing: 0) { - Text(feature.state == .paused ? "Paused" : feature.state.title) - .font(.system(size: 10, weight: .semibold)) - if let sourceURL = frame.sourceURL { - Text("\(sourceURL.lastPathComponent):\(frame.line)") - .font(.system(size: 9.5, design: .monospaced)) - .foregroundStyle(LitheTheme.secondaryText) - .lineLimit(1) - } - } - } - .foregroundStyle(LitheTheme.primaryText) - .padding(.horizontal, 7) - .help(frame.name) - } else { - Text(feature.state.title) - .font(.system(size: 10, weight: .medium)) - .foregroundStyle(LitheTheme.secondaryText) - .padding(.horizontal, 7) - } } .padding(.horizontal, 8) - .frame(minHeight: 34) + .frame(height: DebugToolbarPresentation.toolbarHeight) } .litheWorkbenchSurface(LitheTheme.toolHeader) + .popover(isPresented: $isSmartStepPickerPresented, arrowEdge: .bottom) { + smartStepPicker + } } - @ViewBuilder - private var smartStepButton: some View { - Button { - feature.requestSmartStepInto { result in - guard case .success(let targets) = result else { return } - if targets.count == 1, let target = targets.first { - feature.smartStepInto(target) - } else { - smartStepTargets = targets - isSmartStepPickerPresented = true - } - } + private func debugToolbarActionButton(_ action: DebugToolbarActionID) -> some View { + let isDisabled = isDebugToolbarActionDisabled(action) + return Button { + performDebugToolbarAction(action) } label: { - Image(systemName: "arrow.down.right.and.arrow.up.left") + LitheIDEAIcon( + resourcePath: DebugToolbarPresentation.ideaAssetPath( + for: action, + isSessionActive: feature.isSessionActive + ), + size: DebugToolbarPresentation.iconSize, + fallbackSystemImage: DebugToolbarPresentation.fallbackSystemImage(for: action), + preservesOriginalColors: true + ) + .frame(width: DebugToolbarPresentation.iconSize, height: DebugToolbarPresentation.iconSize) } .litheIconButton() - .disabled(feature.state != .paused || feature.selectedFrameID == nil) - .help("Smart step into") - .popover(isPresented: $isSmartStepPickerPresented, arrowEdge: .bottom) { - VStack(alignment: .leading, spacing: 4) { - Text("Choose Step Target") - .font(.system(size: 11, weight: .semibold)) - .padding(.horizontal, 8) - .padding(.top, 6) - if smartStepTargets.isEmpty { - Text("No callable target at this location") - .font(LitheTheme.smallFont) - .foregroundStyle(LitheTheme.secondaryText) - .padding(8) - } else { - ForEach(smartStepTargets) { target in - Button(target.label) { - feature.smartStepInto(target) - isSmartStepPickerPresented = false - } - .buttonStyle(.plain) - .font(.system(size: 11, design: .monospaced)) - .padding(.horizontal, 8) - .padding(.vertical, 4) - } + .frame(width: 32, height: 30) + .disabled(isDisabled) + .opacity(isDisabled ? 0.36 : 1) + .help(debugToolbarActionHelp(action)) + .accessibilityLabel(debugToolbarActionHelp(action)) + .accessibilityIdentifier("debug-toolbar-\(action.rawValue)") + } + + private func performDebugToolbarAction(_ action: DebugToolbarActionID) { + switch action { + case .restartOrStart: + if feature.isSessionActive { + feature.execute(.restart) + } else if feature.canRetry { + _ = feature.retry() + } else { + model.startDebugging() + } + case .stop: + stopActiveDebugSession() + case .resume: + feature.execute(.continueExecution) + case .pause: + feature.execute(.pause) + case .stepOver: + feature.execute(.next) + case .stepInto: + feature.execute(.stepIn) + case .stepOut: + feature.execute(.stepOut) + case .viewBreakpoints: + model.showDebugBreakpointManager() + case .muteBreakpoints: + feature.toggleBreakpointMute() + } + } + + private func isDebugToolbarActionDisabled(_ action: DebugToolbarActionID) -> Bool { + switch action { + case .restartOrStart: + feature.isSessionActive && !feature.canRestart + case .stop: + !feature.isSessionActive + case .resume: + feature.state != .paused || feature.isExecutionRequestPending + case .pause: + feature.state != .running || feature.isExecutionRequestPending + case .stepOver, .stepInto, .stepOut: + // DAP step requests require a concrete stopped thread. Keep the + // toolbar disabled during the short inspection window after a + // stop event instead of sending a no-op request with no thread. + feature.state != .paused || feature.selectedThreadID == nil + || feature.isExecutionRequestPending + case .viewBreakpoints: + model.workspaceURL == nil + case .muteBreakpoints: + feature.breakpoints.isEmpty + } + } + + private func debugToolbarActionHelp(_ action: DebugToolbarActionID) -> String { + let title: String + switch action { + case .restartOrStart: + title = feature.isSessionActive ? "Rerun" : feature.canRetry ? "Retry debugging" : "Start debugging" + case .stop: title = "Stop debugging" + case .resume: title = "Resume" + case .pause: title = "Pause" + case .stepOver: title = "Step over" + case .stepInto: title = "Step into" + case .stepOut: title = "Step out" + case .viewBreakpoints: title = "View breakpoints" + case .muteBreakpoints: + title = feature.areBreakpointsMuted ? "Enable breakpoints" : "Mute breakpoints" + } + guard let commandID = debugToolbarCommandID(for: action), + let shortcut = model.keyboardShortcutFeature.displayText(for: commandID), + !shortcut.isEmpty else { + return title + } + return "\(title) (\(shortcut))" + } + + private func debugToolbarCommandID(for action: DebugToolbarActionID) -> String? { + switch action { + case .restartOrStart: "debug" + case .stop: "stop-debug" + case .resume: "debug-resume" + case .pause: nil + case .stepOver: "debug-step-over" + case .stepInto: "debug-step-into" + case .stepOut: "debug-step-out" + case .viewBreakpoints: "view-breakpoints" + case .muteBreakpoints: nil + } + } + + private func stopActiveDebugSession() { + if feature.canTerminate { + feature.execute(.terminate) + } else { + model.stopDebugging() + } + } + + private var toolbarDivider: some View { + Rectangle() + .fill(LitheTheme.divider) + .frame(width: 1, height: 18) + .padding(.horizontal, 3) + } + + private var debugExecutionStatus: some View { + HStack(spacing: 5) { + Circle() + .fill(debugStatusColor) + .frame(width: 6, height: 6) + Text(debugStatusText) + .font(.system(size: 10.5, weight: .medium)) + .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(1) + if let frame = feature.selectedFrame, + let sourceURL = frame.sourceURL { + Button { + model.revealDebugLocation( + url: sourceURL, + line: frame.line, + column: frame.column + ) + } label: { + Text("· \(sourceURL.lastPathComponent):\(frame.line)") + .font(.system(size: 9.5, design: .monospaced)) + .foregroundStyle(LitheTheme.secondaryText.opacity(0.82)) + .lineLimit(1) } + .buttonStyle(.plain) + .help("Reveal stopped location in editor") + .accessibilityLabel("Reveal stopped location in editor") } - .frame(minWidth: 230) - .padding(.vertical, 4) } + .padding(.horizontal, 8) + .frame(height: 22) + .background( + Capsule() + .fill(LitheTheme.selection.opacity(0.58)) + ) + .help(debugStatusText) + .accessibilityElement(children: .combine) + .accessibilityLabel(debugStatusText) } - private func toolbarGroup(@ViewBuilder content: () -> Content) -> some View { - HStack(spacing: 1, content: content) - .padding(2) - .background(LitheTheme.inputBackground.opacity(0.6)) - .clipShape(RoundedRectangle(cornerRadius: 4)) + private var debugStatusText: String { + DebugToolbarPresentation.statusText( + for: feature.state, + stoppedReason: feature.stoppedReason + ) } - private var toolbarDivider: some View { - Rectangle() - .fill(LitheTheme.divider) - .frame(width: 1, height: 20) - .padding(.horizontal, 4) + private var debugStatusColor: Color { + switch feature.state { + case .paused: return LitheTheme.warning + case .failed: return LitheTheme.error + case .terminated, .idle: return LitheTheme.secondaryText + default: return LitheTheme.success + } } - private func controlButton( - _ image: String, - help: String, - disabled: Bool, - action: @escaping () -> Void - ) -> some View { - Button(action: action) { Image(systemName: image) } - .litheIconButton() - .disabled(disabled) - .help(help) + private var debugOptionsMenu: some View { + Menu { + if feature.capabilities.supportsStepInTargetsRequest { + Button("Smart Step Into") { requestSmartStepInto() } + .disabled(feature.state != .paused || feature.selectedFrameID == nil) + } + if feature.capabilities.supportsStepBack { + Button("Step Back") { feature.execute(.stepBack) } + .disabled(!feature.canStepBack) + } + if feature.javaSteppingFilters != nil { + Button("Java Stepping Filters…") { + isJavaSteppingSettingsPresented = true + } + .disabled(feature.isSessionActive) + } + Divider() + Button("Connect to Running JVM…") { isJavaAttachPresented = true } + .disabled(feature.isSessionActive) + Button("Clear Console") { feature.clearOutput() } + .disabled(feature.output.isEmpty) + } label: { + LitheIDEAIcon( + resourcePath: "actions/moreVertical.svg", + size: 15, + fallbackSystemImage: "ellipsis" + ) + } + .litheIconButton() + .help("More Debug actions") + .accessibilityLabel("More Debug actions") + } + + private func requestSmartStepInto() { + feature.requestSmartStepInto { result in + guard case .success(let targets) = result else { return } + if targets.count == 1, let target = targets.first { + feature.smartStepInto(target) + } else { + smartStepTargets = targets + isSmartStepPickerPresented = true + } + } + } + + private var smartStepPicker: some View { + VStack(alignment: .leading, spacing: 4) { + Text("Choose Step Target") + .font(.system(size: 11, weight: .semibold)) + .padding(.horizontal, 8) + .padding(.top, 6) + if smartStepTargets.isEmpty { + Text("No callable target at this location") + .font(LitheTheme.smallFont) + .foregroundStyle(LitheTheme.secondaryText) + .padding(8) + } else { + ForEach(smartStepTargets) { target in + Button(target.label) { + feature.smartStepInto(target) + isSmartStepPickerPresented = false + } + .buttonStyle(.plain) + .font(.system(size: 11, design: .monospaced)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + } + } + } + .frame(minWidth: 230) + .padding(.vertical, 4) } private var inspector: some View { @@ -424,127 +604,99 @@ struct GenericDebugView: View { } private var executionInspector: some View { - ScrollView { - LazyVStack(alignment: .leading, spacing: 0) { - sectionHeader("Threads", count: feature.threads.count) - if feature.threads.isEmpty { - Button("Load threads") { feature.inspectThreads() } - .buttonStyle(.plain) - .font(LitheTheme.smallFont) - .foregroundStyle(LitheTheme.accent) - .padding(10) - } else { - ForEach(feature.threads) { thread in - rowButton(selected: feature.selectedThreadID == thread.id) { - feature.selectThread(thread) - } label: { - Image(systemName: threadIcon(thread)) - .foregroundStyle(threadColor(thread)) - Text(thread.name).lineLimit(1) - } - .contextMenu { - Button("Copy Thread Name") { - copyToPasteboard(thread.name) - } - if feature.capabilities.supportsSingleThreadExecutionRequests { - Divider() - Button(feature.state == .paused ? "Resume Thread" : "Pause Thread") { - feature.executeThread( - feature.state == .paused ? .continueExecution : .pause, - thread: thread - ) - } - .disabled(feature.state != .paused && feature.state != .running) + VStack(spacing: 0) { + threadPicker + divider + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + if feature.stackFrames.isEmpty { + placeholder("Pause the process to inspect frames") + } else { + if feature.areFilteredStackFramesExpanded, + feature.hiddenStackFrameCount > 0 { + Button { + feature.collapseFilteredStackFrames() + } label: { + Label("Collapse filtered frames", systemImage: "rectangle.compress.vertical") + .font(LitheTheme.smallFont) + .foregroundStyle(LitheTheme.secondaryText) + .padding(.horizontal, 10) + .frame(minHeight: 27) + .frame(maxWidth: .infinity, alignment: .leading) } + .buttonStyle(.plain) } - } - } - - divider - sectionHeader("Call Stack", count: feature.stackFrames.count) - if feature.stackFrames.isEmpty { - placeholder("Pause the process to inspect frames") - } else { - if feature.areFilteredStackFramesExpanded, - feature.hiddenStackFrameCount > 0 { - Button { - feature.collapseFilteredStackFrames() - } label: { - Label("Collapse filtered frames", systemImage: "rectangle.compress.vertical") - .font(LitheTheme.smallFont) - .foregroundStyle(LitheTheme.secondaryText) - .padding(.horizontal, 10) - .frame(minHeight: 27) - .frame(maxWidth: .infinity, alignment: .leading) - } - .buttonStyle(.plain) - } - ForEach(feature.visibleStackFrameRows) { row in - if let frame = row.frame { - rowButton(selected: feature.selectedFrameID == frame.id) { - feature.selectFrame(frame) - if let sourceURL = frame.sourceURL { - model.openSourceLocation( - url: sourceURL, - line: frame.line, - column: frame.column - ) - } - } label: { - Image(systemName: frame.isFiltered - ? "ellipsis" - : feature.selectedFrameID == frame.id - ? "pause.fill" - : "chevron.right") - .foregroundStyle( - feature.selectedFrameID == frame.id - ? LitheTheme.warning - : LitheTheme.secondaryText - ) - VStack(alignment: .leading, spacing: 1) { - Text(frame.name).lineLimit(1) + ForEach(feature.visibleStackFrameRows) { row in + if let frame = row.frame { + rowButton(selected: feature.selectedFrameID == frame.id) { + feature.selectFrame(frame) if let sourceURL = frame.sourceURL { - Text("\(sourceURL.lastPathComponent):\(frame.line)") - .font(.system(size: 9.5, design: .monospaced)) + model.revealDebugLocation( + url: sourceURL, + line: frame.line, + column: frame.column + ) + } + } label: { + if frame.isFiltered { + Image(systemName: "ellipsis") + .foregroundStyle(LitheTheme.secondaryText) + } else if feature.selectedFrameID == frame.id { + LitheIDEAIcon( + resourcePath: "debugger/frame.svg", + size: 14, + fallbackSystemImage: "pause.fill", + preservesOriginalColors: true + ) + } else { + Image(systemName: "chevron.right") .foregroundStyle(LitheTheme.secondaryText) } + VStack(alignment: .leading, spacing: 1) { + Text(frame.name).lineLimit(1) + if let sourceURL = frame.sourceURL { + Text("\(sourceURL.lastPathComponent):\(frame.line)") + .font(.system(size: 9.5, design: .monospaced)) + .foregroundStyle(LitheTheme.secondaryText) + } + } } - } - .opacity(frame.isFiltered ? 0.58 : 1) - .contextMenu { - Button("Copy Method Name") { - copyToPasteboard(frame.name) - } - if let sourceURL = frame.sourceURL { - Divider() - Button("Copy Source Location") { - copyToPasteboard( - "\(sourceURL.path):\(frame.line):\(frame.column)" - ) + .opacity(frame.isFiltered ? 0.58 : 1) + .contextMenu { + Button("Copy Method Name") { + copyToPasteboard(frame.name) } - Button("Copy Relative Location") { - copyToPasteboard( - "\(sourceURL.lastPathComponent):\(frame.line):\(frame.column)" - ) + if let sourceURL = frame.sourceURL { + Divider() + Button("Copy Source Location") { + copyToPasteboard( + "\(sourceURL.path):\(frame.line):\(frame.column)" + ) + } + Button("Copy Relative Location") { + copyToPasteboard( + "\(sourceURL.lastPathComponent):\(frame.line):\(frame.column)" + ) + } } } + } else { + Button { + feature.expandFilteredStackFrames() + } label: { + Label( + "\(row.hiddenFrameCount) hidden frames", + systemImage: "ellipsis.circle" + ) + .font(LitheTheme.smallFont) + .foregroundStyle(LitheTheme.secondaryText) + .padding(.horizontal, 10) + .frame(minHeight: 27) + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + .help("Show JDK, proxy, and framework frames") } - } else { - Button { - feature.expandFilteredStackFrames() - } label: { - Label( - "\(row.hiddenFrameCount) filtered frames", - systemImage: "ellipsis.circle" - ) - .font(LitheTheme.smallFont) - .foregroundStyle(LitheTheme.secondaryText) - .padding(.horizontal, 10) - .frame(minHeight: 27) - .frame(maxWidth: .infinity, alignment: .leading) - } - .buttonStyle(.plain) - .help("Show JDK, proxy, and framework frames") } } } @@ -554,19 +706,94 @@ struct GenericDebugView: View { .litheWorkbenchSurface(LitheTheme.sidebar) } + private var threadPicker: some View { + Menu { + if feature.threads.isEmpty { + Button("Load threads") { feature.inspectThreads() } + } else { + ForEach(feature.threads) { thread in + Button { + feature.selectThread(thread) + } label: { + HStack(spacing: 7) { + LitheIDEAIcon( + resourcePath: threadIconResourcePath(thread), + size: 14, + fallbackSystemImage: threadIcon(thread), + preservesOriginalColors: true + ) + Text(thread.name) + } + } + } + } + } label: { + HStack(spacing: 7) { + if let thread = selectedThread { + LitheIDEAIcon( + resourcePath: threadIconResourcePath(thread), + size: 14, + fallbackSystemImage: threadIcon(thread), + preservesOriginalColors: true + ) + Text(thread.name) + .lineLimit(1) + Text(feature.state == .paused ? "Paused" : feature.state.title) + .font(.system(size: 9.5)) + .foregroundStyle(LitheTheme.secondaryText) + } else { + LitheIDEAIcon( + resourcePath: "debugger/threadSuspended.svg", + size: 14, + fallbackSystemImage: "circle.dotted", + preservesOriginalColors: true + ) + Text(feature.threads.isEmpty ? "Load threads" : "Select thread") + } + Spacer(minLength: 0) + Image(systemName: "chevron.down") + .font(.system(size: 8, weight: .semibold)) + .foregroundStyle(LitheTheme.secondaryText) + } + .font(.system(size: 10.5, weight: .medium)) + .foregroundStyle(LitheTheme.primaryText) + .padding(.horizontal, 9) + .frame(height: 28) + .contentShape(Rectangle()) + } + .menuStyle(.borderlessButton) + .accessibilityLabel("Debugger thread") + .contextMenu { + if let thread = selectedThread { + Button("Copy Thread Name") { copyToPasteboard(thread.name) } + if feature.capabilities.supportsSingleThreadExecutionRequests { + Button(feature.state == .paused ? "Resume Thread" : "Pause Thread") { + feature.executeThread( + feature.state == .paused ? .continueExecution : .pause, + thread: thread + ) + } + .disabled(feature.state != .paused && feature.state != .running) + } + } + } + } + + private var selectedThread: DebugThread? { + feature.threads.first { $0.id == feature.selectedThreadID } + } + private var dataInspector: some View { VStack(spacing: 0) { + evaluateRow + divider ScrollView { LazyVStack(alignment: .leading, spacing: 0) { if let exceptionInfo = feature.exceptionInfo { exceptionInspector(exceptionInfo) divider } - if !feature.scopes.isEmpty { - scopePicker - divider - } - sectionHeader("Variables", count: feature.variables.count) + variablesHeader if feature.visibleVariableRows.isEmpty { placeholder("Select a stack frame to inspect variables") } else { @@ -574,9 +801,16 @@ struct GenericDebugView: View { switch row.content { case .variable(let variable): HStack(alignment: .firstTextBaseline, spacing: 6) { - Image(systemName: variableSymbol(variable)) - .font(.system(size: variable.isExpandable ? 8 : 4)) + Image(systemName: variableDisclosureSymbol(variable)) + .font(.system(size: 8, weight: .semibold)) .foregroundStyle(LitheTheme.secondaryText) + .frame(width: 9) + .opacity(variable.isExpandable ? 1 : 0) + LitheIDEAIcon( + resourcePath: variableIconResourcePath(variable), + size: 13, + fallbackSystemImage: "circle.fill" + ) Text(variable.name) .font(.system(size: 10.5, design: .monospaced)) Text("=") @@ -586,7 +820,7 @@ struct GenericDebugView: View { .foregroundStyle(LitheTheme.accent) .lineLimit(2) if let type = variable.type, !type.isEmpty { - Text(": (type)") + Text(": \(type)") .font(.system(size: 9.5, design: .monospaced)) .foregroundStyle(LitheTheme.secondaryText) .lineLimit(1) @@ -643,9 +877,11 @@ struct GenericDebugView: View { } else { ForEach(feature.watches) { watch in HStack(alignment: .firstTextBaseline, spacing: 6) { - Image(systemName: "eye") - .font(.system(size: 9)) - .foregroundStyle(LitheTheme.secondaryText) + LitheIDEAIcon( + resourcePath: "debugger/watch.svg", + size: 13, + fallbackSystemImage: "eye" + ) VStack(alignment: .leading, spacing: 2) { Text(watch.expression) .font(.system(size: 10.5, design: .monospaced)) @@ -690,40 +926,57 @@ struct GenericDebugView: View { } } } - divider - evaluateRow } .frame(maxWidth: .infinity, maxHeight: .infinity) .litheWorkbenchSurface(LitheTheme.sidebar) } - private var scopePicker: some View { - VStack(alignment: .leading, spacing: 0) { - sectionHeader("Scopes", count: feature.scopes.count) - ForEach(feature.scopes) { scope in - Button { - feature.selectScope(scope) - } label: { - HStack(spacing: 7) { - Image(systemName: feature.selectedScopeID == scope.id ? "circle.inset.filled" : "circle") - .font(.system(size: 9)) - Text(scope.name) - .font(.system(size: 10.5)) - if scope.expensive { - Text("expensive") - .font(.system(size: 9)) - .foregroundStyle(LitheTheme.secondaryText) + private var variablesHeader: some View { + HStack(spacing: 7) { + Text("Variables") + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(LitheTheme.secondaryText) + Text(String(feature.presentedVariables.count)) + .font(.system(size: 9.5, design: .monospaced)) + .foregroundStyle(LitheTheme.secondaryText) + Spacer(minLength: 0) + if !feature.scopes.isEmpty { + Menu { + ForEach(feature.scopes) { scope in + Button { + feature.selectScope(scope) + } label: { + Label( + scope.name, + systemImage: feature.selectedScopeID == scope.id + ? "checkmark" + : "circle" + ) } - Spacer(minLength: 0) } - .foregroundStyle(feature.selectedScopeID == scope.id ? LitheTheme.accent : LitheTheme.primaryText) - .padding(.horizontal, 10) - .padding(.vertical, 5) - .contentShape(Rectangle()) + } label: { + HStack(spacing: 4) { + Text(selectedScopeName) + .lineLimit(1) + Image(systemName: "chevron.down") + .font(.system(size: 8, weight: .semibold)) + } + .font(.system(size: 9.5)) + .foregroundStyle(LitheTheme.secondaryText) } - .buttonStyle(.plain) + .menuStyle(.borderlessButton) + .accessibilityLabel("Variable scope") } } + .padding(.horizontal, 10) + .frame(height: 27) + .litheWorkbenchSurface(LitheTheme.toolHeader) + } + + private var selectedScopeName: String { + feature.scopes.first { $0.id == feature.selectedScopeID }?.name + ?? feature.scopes.first?.name + ?? "Scope" } private func copyToPasteboard(_ value: String) { @@ -812,21 +1065,37 @@ struct GenericDebugView: View { private var evaluateRow: some View { HStack(spacing: 6) { - Image(systemName: "function") - .foregroundStyle(LitheTheme.secondaryText) + LitheIDEAIcon( + resourcePath: "debugger/evaluateExpression.svg", + size: 16, + fallbackSystemImage: "function", + preservesOriginalColors: true + ) TextField("Evaluate expression", text: $evaluateExpression) .textFieldStyle(.plain) .font(.system(size: 11, design: .monospaced)) .onSubmit { addWatchExpression() } Button { addWatchExpression() } label: { - Image(systemName: "plus.circle") + LitheIDEAIcon( + resourcePath: "actions/add.svg", + size: 14, + fallbackSystemImage: "plus.circle", + preservesOriginalColors: true + ) } .litheIconButton() .help("Add watch") Button { feature.evaluate(evaluateExpression) } label: { - Image(systemName: "arrow.right.circle") + LitheIDEAIcon( + resourcePath: "actions/execute.svg", + size: 14, + fallbackSystemImage: "arrow.right.circle", + preservesOriginalColors: true + ) } .litheIconButton() + .disabled(feature.state != .paused || evaluateExpression.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .help("Evaluate expression") } .padding(.horizontal, 10) .frame(height: 32) @@ -842,7 +1111,12 @@ struct GenericDebugView: View { .font(.system(size: 9.5, design: .monospaced)) .foregroundStyle(LitheTheme.secondaryText) Button { feature.refreshWatches() } label: { - Image(systemName: "arrow.clockwise") + LitheIDEAIcon( + resourcePath: "actions/refresh.svg", + size: 13, + fallbackSystemImage: "arrow.clockwise", + preservesOriginalColors: true + ) } .buttonStyle(.plain) .disabled(feature.state != .paused || feature.watches.isEmpty) @@ -874,9 +1148,35 @@ struct GenericDebugView: View { .foregroundStyle(LitheTheme.warning) } if let errorMessage = feature.errorMessage { - Label(errorMessage, systemImage: "exclamationmark.triangle.fill") - .font(.system(size: 11.5)) - .foregroundStyle(LitheTheme.error) + VStack(alignment: .leading, spacing: 8) { + Label(errorMessage, systemImage: "exclamationmark.triangle.fill") + .font(.system(size: 11.5)) + .foregroundStyle(LitheTheme.error) + + HStack(spacing: 8) { + if feature.canRetry { + Button { + _ = feature.retry() + } label: { + Label("Retry Debug", systemImage: "arrow.clockwise") + } + .buttonStyle(.bordered) + .controlSize(.small) + .accessibilityIdentifier("debug-error-retry") + } + + if !model.isRunVisible { + Button { + model.toggleRun() + } label: { + Label("Open Run Configuration", systemImage: "slider.horizontal.3") + } + .buttonStyle(.bordered) + .controlSize(.small) + .accessibilityIdentifier("debug-error-open-run-configuration") + } + } + } } Text(feature.output.isEmpty ? "Waiting for Debug Adapter output…" : feature.output) .font(.system(size: 12, design: .monospaced)) @@ -921,7 +1221,12 @@ struct GenericDebugView: View { .disabled(feature.state != .paused) .onSubmit { evaluateConsoleExpression() } Button { evaluateConsoleExpression() } label: { - Image(systemName: "arrow.right.circle.fill") + LitheIDEAIcon( + resourcePath: "debugger/evaluateExpression.svg", + size: 15, + fallbackSystemImage: "arrow.right.circle.fill", + preservesOriginalColors: true + ) } .litheIconButton() .disabled(feature.state != .paused || consoleExpression.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) @@ -961,7 +1266,12 @@ struct GenericDebugView: View { .disabled(!model.isDebugStandardInputAvailable) .onSubmit { sendProgramInput() } Button { sendProgramInput() } label: { - Image(systemName: "paperplane.fill") + LitheIDEAIcon( + resourcePath: "debugger/run.svg", + size: 15, + fallbackSystemImage: "paperplane.fill", + preservesOriginalColors: true + ) } .litheIconButton() .disabled(!model.isDebugStandardInputAvailable || programInput.isEmpty) @@ -980,12 +1290,16 @@ struct GenericDebugView: View { private var emptyState: some View { VStack(spacing: 10) { - LitheSystemIcon(systemImage: "ladybug") - .font(.system(size: 30, weight: .light)) - .foregroundStyle(LitheTheme.secondaryText) - Text("Debug the current \(currentLanguageName) file") + LitheIDEAIcon( + resourcePath: "debugger/debug.svg", + size: 32, + fallbackSystemImage: "ladybug", + preservesOriginalColors: true + ) + .frame(width: 36, height: 36) + Text(emptyStateTitle) .font(.system(size: 13, weight: .medium)) - Text("The Debug Adapter starts only when this action is used.") + Text(emptyStateSubtitle) .font(LitheTheme.smallFont) .foregroundStyle(LitheTheme.secondaryText) Button("Start Debugging") { model.startDebugging() } @@ -1010,6 +1324,21 @@ struct GenericDebugView: View { return descriptor.displayName } + private var emptyStateTitle: String { + if let configuration = model.runFeatureIfActive?.selectedConfiguration, + !configuration.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return "Debug \(configuration.name)" + } + return "Debug the current \(currentLanguageName) file" + } + + private var emptyStateSubtitle: String { + if model.runFeatureIfActive?.selectedConfiguration != nil { + return "Uses the selected Run configuration and its project toolchain." + } + return "The Debug Adapter starts only when this action is used." + } + private func sectionHeader(_ title: String, count: Int) -> some View { HStack { Text(LocalizedStringKey(title)) @@ -1025,12 +1354,17 @@ struct GenericDebugView: View { .litheWorkbenchSurface(LitheTheme.toolHeader) } - private func variableSymbol(_ variable: DebugVariable) -> String { - guard variable.isExpandable else { return "circle.fill" } + private func variableDisclosureSymbol(_ variable: DebugVariable) -> String { if feature.isVariableLoading(variable) { return "hourglass" } return feature.isVariableExpanded(variable) ? "chevron.down" : "chevron.right" } + private func variableIconResourcePath(_ variable: DebugVariable) -> String { + feature.automaticVariables.contains(where: { $0.id == variable.id }) + ? "debugger/watch.svg" + : variable.name == "this" ? "nodes/variable.svg" : "nodes/field.svg" + } + private func threadIcon(_ thread: DebugThread) -> String { if feature.stoppedThreadIDs.contains(thread.id) { return feature.selectedThreadID == thread.id @@ -1040,6 +1374,16 @@ struct GenericDebugView: View { return "play.circle" } + private func threadIconResourcePath(_ thread: DebugThread) -> String { + if feature.selectedThreadID == thread.id, + feature.stoppedThreadIDs.contains(thread.id) { + return "debugger/threadCurrent.svg" + } + return feature.stoppedThreadIDs.contains(thread.id) + ? "debugger/threadSuspended.svg" + : "debugger/threadRunning.svg" + } + private func threadColor(_ thread: DebugThread) -> Color { feature.stoppedThreadIDs.contains(thread.id) ? LitheTheme.warning @@ -1060,8 +1404,12 @@ struct GenericDebugView: View { if isLoading { ProgressView().controlSize(.mini) } else { - Image(systemName: "ellipsis.circle") - .font(.system(size: 9)) + LitheIDEAIcon( + resourcePath: "actions/more.svg", + size: 12, + fallbackSystemImage: "ellipsis.circle", + preservesOriginalColors: true + ) } Text(isLoading ? "Loading…" : "Load \(nextCount) more") .font(LitheTheme.smallFont) @@ -1225,7 +1573,15 @@ struct DebugBreakpointManagerView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .litheWorkbenchSurface(LitheTheme.sidebar) .sheet(item: $editingBreakpoint) { breakpoint in - BreakpointEditorView(breakpoint: breakpoint) { + BreakpointEditorView( + breakpoint: breakpoint, + supportsCondition: !feature.capabilities.negotiated + || feature.capabilities.supportsConditionalBreakpoints, + supportsHitCondition: !feature.capabilities.negotiated + || feature.capabilities.supportsHitConditionalBreakpoints, + supportsLogMessage: !feature.capabilities.negotiated + || feature.capabilities.supportsLogPoints + ) { feature.updateBreakpoint( fileURL: breakpoint.fileURL, line: breakpoint.line, @@ -1299,7 +1655,15 @@ struct DebugBreakpointManagerView: View { } .disabled(feature.breakpoints.isEmpty) } label: { - Image(systemName: feature.areBreakpointsMuted ? "speaker.slash.fill" : "ellipsis") + LitheIDEAIcon( + resourcePath: feature.areBreakpointsMuted + ? "debugger/muteBreakpoints.svg" + : "actions/moreVertical.svg", + size: 14, + fallbackSystemImage: feature.areBreakpointsMuted + ? "speaker.slash.fill" : "ellipsis", + preservesOriginalColors: true + ) } .menuStyle(.borderlessButton) .fixedSize() @@ -1339,9 +1703,23 @@ struct DebugBreakpointManagerView: View { Button { feature.setBreakpointEnabled(breakpoint, enabled: !breakpoint.enabled) } label: { - Image(systemName: breakpointSymbol(breakpoint)) - .font(.system(size: 9)) - .foregroundStyle(breakpointColor(breakpoint)) + if breakpoint.isLogpoint { + Image(systemName: breakpointSymbol(breakpoint)) + .font(.system(size: 9)) + .foregroundStyle(breakpointColor(breakpoint)) + } else { + let asset = LitheIcons.debuggerBreakpointAssetPath( + enabled: breakpoint.enabled, + verified: breakpoint.verified, + muted: feature.areBreakpointsMuted + ) + LitheIDEAIcon( + resourcePath: asset, + size: 13, + fallbackSystemImage: breakpointSymbol(breakpoint), + preservesOriginalColors: true + ) + } } .buttonStyle(.plain) .help(breakpoint.enabled ? "Disable breakpoint" : "Enable breakpoint") @@ -1512,13 +1890,12 @@ struct DebugBreakpointManagerView: View { Button { feature.setDataBreakpointEnabled(breakpoint, enabled: !breakpoint.enabled) } label: { - Image(systemName: "eye.circle.fill") - .font(.system(size: 10)) - .foregroundStyle( - breakpoint.enabled - ? (breakpoint.verified ? LitheTheme.error : LitheTheme.warning) - : LitheTheme.secondaryText - ) + LitheIDEAIcon( + resourcePath: "nodes/field.svg", + size: 13, + fallbackSystemImage: "eye.circle.fill", + preservesOriginalColors: true + ) } .buttonStyle(.plain) .accessibilityLabel( @@ -1639,26 +2016,16 @@ struct DebugBreakpointManagerView: View { private enum DebugContent: CaseIterable, Identifiable { case debugger - case breakpoints case console var id: Self { self } var title: LocalizedStringKey { switch self { - case .debugger: "Debugger" - case .breakpoints: "Breakpoints" + case .debugger: "Threads & Variables" case .console: "Console" } } - - var systemImage: String { - switch self { - case .debugger: "ladybug" - case .breakpoints: "circle.fill" - case .console: "terminal" - } - } } private struct JavaAttachView: View { @@ -1980,6 +2347,9 @@ private struct ExceptionBreakpointEditorView: View { struct BreakpointEditorView: View { @Environment(\.dismiss) private var dismiss let breakpoint: GenericDebugBreakpoint + let supportsCondition: Bool + let supportsHitCondition: Bool + let supportsLogMessage: Bool let onSave: (BreakpointEditorValue) -> Void @State private var enabled: Bool @State private var condition: String @@ -1988,9 +2358,15 @@ struct BreakpointEditorView: View { init( breakpoint: GenericDebugBreakpoint, + supportsCondition: Bool = true, + supportsHitCondition: Bool = true, + supportsLogMessage: Bool = true, onSave: @escaping (BreakpointEditorValue) -> Void ) { self.breakpoint = breakpoint + self.supportsCondition = supportsCondition + self.supportsHitCondition = supportsHitCondition + self.supportsLogMessage = supportsLogMessage self.onSave = onSave _enabled = State(initialValue: breakpoint.enabled) _condition = State(initialValue: breakpoint.condition ?? "") @@ -2013,9 +2389,24 @@ struct BreakpointEditorView: View { .toggleStyle(.checkbox) } Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 10) { - editorRow("Condition", text: $condition) - editorRow("Hit count", text: $hitCondition) - editorRow("Log message", text: $logMessage) + editorRow( + "Condition", + text: $condition, + isSupported: supportsCondition, + help: "The active debug adapter does not support conditional breakpoints." + ) + editorRow( + "Hit count", + text: $hitCondition, + isSupported: supportsHitCondition, + help: "The active debug adapter does not support hit-count breakpoints." + ) + editorRow( + "Log message", + text: $logMessage, + isSupported: supportsLogMessage, + help: "The active debug adapter does not support logpoints." + ) } Spacer(minLength: 0) HStack { @@ -2025,9 +2416,9 @@ struct BreakpointEditorView: View { Button("Save") { onSave(BreakpointEditorValue( enabled: enabled, - condition: optional(condition), - hitCondition: optional(hitCondition), - logMessage: optional(logMessage) + condition: supportsCondition ? optional(condition) : nil, + hitCondition: supportsHitCondition ? optional(hitCondition) : nil, + logMessage: supportsLogMessage ? optional(logMessage) : nil )) dismiss() } @@ -2039,7 +2430,12 @@ struct BreakpointEditorView: View { .litheWorkbenchSurface(LitheTheme.editor) } - private func editorRow(_ title: String, text: Binding) -> some View { + private func editorRow( + _ title: String, + text: Binding, + isSupported: Bool, + help: String + ) -> some View { GridRow { Text(title) .font(.system(size: 11)) @@ -2048,7 +2444,10 @@ struct BreakpointEditorView: View { .textFieldStyle(.roundedBorder) .font(.system(size: 11, design: .monospaced)) .frame(minWidth: 300) + .disabled(!isSupported) + .help(isSupported ? title : help) } + .opacity(isSupported ? 1 : 0.55) } private func optional(_ value: String) -> String? { diff --git a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift index 5906b897..0199eef4 100644 --- a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift +++ b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift @@ -30,6 +30,12 @@ struct CodeEditorPalette { var selection: NSColor { themeColor(.accent).withAlphaComponent(isDark ? 0.42 : 0.24) } var selectionText: NSColor { themeColor(.primaryText) } var currentLine: NSColor { color(light: (0, 0, 0, 0.035), dark: (1, 1, 1, 0.035)) } + var executionLine: NSColor { + color( + light: (0.22, 0.52, 0.91, 0.24), + dark: (0.18, 0.43, 0.78, 0.72) + ) + } 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 guide: NSColor { themeColor(.guide) } @@ -111,6 +117,22 @@ struct EditorDebugBreakpointState: Equatable { let verified: Bool } +enum EditorDebugBreakpointAppearance { + static let markerSize: CGFloat = 14 + static let enabledColor = NSColor( + srgbRed: 229.0 / 255.0, + green: 87.0 / 255.0, + blue: 101.0 / 255.0, + alpha: 1 + ) + static let verifiedCheckColor = NSColor( + srgbRed: 108.0 / 255.0, + green: 112.0 / 255.0, + blue: 126.0 / 255.0, + alpha: 1 + ) +} + struct EditorInlineDebugValue: Equatable { let name: String let value: String @@ -264,16 +286,21 @@ struct EditorGutterLayout: Equatable { let gitChangeRange: Range let width: CGFloat + /// IDEA treats the line-number column and the adjacent breakpoint marker + /// column as one forgiving interaction target. The marker is still drawn + /// in `breakpointRange`, but users do not need to hit that narrow strip. + var breakpointInteractionRange: Range { + lineNumberRange.lowerBound..? @@ -748,7 +777,9 @@ struct CodeEditorView: NSViewRepresentable { private var appliedBlameVisible = false private var appliedBlameLines: [GitBlameLine] = [] private var appliedDebugBreakpointLines = Set() - private var appliedDebugBreakpointStates: [Int: EditorDebugBreakpointState] = [:] + // `nil` forces the first editor refresh to install gutter callbacks, + // even when the document starts with no breakpoints. + private var appliedDebugBreakpointStates: [Int: EditorDebugBreakpointState]? private var appliedDebugBreakpointMessages: [Int: String] = [:] private var appliedRunToCursorEnabled = false private var appliedBreakpointsMuted = false @@ -1385,16 +1416,34 @@ struct CodeEditorView: NSViewRepresentable { feature.selectedFrame?.sourceURL?.standardizedFileURL == url, let frame = feature.selectedFrame { inlineDebugLine = max(0, frame.line - 1) + let source = (textView?.string ?? "") as NSString + let automaticExpressions = feature.providerID == "java" + ? DebugAutomaticExpressionProjection.javaExpressions( + forLine: inlineDebugLine ?? 0, + in: source + ) + : [] + if requestedAutomaticDebugFrameID != frame.id + || requestedAutomaticDebugExpressions != automaticExpressions { + requestedAutomaticDebugFrameID = frame.id + requestedAutomaticDebugExpressions = automaticExpressions + Task { @MainActor [weak feature] in + guard feature?.selectedFrameID == frame.id else { return } + feature?.requestAutomaticVariables(automaticExpressions) + } + } inlineDebugValues = EditorInlineDebugValueProjection.values( forLine: inlineDebugLine ?? 0, - in: (textView?.string ?? "") as NSString, - variables: feature.variables.map { + in: source, + variables: feature.presentedVariables.map { EditorInlineDebugValue(name: $0.name, value: $0.value) } ) } else { inlineDebugLine = nil inlineDebugValues = [] + requestedAutomaticDebugFrameID = nil + requestedAutomaticDebugExpressions = [] } if appliedInlineDebugLine != inlineDebugLine || appliedInlineDebugValues != inlineDebugValues @@ -1439,6 +1488,7 @@ struct CodeEditorView: NSViewRepresentable { return frame.line }() let isRunToCursorEnabled = model.genericDebugFeatureIfActive?.state == .paused + && model.genericDebugFeatureIfActive?.capabilities.supportsGotoTargetsRequest == true let areBreakpointsMuted = model.genericDebugFeatureIfActive?.areBreakpointsMuted ?? false if appliedBlameVisible != isBlameVisible || appliedBlameLines != blameLines @@ -1519,6 +1569,7 @@ struct CodeEditorView: NSViewRepresentable { ) gutter?.updateDebugBreakpointMessages(debugBreakpointMessages) gutter?.updateCurrentExecutionLine(currentExecutionLine) + (textView as? CodeTextView)?.updateCurrentExecutionLine(currentExecutionLine) } } @@ -1797,6 +1848,8 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { private var pendingDebugHover: (expression: String, range: NSRange)? private var currentLineColor = CodeEditorPalette.dark.currentLine + private var executionLineColor = CodeEditorPalette.dark.executionLine + private var currentExecutionLine: Int? private var bracketColor = CodeEditorPalette.dark.bracket private var symbolColor = CodeEditorPalette.dark.symbol private var guideColor = CodeEditorPalette.dark.guide @@ -1843,6 +1896,7 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { .foregroundColor: palette.selectionText ] currentLineColor = palette.currentLine + executionLineColor = palette.executionLine bracketColor = palette.bracket symbolColor = palette.symbol guideColor = palette.guide @@ -2532,10 +2586,38 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { override func drawBackground(in rect: NSRect) { super.drawBackground(in: rect) + drawExecutionLineBackground(in: rect) drawCurrentLineBackground(in: rect) drawIndentGuides(in: rect) } + func updateCurrentExecutionLine(_ line: Int?) { + let normalizedLine = line.map { max(0, $0 - 1) } + guard currentExecutionLine != normalizedLine else { return } + currentExecutionLine = normalizedLine + needsDisplay = true + } + + private func drawExecutionLineBackground(in rect: NSRect) { + guard let currentExecutionLine, + let layoutManager, + layoutManager.numberOfGlyphs > 0, + let lineRect = lineFragmentRect( + forLine: currentExecutionLine, + in: string as NSString, + layoutManager: layoutManager + ) else { return } + let executionRect = NSRect( + x: 0, + y: textContainerOrigin.y + lineRect.minY, + width: bounds.width, + height: lineRect.height + ) + guard executionRect.intersects(rect) else { return } + executionLineColor.setFill() + executionRect.intersection(rect).fill() + } + private func drawCurrentLineBackground(in rect: NSRect) { let source = string as NSString let caret = min(selectedRange().location, source.length) @@ -3917,8 +3999,21 @@ final class LineNumberGutterView: NSView { palette.currentLine.setFill() NSRect(x: 0, y: y, width: bounds.width, height: lineRect.height).fill() } + if !isBlameVisible, + hoveredDebugBreakpointLine == lineNumber - 1 { + // Keep the hover affordance attached to the forgiving IDEA-style + // breakpoint hit target, not only to the 14 px marker column. + palette.foldHover.withAlphaComponent(0.7).setFill() + NSRect( + x: editorGutterOriginX + gutterLayout.breakpointInteractionRange.lowerBound, + y: y, + width: EditorGutterLayout.width(of: gutterLayout.breakpointInteractionRange), + height: lineRect.height + ).fill() + } if currentExecutionLine == lineNumber - 1 { - drawCurrentExecutionLine(y: y, height: lineRect.height) + palette.executionLine.setFill() + NSRect(x: 0, y: y, width: bounds.width, height: lineRect.height).fill() } if isBlameVisible, let blame = blameByLine[lineNumber - 1], @@ -3942,6 +4037,11 @@ final class LineNumberGutterView: NSView { ) } } + // Draw the current execution marker after the breakpoint marker so + // a stopped frame remains visually dominant when both share a line. + if currentExecutionLine == lineNumber - 1 { + drawCurrentExecutionLine(y: y, height: lineRect.height) + } if let marker = gitLineChangeMarkersByLine[lineNumber - 1] { drawGitLineChange(marker, y: y, height: lineRect.height) } @@ -4009,9 +4109,18 @@ final class LineNumberGutterView: NSView { private func drawLineNumber(_ number: Int, y: CGFloat, height: CGFloat) { let label = String(number) as NSString let editorFont = textView?.font ?? LitheTheme.editorFont(size: 13) + let isExecutionLine = currentExecutionLine == number - 1 + let isBreakpointLine = debugBreakpointStatesByLine[number - 1] != nil let attributes: [NSAttributedString.Key: Any] = [ - .font: EditorGutterLayout.lineNumberFont(for: editorFont), - .foregroundColor: palette.lineNumber + .font: isExecutionLine + ? LitheTheme.editorFont( + size: max(8, editorFont.pointSize - 1), + weight: .semibold + ) + : EditorGutterLayout.lineNumberFont(for: editorFont), + .foregroundColor: isExecutionLine + ? palette.link + : (isBreakpointLine ? palette.text : palette.lineNumber) ] let size = label.size(withAttributes: attributes) let centeredY = y + max(0, (height - size.height) / 2) @@ -4105,28 +4214,39 @@ final class LineNumberGutterView: NSView { height: CGFloat, state: EditorDebugBreakpointState ) { - let markerSize: CGFloat = 10 - let path = NSBezierPath( - ovalIn: NSRect( - x: editorGutterOriginX + gutterLayout.breakpointRange.lowerBound - + (EditorGutterLayout.width(of: gutterLayout.breakpointRange) - markerSize) / 2, - y: y + max(0, (height - markerSize) / 2), - width: markerSize, - height: markerSize - ) + let markerSize = EditorDebugBreakpointAppearance.markerSize + let rect = NSRect( + x: editorGutterOriginX + gutterLayout.breakpointRange.lowerBound + + (EditorGutterLayout.width(of: gutterLayout.breakpointRange) - markerSize) / 2, + y: y + max(0, (height - markerSize) / 2), + width: markerSize, + height: markerSize ) - let isInactive = areBreakpointsMuted || !state.enabled - NSColor(red: 0.92, green: 0.28, blue: 0.30, alpha: isInactive ? 0.42 : 0.96).setStroke() - path.lineWidth = 1.5 - if state.verified { - path.fill() - } else { - path.stroke() + let breakpointAsset = LitheIcons.debuggerBreakpointAssetPath( + enabled: state.enabled, + verified: state.verified, + muted: areBreakpointsMuted + ) + let themedAsset = effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + ? LitheIcons.darkIdeaAssetPath(for: breakpointAsset) + : breakpointAsset + if let image = LitheIcons.ideaImage(resourcePath: themedAsset) + ?? LitheIcons.ideaImage(resourcePath: breakpointAsset) { + image.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 1) + return } + + // Keep a local fallback for an unbundled preview or a damaged asset. + let path = NSBezierPath(ovalIn: rect) + let isInactive = areBreakpointsMuted || !state.enabled + EditorDebugBreakpointAppearance.enabledColor + .withAlphaComponent(isInactive ? 0.42 : 1) + .setFill() + path.fill() } private func drawDebugBreakpointHover(y: CGFloat, height: CGFloat) { - let markerSize: CGFloat = 10 + let markerSize = EditorDebugBreakpointAppearance.markerSize let rect = NSRect( x: editorGutterOriginX + gutterLayout.breakpointRange.lowerBound + (EditorGutterLayout.width(of: gutterLayout.breakpointRange) - markerSize) / 2, @@ -4134,20 +4254,41 @@ final class LineNumberGutterView: NSView { width: markerSize, height: markerSize ) - NSColor(calibratedRed: 0.92, green: 0.28, blue: 0.30, alpha: 0.55).setFill() - NSBezierPath(ovalIn: rect).fill() + let breakpointAsset = effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + ? LitheIcons.darkIdeaAssetPath(for: "debugger/db_set_breakpoint.svg") + : "debugger/db_set_breakpoint.svg" + if let image = LitheIcons.ideaImage(resourcePath: breakpointAsset) + ?? LitheIcons.ideaImage(resourcePath: "debugger/db_set_breakpoint.svg") { + image.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 0.82) + } else { + EditorDebugBreakpointAppearance.enabledColor.withAlphaComponent(0.82).setFill() + NSBezierPath(ovalIn: rect).fill() + } } private func drawCurrentExecutionLine(y: CGFloat, height: CGFloat) { - let markerSize: CGFloat = 10 + let markerSize: CGFloat = 14 + let rect = NSRect( + // Keep the execution arrow in the right edge of the line-number + // column so a breakpoint on the same line remains visible. IDEA + // uses two distinct gutter signals for these states. + x: editorGutterOriginX + gutterLayout.lineNumberRange.upperBound - markerSize, + y: y + max(0, (height - markerSize) / 2), + width: markerSize, + height: markerSize + ) + if let image = LitheIcons.ideaImage(resourcePath: "debugger/threadCurrent.svg") { + image.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 1) + return + } let centerY = y + height / 2 - let left = editorGutterOriginX + gutterLayout.breakpointRange.lowerBound + 2 + let left = rect.minX + 2 let path = NSBezierPath() path.move(to: NSPoint(x: left, y: centerY)) - path.line(to: NSPoint(x: left + markerSize, y: centerY - markerSize / 2)) - path.line(to: NSPoint(x: left + markerSize, y: centerY + markerSize / 2)) + path.line(to: NSPoint(x: left + 10, y: centerY - 5)) + path.line(to: NSPoint(x: left + 10, y: centerY + 5)) path.close() - NSColor(calibratedRed: 0.98, green: 0.72, blue: 0.18, alpha: 1).setFill() + NSColor(calibratedRed: 0.32, green: 0.64, blue: 1, alpha: 1).setFill() path.fill() } @@ -4206,7 +4347,10 @@ final class LineNumberGutterView: NSView { override func mouseEntered(with event: NSEvent) { super.mouseEntered(with: event) - updateFoldHover(at: convert(event.locationInWindow, from: nil)) + let point = convert(event.locationInWindow, from: nil) + updateFoldHover(at: point) + updateBreakpointHover(at: point) + updateBreakpointToolTip(at: point) } override func mouseMoved(with event: NSEvent) { @@ -4217,6 +4361,11 @@ final class LineNumberGutterView: NSView { updateBreakpointToolTip(at: point) if foldRegion(at: point) != nil || isBreakpointTarget(at: point) { NSCursor.pointingHand.set() + } else { + // Tracking events do not always trigger `cursorUpdate` when the + // pointer moves between gutter columns. Reset explicitly so a + // stale pointing-hand cursor cannot leak into the editor. + NSCursor.arrow.set() } } @@ -4255,7 +4404,7 @@ final class LineNumberGutterView: NSView { private func isBreakpointTarget(at point: NSPoint) -> Bool { let localX = point.x - editorGutterOriginX - guard gutterLayout.breakpointRange.contains(localX), + guard gutterLayout.breakpointInteractionRange.contains(localX), let line = editorLine(at: point) else { return false } if debugBreakpointStatesByLine[line] != nil { return true } return canAddDebugBreakpoint?(line) == true @@ -4263,7 +4412,7 @@ final class LineNumberGutterView: NSView { private func updateBreakpointToolTip(at point: NSPoint) { let localX = point.x - editorGutterOriginX - guard gutterLayout.breakpointRange.contains(localX), + guard gutterLayout.breakpointInteractionRange.contains(localX), let line = editorLine(at: point) else { toolTip = nil return @@ -4280,7 +4429,9 @@ final class LineNumberGutterView: NSView { return } guard canAddDebugBreakpoint?(line) == true else { - toolTip = nil + // A tooltip here is intentional: it explains why the same gutter + // gesture works on a method line but not on a comment or brace. + toolTip = "Line \(line + 1): Cannot set a Java breakpoint here" return } toolTip = "Line \(line + 1): Click to set breakpoint" @@ -4370,6 +4521,10 @@ final class LineNumberGutterView: NSView { for: NSPoint(x: textView.textContainerInset.width, y: documentY), in: textContainer ) + guard glyphIndex < layoutManager.numberOfGlyphs else { + super.mouseDown(with: event) + return + } let characterIndex = layoutManager.characterIndexForGlyph(at: glyphIndex) let source = textView.string as NSString let line = (textView as? CodeTextView)?.lineNumber(at: characterIndex, in: source) @@ -4380,6 +4535,10 @@ final class LineNumberGutterView: NSView { } return } + if !isBlameVisible, isBreakpointTarget(at: point) { + onToggleDebugBreakpoint?(line) + return + } let gitMarker = gitLineChangeMarkersByLine[line] let localX = point.x - editorGutterOriginX switch gutterLayout.hitTarget(at: localX, hasGitChange: gitMarker != nil) { @@ -4399,9 +4558,6 @@ final class LineNumberGutterView: NSView { guard let marker = markers.first(where: { $0.direction == preferredDirection }) ?? markers.first else { return } onSelectImplementation?(marker) - case .breakpoint where !isBlameVisible - && (debugBreakpointStatesByLine[line] != nil || canAddDebugBreakpoint?(line) == true): - onToggleDebugBreakpoint?(line) case .lineNumber, .breakpoint, nil: textView.window?.makeFirstResponder(textView) textView.setSelectedRange(NSRange(location: characterIndex, length: 0)) @@ -4411,9 +4567,8 @@ final class LineNumberGutterView: NSView { override func menu(for event: NSEvent) -> NSMenu? { let point = convert(event.locationInWindow, from: nil) let localX = point.x - editorGutterOriginX - if gutterLayout.breakpointRange.contains(localX), - let line = editorLine(at: point), - debugBreakpointStatesByLine[line] != nil { + if gutterLayout.breakpointInteractionRange.contains(localX), + let line = editorLine(at: point) { return debugBreakpointContextMenu(forLine: line) } if gutterLayout.lineNumberRange.contains(localX), @@ -4457,8 +4612,18 @@ final class LineNumberGutterView: NSView { } func debugBreakpointContextMenu(forLine line: Int) -> NSMenu? { - guard let state = debugBreakpointStatesByLine[line] else { return nil } contextDebugBreakpointLine = line + guard let state = debugBreakpointStatesByLine[line] else { + guard canAddDebugBreakpoint?(line) == true else { return nil } + let menu = NSMenu(title: "Breakpoint") + menu.addItem( + withTitle: "Set Breakpoint", + action: #selector(addDebugBreakpointFromMenu), + keyEquivalent: "" + ) + menu.items.last?.target = self + return menu + } let menu = NSMenu(title: "Breakpoint") if onEditDebugBreakpoint != nil { menu.addItem( @@ -4498,6 +4663,10 @@ final class LineNumberGutterView: NSView { if let line = contextDebugBreakpointLine { onEditDebugBreakpoint?(line) } } + @objc func addDebugBreakpointFromMenu() { + if let line = contextDebugBreakpointLine { onToggleDebugBreakpoint?(line) } + } + @objc private func toggleDebugBreakpointFromMenu() { guard let line = contextDebugBreakpointLine, let state = debugBreakpointStatesByLine[line] else { return } diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift index d431a419..9ab26a87 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift @@ -522,8 +522,12 @@ struct WorkbenchView: View { Spacer(minLength: 22) - debugConfigurationPicker + runConfigurationPicker + runLaunchButton debugLaunchButton + if hasActiveExecution { + stopExecutionButton + } backgroundPickerButton @@ -541,35 +545,89 @@ struct WorkbenchView: View { } } + private var runLaunchButton: some View { + Button { + if model.runFeatureIfActive?.isRunning == true { + model.restartSelectedRun() + } else { + model.runSelectedConfiguration() + } + } label: { + LitheIDEAIcon( + resourcePath: model.runFeatureIfActive?.isRunning == true + ? "debugger/rerun.svg" + : "debugger/run.svg", + size: 16, + fallbackSystemImage: model.runFeatureIfActive?.isRunning == true + ? "arrow.clockwise" + : "play.fill", + preservesOriginalColors: true + ) + .frame(width: 28, height: 28) + .litheRowHover(isActive: false, cornerRadius: 6, activeBackground: LitheTheme.subtleSelection) + } + .buttonStyle(.plain) + .lithePointer() + .help(model.runFeatureIfActive?.isRunning == true ? "Rerun selected configuration" : "Run selected configuration") + .accessibilityLabel(model.runFeatureIfActive?.isRunning == true ? "Rerun selected configuration" : "Run selected configuration") + .accessibilityIdentifier("run-selected-run-configuration") + } + private var debugLaunchButton: some View { Button { - model.startDebugging() + model.startOrRestartDebugging() } label: { - HStack(spacing: 5) { - LitheIDEAIcon( - resourcePath: "toolwindows/toolWindowDebugger.svg", - size: 16, - fallbackSystemImage: "ladybug.fill" - ) - if let configuration = model.runFeatureIfActive?.selectedConfiguration { - Text(configuration.name) - .font(.system(size: 11.5, weight: .medium)) - .lineLimit(1) - } - } - .padding(.horizontal, 8) - .frame(height: 30) + LitheIDEAIcon( + resourcePath: isDebugSessionActive + ? "debugger/restartDebug.svg" + : "debugger/debug.svg", + size: 16, + fallbackSystemImage: "ladybug.fill", + preservesOriginalColors: true + ) + .frame(width: 28, height: 28) .litheRowHover(isActive: false, cornerRadius: 6, activeBackground: LitheTheme.subtleSelection) } .buttonStyle(.plain) .lithePointer() - .foregroundStyle(LitheTheme.success) - .help("Debug selected run configuration") - .accessibilityLabel("Debug selected run configuration") + .help(isDebugSessionActive ? "Rerun or show Debug session" : "Debug selected run configuration") + .accessibilityLabel(isDebugSessionActive ? "Rerun or show Debug session" : "Debug selected run configuration") .accessibilityIdentifier("debug-selected-run-configuration") } - private var debugConfigurationPicker: some View { + private var stopExecutionButton: some View { + Button { + if isDebugSessionActive { + model.stopDebugging() + } else { + model.stopSelectedRun() + } + } label: { + LitheIDEAIcon( + resourcePath: "debugger/stop.svg", + size: 16, + fallbackSystemImage: "stop.fill", + preservesOriginalColors: true + ) + .frame(width: 28, height: 28) + .litheRowHover(isActive: false, cornerRadius: 6, activeBackground: LitheTheme.subtleSelection) + } + .buttonStyle(.plain) + .lithePointer() + .help("Stop active execution") + .accessibilityLabel("Stop active execution") + .accessibilityIdentifier("stop-active-execution") + } + + private var isDebugSessionActive: Bool { + model.genericDebugFeatureIfActive?.isSessionActive == true + } + + private var hasActiveExecution: Bool { + isDebugSessionActive || model.runFeatureIfActive?.isRunning == true + } + + private var runConfigurationPicker: some View { Menu { if let runFeature = model.runFeatureIfActive, !runFeature.configurations.isEmpty { @@ -594,11 +652,11 @@ struct WorkbenchView: View { } } label: { HStack(spacing: 5) { - Image(systemName: "chevron.down") - .font(.system(size: 8, weight: .bold)) Text(model.runFeatureIfActive?.selectedConfiguration?.name ?? "Current File") .font(.system(size: 11.5, weight: .medium)) .lineLimit(1) + Image(systemName: "chevron.down") + .font(.system(size: 8, weight: .bold)) } .foregroundStyle(LitheTheme.primaryText) .padding(.horizontal, 8) diff --git a/macos/Sources/LitheCoreContracts/Execution/RunModels.swift b/macos/Sources/LitheCoreContracts/Execution/RunModels.swift index 0d764f3b..b502b072 100644 --- a/macos/Sources/LitheCoreContracts/Execution/RunModels.swift +++ b/macos/Sources/LitheCoreContracts/Execution/RunModels.swift @@ -37,7 +37,7 @@ package struct RunPortConflict: Identifiable, Hashable, Sendable { package var id: String { String(port) } package var title: String { - "Port (port) is used by " + configurationNames.joined(separator: ", ") + "Port \(port) is used by " + configurationNames.joined(separator: ", ") } } diff --git a/macos/Sources/LitheDebugModule/Application/DebugBreakpointLocationValidator.swift b/macos/Sources/LitheDebugModule/Application/DebugBreakpointLocationValidator.swift index de53e523..6b7a4232 100644 --- a/macos/Sources/LitheDebugModule/Application/DebugBreakpointLocationValidator.swift +++ b/macos/Sources/LitheDebugModule/Application/DebugBreakpointLocationValidator.swift @@ -18,6 +18,7 @@ public enum DebugBreakpointLocationValidator { guard index + 1 == line else { continue } guard !code.isEmpty, !code.hasPrefix("@"), + !isJavaTypeDeclaration(code), !Self.nonExecutableOnlyLines.contains(code) else { return false } return code.contains(where: { $0.isLetter || $0.isNumber || $0 == "_" }) } @@ -28,6 +29,24 @@ public enum DebugBreakpointLocationValidator { "{", "}", "(", ")", "[", "]", ";", ",", ":" ] + /// Type declarations do not represent a Java execution location. Modifiers + /// are parsed as tokens so declarations such as `public final class` and + /// `private static interface` are treated the same as their short forms. + private static func isJavaTypeDeclaration(_ code: String) -> Bool { + let tokens = code.split(whereSeparator: { $0 == " " || $0 == "\t" }) + guard !tokens.isEmpty else { return false } + let modifiers: Set = [ + "public", "private", "protected", "abstract", "final", "static", "sealed", "non-sealed", "strictfp" + ] + var index = 0 + while index < tokens.count, modifiers.contains(tokens[index]) { + index += 1 + } + guard index < tokens.count else { return false } + if tokens[index] == "@interface" { return true } + return ["class", "interface", "enum", "record"].contains(tokens[index]) + } + private static func codeWithoutJavaCommentsAndStrings( _ line: String, inBlockComment: inout Bool diff --git a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift index c671a50e..88be0168 100644 --- a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift +++ b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift @@ -233,6 +233,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu @Published public private(set) var scopes: [DebugScope] = [] @Published public private(set) var selectedScopeID: Int? @Published public private(set) var variables: [DebugVariable] = [] + @Published public private(set) var automaticVariables: [DebugVariable] = [] @Published public private(set) var variableChildren: [String: [DebugVariable]] = [:] @Published public private(set) var expandedVariableIDs: Set = [] @Published public private(set) var loadingVariableIDs: Set = [] @@ -250,10 +251,16 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu @Published public private(set) var javaSteppingFilters: DebugSteppingFilters? @Published public private(set) var consoleHistory: [String] = [] @Published public private(set) var areFilteredStackFramesExpanded = false + /// Prevents overlapping execution requests while the adapter is still + /// acknowledging the previous step/continue operation. + @Published public private(set) var isExecutionRequestPending = false /// Delivers the selected stopped frame to the host editor for source /// navigation. The Debug module does not own editor presentation. public var onStoppedLocation: ((URL, Int, Int) -> Void)? + /// Lets the host derive source-referenced expressions after the selected + /// frame has completed its inspection-state reset. + public var onAutomaticVariableInspectionRequest: ((DebugStackFrame) -> Void)? /// Lets the host activate its native Terminal module without coupling /// Debug to a platform process or presentation implementation. public var onRunInTerminalRequest: DebugRunInTerminalRequestHandler? { @@ -293,8 +300,12 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu private let maximumConsoleHistoryEntries = 100 private let maximumOutputCharacters = 400_000 private let variablePageSize = 100 + private let maximumAutomaticVariables = 8 private var watchGeneration = 0 private var inspectionGeneration = 0 + private var automaticExpressionOrder: [String] = [] + private var automaticExpressionResults: [String: DebugVariable] = [:] + private var automaticExpressionFrameID: Int? private static let rootVariablePageID = "__lithe_debug_root_variables__" private var debuggeeOutputNormalizer = GenericDebugOutputNormalizer() @@ -317,6 +328,12 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu if self.activeSessionID == sessionID { self.providerID = providerID self.state = state + if state != .paused { + self.isExecutionRequestPending = false + } + if state == .running { + self.clearStoppedInspection() + } self.saveActiveSessionSnapshot() } else { self.updateInactiveSessionState( @@ -349,7 +366,10 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu public var canControl: Bool { state == .running || state == .paused } public var canRestart: Bool { - canControl && capabilities.supportsRestartRequest + // Some Java adapters (notably older JDT LS builds) do not advertise + // DAP `restart`. We can still provide the IDEA-style rerun action by + // relaunching the exact saved request after stopping this session. + canControl && (capabilities.supportsRestartRequest || lastStartRequest != nil) } public var canTerminate: Bool { canControl && capabilities.supportsTerminateRequest @@ -362,7 +382,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } public var visibleVariableRows: [GenericDebugVariableRow] { var rows: [GenericDebugVariableRow] = [] - appendVisibleVariables(variables, parentPath: "root", depth: 0, to: &rows) + appendVisibleVariables(presentedVariables, parentPath: "root", depth: 0, to: &rows) appendVariableLoadMoreRow( parentVariableID: nil, parentPath: "root", @@ -371,6 +391,17 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu ) return rows } + /// Root-scope values plus source-referenced expressions evaluated for the + /// selected frame, matching the compact variable list used by Java IDEs. + public var presentedVariables: [DebugVariable] { + var knownNames = Set() + var result: [DebugVariable] = [] + for variable in variables + automaticVariables { + guard knownNames.insert(variable.name).inserted else { continue } + result.append(variable) + } + return result + } public var visibleStackFrameRows: [GenericDebugStackFrameRow] { guard javaSteppingFilters?.hideFilteredStackFrames == true, !areFilteredStackFramesExpanded else { @@ -463,6 +494,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu guard sessions.select(sessionID: sessionID) else { return false } activeSessionID = sessionID providerID = summary.providerID + isExecutionRequestPending = false onSessionSelectionChanged?(sessionID) publishConsoleHistory(for: sessionID) restoreSessionSnapshot(sessionID, summary: summary) @@ -510,6 +542,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu debuggeeOutputNormalizer.reset() errorMessage = nil stoppedReason = nil + isExecutionRequestPending = false exceptionInfo = nil threads = [] stoppedThreadIDs = [] @@ -517,6 +550,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu scopes = [] selectedScopeID = nil resetVariableTree() + resetAutomaticVariables() invalidateWatchResults() capabilities = .unknown selectedThreadID = nil @@ -524,12 +558,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu stoppedFrame = nil selectedFrame = nil do { - if requestedBreakpointsByFile[request.fileURL] != nil { - try sessions.setBreakpoints( - effectiveBreakpoints(for: request.fileURL), - in: request.fileURL - ) - } + try synchronizeRequestedBreakpoints(for: providerID) if !dataBreakpoints.isEmpty { try sessions.setDataBreakpoints(coreDataBreakpoints, for: request.fileURL) } @@ -604,6 +633,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu onSessionSelectionChanged?(nil) consoleHistory = [] state = .idle + isExecutionRequestPending = false stoppedReason = nil exceptionInfo = nil selectedThreadID = nil @@ -617,6 +647,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu scopes = [] selectedScopeID = nil resetVariableTree() + resetAutomaticVariables() invalidateWatchResults() capabilities = .unknown activeFileURL = nil @@ -634,6 +665,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu consoleHistoryCursorBySession.removeAll() consoleHistoryDraftBySession.removeAll() state = .idle + isExecutionRequestPending = false stoppedReason = nil exceptionInfo = nil selectedThreadID = nil @@ -647,6 +679,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu scopes = [] selectedScopeID = nil resetVariableTree() + resetAutomaticVariables() invalidateWatchResults() capabilities = .unknown activeFileURL = nil @@ -1007,7 +1040,19 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } public func execute(_ command: DebugExecutionCommand) { + if command == .restart, + !capabilities.supportsRestartRequest, + lastStartRequest != nil { + _ = retry() + return + } guard let session = activeSession else { return } + guard !isExecutionRequestPending else { return } + if command == .continueExecution || command == .pause || + command == .next || command == .stepIn || command == .stepOut || + command == .stepBack || command == .goto { + isExecutionRequestPending = true + } session.execute(command, threadID: selectedThreadID) } @@ -1048,9 +1093,11 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu public func executeThread(_ command: DebugExecutionCommand, thread: DebugThread) { guard capabilities.supportsSingleThreadExecutionRequests, + !isExecutionRequestPending, (command == .continueExecution && state == .paused) || (command == .pause && state == .running), let session = activeSession else { return } + isExecutionRequestPending = true session.execute( command, threadID: thread.id, @@ -1132,6 +1179,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu scopes = [] selectedScopeID = nil resetVariableTree() + resetAutomaticVariables() invalidateWatchResults() guard let session = activeSession else { return } session.requestStackTrace(threadID: thread.id) { [weak self] result in @@ -1142,8 +1190,9 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu case .success(let frames): self.stackFrames = frames self.areFilteredStackFramesExpanded = false - self.selectedFrameID = frames.first?.id - if let frame = frames.first { + let preferredFrame = self.preferredStoppedFrame(in: frames) + self.selectedFrameID = preferredFrame?.id + if let frame = preferredFrame { self.selectFrame(frame, generation: generation) } else { self.selectedFrame = nil @@ -1164,8 +1213,10 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu scopes = [] selectedScopeID = nil resetVariableTree() + resetAutomaticVariables() invalidateWatchResults() publishStoppedLocation(frame) + onAutomaticVariableInspectionRequest?(frame) refreshWatches() guard let session = activeSession else { return } session.requestScopes(frameID: frame.id) { [weak self] result in @@ -1213,6 +1264,104 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu ) } + /// Evaluates identifiers referenced by the paused source line so fields + /// such as Java's `service` appear beside locals and as editor inlays. + public func requestAutomaticVariables(_ expressions: [String]) { + guard state == .paused, + let frameID = selectedFrameID, + let session = activeSession else { return } + var knownExpressions = Set() + var orderedExpressions: [String] = [] + for rawExpression in expressions { + let expression = rawExpression.trimmingCharacters(in: .whitespacesAndNewlines) + guard !expression.isEmpty, + knownExpressions.insert(expression).inserted else { continue } + orderedExpressions.append(expression) + if orderedExpressions.count == maximumAutomaticVariables { break } + } + guard automaticExpressionFrameID != frameID + || automaticExpressionOrder != orderedExpressions else { return } + + automaticExpressionFrameID = frameID + automaticExpressionOrder = orderedExpressions + automaticExpressionResults = [:] + automaticVariables = [] + guard !orderedExpressions.isEmpty else { return } + + let generation = inspectionGeneration + for expression in orderedExpressions { + evaluateAutomaticVariable( + expression, + evaluatedExpression: expression, + allowsJavaFieldFallback: true, + orderedExpressions: orderedExpressions, + frameID: frameID, + generation: generation, + session: session + ) + } + } + + private func evaluateAutomaticVariable( + _ expression: String, + evaluatedExpression: String, + allowsJavaFieldFallback: Bool, + orderedExpressions: [String], + frameID: Int, + generation: Int, + session: any DebugAdapterControllingSession + ) { + session.evaluate(evaluatedExpression, frameID: frameID) { [weak self] result in + guard let self, + self.state == .paused, + self.inspectionGeneration == generation, + self.selectedFrameID == frameID, + self.automaticExpressionFrameID == frameID, + self.automaticExpressionOrder == orderedExpressions else { return } + switch result { + case .success(let variable): + let displayName = expression.split(separator: ".").last.map(String.init) + ?? expression + self.automaticExpressionResults[expression] = DebugVariable( + id: variable.id, + name: displayName, + value: variable.value, + type: variable.type, + evaluateName: variable.evaluateName ?? evaluatedExpression, + variablesReference: variable.variablesReference, + containerReference: variable.containerReference, + namedVariables: variable.namedVariables, + indexedVariables: variable.indexedVariables + ) + self.publishAutomaticVariables(orderedExpressions) + case .failure: + if allowsJavaFieldFallback, + self.providerID == "java", + !expression.contains(".") { + self.evaluateAutomaticVariable( + expression, + evaluatedExpression: "this.\(expression)", + allowsJavaFieldFallback: false, + orderedExpressions: orderedExpressions, + frameID: frameID, + generation: generation, + session: session + ) + } else { + // Source-driven evaluation is speculative. Invalid + // candidates should not flood the user's Debug Console. + self.publishAutomaticVariables(orderedExpressions) + } + } + } + } + + private func publishAutomaticVariables(_ orderedExpressions: [String]) { + automaticVariables = orderedExpressions.compactMap { + automaticExpressionResults[$0] + } + } + private func loadVariables( reference: Int, namedVariables: Int, @@ -1450,6 +1599,9 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu let normalized = debuggeeOutputNormalizer.normalize(rawOutput) guard !normalized.isEmpty else { return } append(normalized) + if let diagnostic = launchDiagnostic(in: normalized) { + errorMessage = diagnostic + } } private func resetInspectionState() { @@ -1464,10 +1616,17 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu stackFrames = [] areFilteredStackFramesExpanded = false scopes = [] + selectedScopeID = nil resetVariableTree() + resetAutomaticVariables() invalidateWatchResults() } + private func clearStoppedInspection() { + invalidateInspectionRequests() + resetInspectionState() + } + private func saveActiveSessionSnapshot() { guard let activeSessionID, let providerID else { return } sessionSnapshots[activeSessionID] = GenericDebugSessionSnapshot( @@ -1637,6 +1796,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu case .output(_, let text): append(text) case .stopped(let reason, let threadID, let description): + isExecutionRequestPending = false let generation = beginInspectionTransition() if let threadID { stoppedThreadIDs.insert(threadID) @@ -1653,6 +1813,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu areFilteredStackFramesExpanded = false scopes = [] resetVariableTree() + resetAutomaticVariables() invalidateWatchResults() if reason == "exception", let threadID { loadExceptionInfo(threadID: threadID, generation: generation) @@ -1663,40 +1824,16 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu shouldLoadExceptionInfo: reason == "exception" && threadID == nil ) case .continued(let threadID): + isExecutionRequestPending = false if let threadID { stoppedThreadIDs.remove(threadID) } else { stoppedThreadIDs = [] } - invalidateInspectionRequests() - stoppedReason = nil - exceptionInfo = nil - selectedThreadID = nil - selectedFrameID = nil - stoppedFrame = nil - selectedFrame = nil - threads = [] - stoppedThreadIDs = [] - stackFrames = [] - areFilteredStackFramesExpanded = false - scopes = [] - resetVariableTree() - invalidateWatchResults() + clearStoppedInspection() case .terminated(let exitCode): - invalidateInspectionRequests() - stoppedReason = nil - exceptionInfo = nil - selectedThreadID = nil - selectedFrameID = nil - stoppedFrame = nil - selectedFrame = nil - threads = [] - stoppedThreadIDs = [] - stackFrames = [] - areFilteredStackFramesExpanded = false - scopes = [] - resetVariableTree() - invalidateWatchResults() + isExecutionRequestPending = false + clearStoppedInspection() if let exitCode { append("Debug session exited with code \(exitCode).\n") } case .breakpoint(let resolved): if let dataID = resolved.dataID, @@ -1763,8 +1900,9 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu case .success(let frames): self.stackFrames = frames self.areFilteredStackFramesExpanded = false - self.selectedFrameID = frames.first?.id - if let frame = frames.first { + let preferredFrame = self.preferredStoppedFrame(in: frames) + self.selectedFrameID = preferredFrame?.id + if let frame = preferredFrame { self.selectFrame(frame, generation: generation) } else { self.selectedFrame = nil @@ -1775,6 +1913,16 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } } + /// Prefer a source-backed, unfiltered frame after a stop so Java's + /// synthetic reflection and method-handle frames do not become the user's + /// initial inspection location. The full stack remains available and can + /// still be expanded from the filtered-frame group. + private func preferredStoppedFrame(in frames: [DebugStackFrame]) -> DebugStackFrame? { + frames.first(where: { !$0.isFiltered && $0.sourceURL != nil }) + ?? frames.first(where: { !$0.isFiltered }) + ?? frames.first + } + private func loadExceptionInfo(threadID: Int, generation: Int) { guard capabilities.supportsExceptionInfoRequest, let session = activeSession else { return } @@ -1962,6 +2110,14 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } } + private func synchronizeRequestedBreakpoints(for providerID: String?) throws { + guard let providerID else { return } + for fileURL in requestedBreakpointsByFile.keys.sorted(by: { $0.path < $1.path }) + where sessionsProviderID(for: fileURL) == providerID { + try sessions.setBreakpoints(effectiveBreakpoints(for: fileURL), in: fileURL) + } + } + private func synchronizeBreakpoints(for fileURL: URL) { do { try sessions.setBreakpoints(effectiveBreakpoints(for: fileURL), in: fileURL) @@ -2202,6 +2358,13 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu loadingVariablePageIDs = [] } + private func resetAutomaticVariables() { + automaticVariables = [] + automaticExpressionOrder = [] + automaticExpressionResults = [:] + automaticExpressionFrameID = nil + } + private func replaceVariable(_ replacement: DebugVariable) { if let index = variables.firstIndex(where: { $0.id == replacement.id }) { variables[index] = replacement @@ -2287,6 +2450,20 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu saveActiveSessionSnapshot() } + private func launchDiagnostic(in output: String) -> String? { + let pattern = #"(?i)\bport\s+(\d{1,5})\s+was already in use\b"# + guard let expression = try? NSRegularExpression(pattern: pattern), + let match = expression.firstMatch( + in: output, + range: NSRange(output.startIndex..., in: output) + ), + let portRange = Range(match.range(at: 1), in: output) else { + return nil + } + let port = output[portRange] + return "Port \(port) is already in use. Stop the process using it or change server.port in the Run configuration." + } + private func append(_ text: String, to output: inout String) { output += text if output.count > maximumOutputCharacters { diff --git a/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift b/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift index 9007a748..ab203a04 100644 --- a/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift +++ b/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift @@ -99,6 +99,10 @@ package final class RunFeatureModel: ObservableObject { service.options(for: configuration) } + package func configuredServerPort(for configuration: RunConfiguration) -> Int? { + service.configuredServerPort(for: configuration) + } + package func source(for configuration: RunConfiguration) -> RunConfigurationSource { service.source(for: configuration) } diff --git a/macos/Sources/LitheExecutionModule/Services/RunService.swift b/macos/Sources/LitheExecutionModule/Services/RunService.swift index 050c8c89..4c9c1c42 100644 --- a/macos/Sources/LitheExecutionModule/Services/RunService.swift +++ b/macos/Sources/LitheExecutionModule/Services/RunService.swift @@ -287,6 +287,13 @@ package final class RunService: ObservableObject { optionsByConfigurationID[configuration.id] ?? RunOptions() } + /// Returns the service port explicitly configured for this run target, or + /// a Spring-style framework's conventional 8080 default when no override exists. + package func configuredServerPort(for configuration: RunConfiguration) -> Int? { + configuredPort(for: configuration) + ?? (configuration.kind.mavenFramework != nil ? 8080 : nil) + } + package func source(for configuration: RunConfiguration) -> RunConfigurationSource { effectiveSourcesByConfigurationID[configuration.id] ?? .generated } diff --git a/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift b/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift index 6f4ad7d9..261861c6 100644 --- a/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift +++ b/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift @@ -324,7 +324,11 @@ package final class LanguageToolingSessionManager: ObservableObject, let selected: JavaDebugLaunchTarget if exactMatches.count == 1 { selected = exactMatches[0].target - } else if targets.count == 1 { + } else if targets.count == 1, targets[0].filePath == nil { + // Older JDT LS builds may omit filePath when the workspace has a + // single launch target. If a path is present, do not silently use + // another class for the current editor file: that turns a + // Spring-dependent source into an invalid bare-java launch. selected = targets[0].target } else { let message = exactMatches.isEmpty diff --git a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift index d1c51a40..8e5b9f01 100644 --- a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift +++ b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift @@ -29,6 +29,17 @@ struct DebugModuleTests { #expect(feature.output == "ready\nnext\n") } + @Test + func springPortConflictOutputProducesAnActionableDiagnostic() { + let manager = DebugAdapterSessionManager(providers: []) { _, _ in nil } + let feature = GenericDebugFeatureModel(sessions: manager) + + feature.appendDebuggeeOutput("Web server failed to start. Port 8080 was already in use.\n") + + #expect(feature.errorMessage == "Port 8080 is already in use. Stop the process using it or change server.port in the Run configuration.") + #expect(feature.output.contains("Port 8080 was already in use.")) + } + @Test func staleSessionCallbacksCannotOverwriteAReplacementSession() throws { let descriptor = DebugProviderDescriptor( @@ -1086,6 +1097,69 @@ struct DebugModuleTests { feature.stop() } + @Test + func restartFallsBackToRelaunchWhenAdapterDoesNotAdvertiseRestart() throws { + let session = DeferredInspectionDebugSession() + let descriptor = DebugProviderDescriptor( + id: "java", + displayName: "Java", + fileExtensions: ["java"] + ) + let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in session } + let feature = GenericDebugFeatureModel(sessions: manager) + let root = URL(fileURLWithPath: "/tmp/java-debug-restart-fallback", isDirectory: true) + let source = root.appendingPathComponent("src/Main.java") + let configuration = DebugLaunchConfiguration( + name: "Restart Main", + request: .launch, + arguments: ["mainClass": .string("example.Main")] + ) + + #expect(feature.start(fileURL: source, rootURL: root, configuration: configuration)) + #expect(feature.state == .paused) + #expect(!feature.capabilities.supportsRestartRequest) + #expect(feature.canRestart) + + feature.execute(.restart) + + #expect(session.startCount == 2) + #expect(session.launchConfigurations == [configuration, configuration]) + feature.stop() + } + + @Test + func executionControlsIgnoreOverlappingRequestsUntilSessionLeavesPausedState() throws { + let session = DeferredInspectionDebugSession() + let descriptor = DebugProviderDescriptor( + id: "java", + displayName: "Java", + fileExtensions: ["java"] + ) + let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in session } + let feature = GenericDebugFeatureModel(sessions: manager) + let root = URL(fileURLWithPath: "/tmp/java-debug-execution-lock", isDirectory: true) + let source = root.appendingPathComponent("src/Main.java") + let configuration = DebugLaunchConfiguration( + name: "Execution lock", + request: .launch, + arguments: ["mainClass": .string("example.Main")] + ) + + #expect(feature.start(fileURL: source, rootURL: root, configuration: configuration)) + feature.execute(.continueExecution) + feature.execute(.continueExecution) + #expect(session.executionCommands == [.continueExecution]) + #expect(feature.isExecutionRequestPending) + + session.transition(to: .running) + #expect(!feature.isExecutionRequestPending) + session.transition(to: .paused) + feature.execute(.continueExecution) + #expect(session.executionCommands == [.continueExecution, .continueExecution]) + + feature.stop() + } + @Test func filteredStackFramesCollapseByConsecutiveRunsAndRestoreOrder() { let session = DeferredInspectionDebugSession() @@ -1180,6 +1254,56 @@ struct DebugModuleTests { ]) } + @Test + func stoppedStackPrefersTheFirstSourceBackedUnfilteredFrame() throws { + let session = DeferredInspectionDebugSession() + let descriptor = DebugProviderDescriptor( + id: "java", + displayName: "Java", + fileExtensions: ["java"] + ) + let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in session } + let feature = GenericDebugFeatureModel(sessions: manager) + let root = URL(fileURLWithPath: "/tmp/java-preferred-frame", isDirectory: true) + let source = root.appendingPathComponent("src/UserService.java") + #expect(feature.start( + fileURL: source, + rootURL: root, + configuration: DebugLaunchConfiguration( + name: "Main", + request: .launch, + arguments: ["mainClass": .string("example.Main")] + ) + )) + defer { feature.stop() } + + // Java adapters can report a synthetic method-handle frame above the + // actual application frame. The initial inspection must land on the + // application source while retaining the synthetic frame in the list. + feature.selectThread(DebugThread(id: 7, name: "http-worker")) + let synthetic = DebugStackFrame( + id: 1, + name: "java.lang.invoke.MethodHandle.invokeVirtual", + sourceURL: nil, + line: 1, + column: 1, + isFiltered: true + ) + let application = DebugStackFrame( + id: 2, + name: "example.UserService.listUsers", + sourceURL: source, + line: 18, + column: 5 + ) + session.completeStackTrace(at: 0, with: [synthetic, application]) + + #expect(feature.stackFrames.map(\.id) == [1, 2]) + #expect(feature.selectedFrameID == 2) + #expect(feature.selectedFrame?.sourceURL == source) + #expect(session.scopeFrameIDs == [2]) + } + @Test func rapidInspectionSelectionDiscardsOutOfOrderResults() throws { let session = DeferredInspectionDebugSession() @@ -1310,6 +1434,108 @@ struct DebugModuleTests { #expect(feature.scopes.isEmpty) } + @Test + func runningStateClearsStoppedInspectionBeforeContinuedEvent() throws { + let session = DeferredInspectionDebugSession() + let feature = makeDeferredFeature( + session: session, + rootPath: "/tmp/java-running-inspection-reset" + ) + defer { feature.stop() } + let thread = DebugThread(id: 7, name: "http-worker") + let frame = DebugStackFrame( + id: 70, + name: "UserController.list()", + sourceURL: URL(fileURLWithPath: "/tmp/java-running-inspection-reset/src/UserController.java"), + line: 23, + column: 1 + ) + let service = DebugVariable( + id: "service", + name: "service", + value: "UserService@72", + type: "UserService", + evaluateName: "service", + variablesReference: 0 + ) + + feature.selectThread(thread) + session.completeStackTrace(at: 0, with: [frame]) + session.completeScopes( + at: 0, + with: [DebugScope(id: 700, name: "Locals", variablesReference: 700, expensive: false)] + ) + session.completeVariables(at: 0, with: [service]) + feature.requestAutomaticVariables(["service"]) + session.completeEvaluation(at: 0, with: .success(service)) + + #expect(feature.selectedThreadID == thread.id) + #expect(feature.selectedFrameID == frame.id) + #expect(feature.selectedScopeID == 700) + #expect(feature.presentedVariables == [service]) + + // The adapter commonly confirms the request before sending its + // continued event. Running state must not expose the old pause. + session.transition(to: .running) + + #expect(feature.state == .running) + #expect(feature.selectedThreadID == nil) + #expect(feature.selectedFrameID == nil) + #expect(feature.selectedScopeID == nil) + #expect(feature.stackFrames.isEmpty) + #expect(feature.scopes.isEmpty) + #expect(feature.variables.isEmpty) + #expect(feature.automaticVariables.isEmpty) + #expect(feature.presentedVariables.isEmpty) + } + + @Test + func automaticVariableInspectionRunsAfterFrameResetAndFallsBackToJavaField() { + let session = DeferredInspectionDebugSession() + let feature = makeDeferredFeature( + session: session, + rootPath: "/tmp/java-automatic-variable-inspection" + ) + defer { feature.stop() } + let frame = DebugStackFrame( + id: 31, + name: "UserController.list()", + sourceURL: URL(fileURLWithPath: "/tmp/java-automatic-variable-inspection/src/UserController.java"), + line: 23, + column: 1 + ) + var requestedFrameID: Int? + feature.onAutomaticVariableInspectionRequest = { selectedFrame in + requestedFrameID = selectedFrame.id + feature.requestAutomaticVariables(["service"]) + } + + #expect(feature.state == .paused) + #expect(feature.providerID == "java") + feature.selectFrame(frame) + #expect(requestedFrameID == frame.id) + #expect(session.evaluateExpressions == ["service"]) + #expect(session.evaluateFrameIDs == [frame.id]) + + guard session.evaluateExpressions.count == 1 else { return } + session.completeEvaluation(at: 0, with: .failure(DeferredDebugSessionError.launchFailed)) + #expect(session.evaluateExpressions == ["service", "this.service"]) + + let service = DebugVariable( + id: "service", + name: "this.service", + value: "UserService@72", + type: "UserService", + evaluateName: "this.service", + variablesReference: 72 + ) + guard session.evaluateExpressions.count == 2 else { return } + session.completeEvaluation(at: 1, with: .success(service)) + + #expect(feature.automaticVariables.map(\.name) == ["service"]) + #expect(feature.automaticVariables.map(\.value) == ["UserService@72"]) + } + @Test func largeIndexedVariableCollectionsLoadInBoundedPages() throws { let session = DeferredInspectionDebugSession() @@ -2721,6 +2947,7 @@ private final class DeferredInspectionDebugSession: DebugAdapterControllingSessi private(set) var startCount = 0 private(set) var launchConfigurations: [DebugLaunchConfiguration] = [] private(set) var breakpointUpdates: [[DebugSourceBreakpoint]] = [] + private(set) var executionCommands: [DebugExecutionCommand] = [] var failNextLaunch = false var onStateChange: ((DebugAdapterState) -> Void)? var onEvent: ((DebugAdapterEvent) -> Void)? @@ -2745,6 +2972,11 @@ private final class DeferredInspectionDebugSession: DebugAdapterControllingSessi threadID: Int, completion: (Result) -> Void )] = [] + private var evaluateRequests: [( + expression: String, + frameID: Int?, + completion: (Result) -> Void + )] = [] var stackTraceThreadIDs: [Int] { stackTraceRequests.map(\.threadID) } var scopeFrameIDs: [Int] { scopeRequests.map(\.frameID) } @@ -2760,6 +2992,8 @@ private final class DeferredInspectionDebugSession: DebugAdapterControllingSessi } } var exceptionInfoThreadIDs: [Int] { exceptionInfoRequests.map(\.threadID) } + var evaluateExpressions: [String] { evaluateRequests.map(\.expression) } + var evaluateFrameIDs: [Int?] { evaluateRequests.map(\.frameID) } init(capabilities: DebugAdapterCapabilities = .unknown) { self.capabilities = capabilities @@ -2792,10 +3026,17 @@ private final class DeferredInspectionDebugSession: DebugAdapterControllingSessi onStateChange?(.failed) } + func transition(to state: DebugAdapterState) { + self.state = state + onStateChange?(state) + } + func setBreakpoints(_ breakpoints: [DebugSourceBreakpoint], in _: URL) { breakpointUpdates.append(breakpoints) } - func execute(_: DebugExecutionCommand, threadID _: Int?) {} + func execute(_ command: DebugExecutionCommand, threadID _: Int?) { + executionCommands.append(command) + } func requestThreads(_: @escaping (Result<[DebugThread], Error>) -> Void) {} func requestExceptionInfo( @@ -2843,10 +3084,12 @@ private final class DeferredInspectionDebugSession: DebugAdapterControllingSessi } func evaluate( - _: String, - frameID _: Int?, - completion _: @escaping (Result) -> Void - ) {} + _ expression: String, + frameID: Int?, + completion: @escaping (Result) -> Void + ) { + evaluateRequests.append((expression, frameID, completion)) + } // This double deliberately delivers responses after cancellation to model // adapters and callback queues that cannot retract an already-sent result. @@ -2875,6 +3118,10 @@ private final class DeferredInspectionDebugSession: DebugAdapterControllingSessi func completeExceptionInfo(at index: Int, with info: DebugExceptionInfo) { exceptionInfoRequests[index].completion(.success(info)) } + + func completeEvaluation(at index: Int, with result: Result) { + evaluateRequests[index].completion(result) + } } @MainActor diff --git a/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift b/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift index fd089fc8..d8acaac4 100644 --- a/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift +++ b/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift @@ -7,6 +7,59 @@ import Testing @MainActor struct ExecutionModuleTests { + @Test + func configuredServerPortUsesArgumentsEnvironmentResourcesAndFrameworkDefault() async throws { + let root = URL(fileURLWithPath: "/workspace/service-port", isDirectory: true) + let properties = root.appendingPathComponent("src/main/resources/application.properties") + let configuration = RunConfiguration( + id: "spring:api", + name: "API", + kind: .springBoot, + modulePath: ".", + mainClass: "example.Application" + ) + + let argumentService = makeRunService( + configuration: configuration, + options: RunOptions( + vmArguments: "-Dserver.port=18081", + programArguments: "--server.port=18082", + environment: ["SERVER_PORT": "18083"] + ), + fileAccess: TestRunFileAccess(contents: [properties: "server.port=18084"]), + serverPortParser: FixedServerPortParser(port: 18084) + ) + await argumentService.loadProject(at: root, files: [properties], mavenProject: nil) + #expect(argumentService.configuredServerPort(for: configuration) == 18082) + + let environmentService = makeRunService( + configuration: configuration, + options: RunOptions(environment: ["SERVER_PORT": "18083"]), + fileAccess: TestRunFileAccess(contents: [properties: "server.port=18084"]), + serverPortParser: FixedServerPortParser(port: 18084) + ) + await environmentService.loadProject(at: root, files: [properties], mavenProject: nil) + #expect(environmentService.configuredServerPort(for: configuration) == 18083) + + let resourceService = makeRunService( + configuration: configuration, + options: RunOptions(), + fileAccess: TestRunFileAccess(contents: [properties: "server.port=18084"]), + serverPortParser: FixedServerPortParser(port: 18084) + ) + await resourceService.loadProject(at: root, files: [properties], mavenProject: nil) + #expect(resourceService.configuredServerPort(for: configuration) == 18084) + + let defaultService = makeRunService( + configuration: configuration, + options: RunOptions(), + fileAccess: TestRunFileAccess(), + serverPortParser: FixedServerPortParser(port: nil) + ) + await defaultService.loadProject(at: root, files: [], mavenProject: nil) + #expect(defaultService.configuredServerPort(for: configuration) == 8080) + } + @Test func disabledExecutionDoesNotConstructGraph() async throws { let recorder = Recorder() @@ -213,6 +266,30 @@ struct ExecutionModuleTests { } } +@MainActor +private func makeRunService( + configuration: RunConfiguration, + options: RunOptions, + fileAccess: TestRunFileAccess, + serverPortParser: FixedServerPortParser +) -> RunService { + RunService( + runtime: TestRuntime(), + process: TestStreamingProcess(), + processFactory: { TestStreamingProcess() }, + fileAccess: fileAccess, + preferences: TestRunPreferences(), + serverPortParser: serverPortParser, + runConfigurationOperations: SingleRunConfigurationOperations( + configuration: configuration, + options: options + ), + executableResolver: TestExecutableResolver(), + languageProviderCatalog: .compatibilityFallback, + languageRunProviders: .standard(catalog: .compatibilityFallback) + ) +} + @MainActor private final class Recorder { var factoryCalls = 0 var graphCalls = 0 @@ -284,8 +361,16 @@ private struct TestMavenOperations: MavenProjectOperations { } private struct TestRunFileAccess: RunFileAccess { + let contents: [URL: String] + + init(contents: [URL: String] = [:]) { + self.contents = contents + } + func isDirectory(at url: URL) -> Bool { false } - func readData(from url: URL) throws -> Data { Data() } + func readData(from url: URL) throws -> Data { + Data((contents[url.standardizedFileURL] ?? "").utf8) + } } @MainActor @@ -300,6 +385,41 @@ private struct TestServerPortParser: RunServerPortParsing { func serverPort(content: String, fileExtension: String) -> Int? { nil } } +private struct FixedServerPortParser: RunServerPortParsing { + let port: Int? + func serverPort(content _: String, fileExtension _: String) -> Int? { port } +} + +private struct SingleRunConfigurationOperations: RunConfigurationOperations { + let configuration: RunConfiguration + let options: RunOptions + + func inspect(at _: URL) -> ProjectRunConfigurationInspection { + ProjectRunConfigurationInspection(status: .ready, diagnostics: []) + } + func generate(at _: URL, files _: [URL], modulePaths _: [String]) throws -> RunConfigurationGenerationResult { + RunConfigurationGenerationResult(entryCount: 1) + } + func resolve(at _: URL, toolchainCandidates _: [ProjectToolchainCandidate]) throws -> RunConfigurationResolution { + RunConfigurationResolution( + configurations: [EffectiveRunConfiguration(configuration: configuration, options: options)], + diagnostics: [], + defaultConfigurationID: configuration.id + ) + } + func launchPlan( + at _: URL, + configurationID _: String, + currentFile _: String?, + classPath _: String?, + debugPort _: Int? + ) throws -> SharedLaunchPlan { + throw RunConfigurationOperationFailure(message: "Not required by the port resolution test") + } + func createConfiguration(_ draft: RunConfigurationDraft, at _: URL) throws -> String { draft.name } + func migrateLegacySettings(at _: URL, configurationIDs _: [String]) throws {} +} + @MainActor private final class TestExecutableResolver: RunExecutableResolving { func resolve(_ plan: SharedLaunchPlan, projectURL: URL, options: RunOptions) throws -> ResolvedRunExecutable { diff --git a/macos/Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift b/macos/Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift index 8c45b469..5dbeca25 100644 --- a/macos/Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift +++ b/macos/Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift @@ -276,6 +276,45 @@ struct LanguageIntelligenceModuleTests { )) } + @Test + func javaDebugLaunchTargetDoesNotBorrowAnotherFileWhenJdtlsReportsItsPath() async throws { + let root = URL(fileURLWithPath: "/workspace/java-debug", isDirectory: true) + let source = root.appendingPathComponent("service/src/main/java/example/UserService.java") + let otherMain = root.appendingPathComponent("service/src/main/java/example/Main.java") + let descriptor = try #require( + LanguageProviderCatalog.compatibilityFallback.provider(for: source) + ) + let session = WorkspaceStateLanguageServerSession() + let manager = LanguageToolingSessionManager( + catalog: .compatibilityFallback, + runtimes: [WorkspaceStateLanguageProviderRuntime( + descriptor: descriptor, + session: session + )] + ) + let task = Task { + try await manager.resolveJavaDebugLaunchTarget(fileURL: source, rootURL: root) + } + defer { task.cancel() } + + try await session.waitUntilStarted() + session.publish(.ready) + _ = try await session.waitForExecuteCommand() + session.completeExecuteReturningValue(.success(.array([ + .object([ + "mainClass": .string("service/example.Main"), + "projectName": .string("service"), + "filePath": .string(otherMain.path), + ]) + ]))) + + await #expect(throws: LanguageToolingSessionError.toolingUnavailable( + "No Java main method was found in UserService.java." + )) { + try await task.value + } + } + @Test func javaTestDiscoveryProjectsSortedClassesAndMethodsForTheTestsTree() async throws { let root = URL(fileURLWithPath: "/workspace/java-tests", isDirectory: true) diff --git a/macos/Tests/LitheTests/DebugToolbarPresentationTests.swift b/macos/Tests/LitheTests/DebugToolbarPresentationTests.swift new file mode 100644 index 00000000..96cc2c34 --- /dev/null +++ b/macos/Tests/LitheTests/DebugToolbarPresentationTests.swift @@ -0,0 +1,137 @@ +import Foundation +import Testing +import LitheCoreContracts +@testable import Lithe + +@Suite("IDEA-aligned Debug toolbar presentation") +struct DebugToolbarPresentationTests { + @Test + func primaryActionsKeepTheIDEAOrderAndGrouping() { + #expect(DebugToolbarPresentation.primaryActions == [ + .restartOrStart, + .stop, + .resume, + .pause, + .stepOver, + .stepInto, + .stepOut, + .viewBreakpoints, + .muteBreakpoints + ]) + #expect(DebugToolbarPresentation.separatorsAfter == [.stop, .stepOut]) + } + + @Test + func startActionUsesDebugBeforeLaunchAndRestartDuringASession() { + #expect(DebugToolbarPresentation.ideaAssetPath( + for: .restartOrStart, + isSessionActive: false + ) == "debugger/debug.svg") + #expect(DebugToolbarPresentation.ideaAssetPath( + for: .restartOrStart, + isSessionActive: true + ) == "debugger/restartDebug.svg") + } + + @Test + func everyPrimaryActionShipsLightAndDarkIDEAAssets() { + let iconRoot = repositoryRoot + .appendingPathComponent("macos/Resources/IDEAIcons", isDirectory: true) + + for action in DebugToolbarPresentation.primaryActions { + let resourcePath = DebugToolbarPresentation.ideaAssetPath( + for: action, + isSessionActive: true + ) + #expect(FileManager.default.fileExists( + atPath: iconRoot.appendingPathComponent(resourcePath).path + )) + #expect(FileManager.default.fileExists( + atPath: iconRoot.appendingPathComponent( + LitheIcons.darkIdeaAssetPath(for: resourcePath) + ).path + )) + } + } + + @Test + func darkAssetPathKeepsTheIDEADirectoryAndSuffixConvention() { + #expect( + LitheIcons.darkIdeaAssetPath(for: "debugger/stepOver.svg") + == "debugger/stepOver_dark.svg" + ) + } + + @Test + func breakpointStatesUseTheIDEAGutterGlyphs() { + #expect(LitheIcons.debuggerBreakpointAssetPath( + enabled: true, + verified: false, + muted: false + ) == "debugger/db_set_breakpoint.svg") + #expect(LitheIcons.debuggerBreakpointAssetPath( + enabled: true, + verified: true, + muted: false + ) == "debugger/db_verified_breakpoint.svg") + #expect(LitheIcons.debuggerBreakpointAssetPath( + enabled: false, + verified: true, + muted: false + ) == "debugger/db_disabled_breakpoint.svg") + #expect(LitheIcons.debuggerBreakpointAssetPath( + enabled: true, + verified: true, + muted: true + ) == "debugger/db_muted_breakpoint.svg") + } + + @Test + func toolbarCommandIDsMapToTheExistingKeymapCommands() { + #expect(LitheCommandCatalog.command(id: "debug-resume") != nil) + #expect(LitheCommandCatalog.command(id: "debug-step-over") != nil) + #expect(LitheCommandCatalog.command(id: "debug-step-into") != nil) + #expect(LitheCommandCatalog.command(id: "debug-step-out") != nil) + #expect(LitheCommandCatalog.command(id: "view-breakpoints") != nil) + } + + @Test + func statusTextIncludesTheActualStopReason() { + #expect(DebugToolbarPresentation.statusText( + for: .paused, + stoppedReason: "breakpoint" + ) == "Paused · Breakpoint") + #expect(DebugToolbarPresentation.statusText( + for: .paused, + stoppedReason: "exception" + ) == "Paused · Exception") + #expect(DebugToolbarPresentation.statusText( + for: .paused, + stoppedReason: "function breakpoint" + ) == "Paused · Method breakpoint") + #expect(DebugToolbarPresentation.statusText( + for: .paused, + stoppedReason: "data breakpoint" + ) == "Paused · Field breakpoint") + #expect(DebugToolbarPresentation.statusText( + for: .paused, + stoppedReason: " " + ) == "Paused") + } + + @Test + func statusTextMapsLifecycleStatesToStableLabels() { + #expect(DebugToolbarPresentation.statusText(for: .running, stoppedReason: nil) == "Running") + #expect(DebugToolbarPresentation.statusText(for: .launching, stoppedReason: nil) == "Launching") + #expect(DebugToolbarPresentation.statusText(for: .terminated, stoppedReason: nil) == "Finished") + #expect(DebugToolbarPresentation.statusText(for: .failed, stoppedReason: nil) == "Failed") + } + + private var repositoryRoot: URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + } +} diff --git a/macos/Tests/LitheTests/EditorGutterLayoutTests.swift b/macos/Tests/LitheTests/EditorGutterLayoutTests.swift index 9f2eeba2..c1179770 100644 --- a/macos/Tests/LitheTests/EditorGutterLayoutTests.swift +++ b/macos/Tests/LitheTests/EditorGutterLayoutTests.swift @@ -42,6 +42,30 @@ struct EditorGutterLayoutTests { #expect(!values[0].value.contains("\n")) } + @Test + func javaAutomaticDebugExpressionsKeepReceiversAndSkipMethods() { + let source = "return service.listUsers();" as NSString + + let expressions = DebugAutomaticExpressionProjection.javaExpressions( + forLine: 0, + in: source + ) + + #expect(expressions == ["service"]) + } + + @Test + func javaAutomaticDebugExpressionsAreBoundedAndSourceOrdered() { + let source = "a + b + c + d + e + f + g + h + i + j" as NSString + + let expressions = DebugAutomaticExpressionProjection.javaExpressions( + forLine: 0, + in: source + ) + + #expect(expressions == ["a", "b", "c", "d", "e", "f", "g", "h"]) + } + @MainActor @Test func inlineDebugValueOverlayUsesOnlyRemainingEditorWidth() throws { @@ -113,6 +137,23 @@ struct EditorGutterLayoutTests { #expect(editedLine == 6) } + @MainActor + @Test + func emptyExecutableLineContextMenuOffersSettingABreakpoint() throws { + let gutter = LineNumberGutterView(frame: NSRect(x: 0, y: 0, width: 80, height: 200)) + var toggledLine: Int? + gutter.updateDebugBreakpointLines( + [:], + onToggle: { toggledLine = $0 }, + canAdd: { $0 == 4 } + ) + + let menu = try #require(gutter.debugBreakpointContextMenu(forLine: 4)) + #expect(menu.items.map(\.title) == ["Set Breakpoint"]) + gutter.addDebugBreakpointFromMenu() + #expect(toggledLine == 4) + } + @MainActor @Test func foldingLinesChangesTheOverlayTargetGeometry() throws { @@ -381,13 +422,25 @@ struct EditorGutterLayoutTests { @Test func columnsHaveDistinctHitTargets() { let layout = EditorGutterLayout(lineNumberTextWidth: 24) - #expect(layout.hitTarget(at: 6, hasGitChange: false) == .breakpoint) + #expect(layout.hitTarget(at: 6, hasGitChange: false) == .lineNumber) #expect(layout.hitTarget(at: 22, hasGitChange: false) == .lineNumber) + #expect(layout.hitTarget(at: 34, hasGitChange: false) == .breakpoint) #expect(layout.hitTarget(at: 48, hasGitChange: false) == .implementation) #expect(layout.hitTarget(at: 68, hasGitChange: false) == .fold) #expect(layout.hitTarget(at: 78, hasGitChange: true) == .gitChange) } + @Test + func breakpointInteractionIncludesMarkerAndLineNumberColumns() { + let layout = EditorGutterLayout(lineNumberTextWidth: 24) + + #expect(layout.breakpointInteractionRange.contains(6)) + #expect(layout.breakpointInteractionRange.contains(22)) + #expect(layout.breakpointInteractionRange.contains(34)) + #expect(!layout.breakpointInteractionRange.contains(48)) + #expect(EditorDebugBreakpointAppearance.markerSize == 14) + } + @Test func gitColumnDoesNotConsumeClicksWithoutAChange() { let layout = EditorGutterLayout(lineNumberTextWidth: 24) @@ -428,7 +481,8 @@ struct EditorGutterLayoutTests { func lineNumberColumnExpandsWithoutOverlappingFollowingColumns() { let layout = EditorGutterLayout(lineNumberTextWidth: 34) #expect(layout.lineNumberRange.upperBound - layout.lineNumberRange.lowerBound == 37) - #expect(layout.lineNumberRange.upperBound == layout.implementationRange.lowerBound) + #expect(layout.lineNumberRange.upperBound == layout.breakpointRange.lowerBound) + #expect(layout.breakpointRange.upperBound == layout.implementationRange.lowerBound) #expect(layout.implementationRange.upperBound == layout.foldRange.lowerBound) #expect(layout.foldRange.upperBound == layout.gitChangeRange.lowerBound) #expect(layout.gitChangeRange.upperBound == layout.width) diff --git a/macos/Tests/LitheTests/EditorLayoutMetricsTests.swift b/macos/Tests/LitheTests/EditorLayoutMetricsTests.swift index e05efdd2..ebcf7dfa 100644 --- a/macos/Tests/LitheTests/EditorLayoutMetricsTests.swift +++ b/macos/Tests/LitheTests/EditorLayoutMetricsTests.swift @@ -17,8 +17,8 @@ struct EditorLayoutMetricsTests { @Test func standardGutterUsesDistinctBreakpointImplementationLineNumberAndFoldColumns() { let layout = EditorGutterLayout(lineNumberTextWidth: 0) - #expect(layout.breakpointRange.upperBound == layout.lineNumberRange.lowerBound) - #expect(layout.lineNumberRange.upperBound == layout.implementationRange.lowerBound) + #expect(layout.lineNumberRange.upperBound == layout.breakpointRange.lowerBound) + #expect(layout.breakpointRange.upperBound == layout.implementationRange.lowerBound) #expect(layout.implementationRange.upperBound == layout.foldRange.lowerBound) #expect(layout.foldRange.upperBound == layout.gitChangeRange.lowerBound) #expect(layout.gitChangeRange.upperBound == EditorLayoutMetrics.standardGutterWidth) diff --git a/macos/Tests/LitheTests/JavaTestDebugLaunchServiceTests.swift b/macos/Tests/LitheTests/JavaTestDebugLaunchServiceTests.swift index 79c4034d..6cda94cb 100644 --- a/macos/Tests/LitheTests/JavaTestDebugLaunchServiceTests.swift +++ b/macos/Tests/LitheTests/JavaTestDebugLaunchServiceTests.swift @@ -144,16 +144,40 @@ struct JavaTestDebugLaunchServiceTests { ) let resultServer = TestJavaTestResultServer(port: 43_128) model.javaTestWorkflowState.resultServer = resultServer + model.isTerminalVisible = true model.handleDebugSessionStateChange(.running) #expect(resultServer.stopCount == 0) #expect(model.javaTestWorkflowState.resultServer != nil) + #expect(model.isDebugVisible) + #expect(!model.isTerminalVisible) model.handleDebugSessionStateChange(.terminated) #expect(resultServer.stopCount == 1) #expect(model.javaTestWorkflowState.resultServer == nil) } + @Test + func pausedDebugStateShowsDebuggerAndActivatesApplication() { + let store = JavaTestDebugStore() + let settings = AppSettings(store: store) + let platformUI = DebugActivationPlatformUI() + let model = AppModel( + settings: settings, + services: MacServiceContainer( + store: store, + settings: settings, + moduleLaunchMode: .safeMode, + platformUI: platformUI + ).services + ) + + model.handleDebugSessionStateChange(.paused) + + #expect(model.isDebugVisible) + #expect(platformUI.activationCount == 1) + } + private func javaTestTarget(fileURL: URL) -> JavaTestDebugLaunchTarget { JavaTestDebugLaunchTarget( fileURL: fileURL, @@ -170,6 +194,22 @@ struct JavaTestDebugLaunchServiceTests { } } +@MainActor +private final class DebugActivationPlatformUI: PlatformUI { + private(set) var activationCount = 0 + + func activateApplication() { + activationCount += 1 + } + + func chooseDirectory(title: String, prompt: String) -> URL? { nil } + func chooseFile(title: String, prompt: String) -> URL? { nil } + func revealInFileBrowser(_ url: URL) {} + func open(_ url: URL) {} + func copyToClipboard(_ value: String) {} + func markdownImageFromClipboard() -> MarkdownImageSource? { nil } +} + @MainActor private final class TestJavaTestTargetResolver: JavaTestDebugLaunchTargetResolving { struct Request: Equatable { diff --git a/macos/Tests/LitheTests/KeyboardShortcutTests.swift b/macos/Tests/LitheTests/KeyboardShortcutTests.swift index 5e439814..61b035fc 100644 --- a/macos/Tests/LitheTests/KeyboardShortcutTests.swift +++ b/macos/Tests/LitheTests/KeyboardShortcutTests.swift @@ -8,7 +8,7 @@ struct KeyboardShortcutTests { @Test func catalogHasStableUniqueCommandsAndConflictFreeDefaults() { let commands = LitheCommandCatalog.commands - #expect(commands.count == 36) + #expect(commands.count == 37) #expect(Set(commands.map(\.id)).count == commands.count) let owners = commands.flatMap { command in @@ -21,6 +21,15 @@ struct KeyboardShortcutTests { } } + @Test + func toggleBreakpointUsesTheIDEADefaultShortcut() throws { + let command = try #require(LitheCommandCatalog.command(id: "toggle-breakpoint")) + + #expect(command.defaultBindings == [ + .keyPress(key: "f8", modifiers: [.command]) + ]) + } + @Test func viewBreakpointsUsesTheIDEADefaultShortcut() throws { let command = try #require(LitheCommandCatalog.command(id: "view-breakpoints")) @@ -58,6 +67,7 @@ struct KeyboardShortcutTests { #expect(actionIDs.contains("go-to-implementation")) #expect(actionIDs.contains("rebuild-java-index")) #expect(actionIDs.contains("spring-endpoints")) + #expect(actionIDs.contains("toggle-breakpoint")) #expect(actionIDs.contains("view-breakpoints")) } diff --git a/macos/Tests/LitheTests/MacDebugPortAvailabilityCheckerTests.swift b/macos/Tests/LitheTests/MacDebugPortAvailabilityCheckerTests.swift new file mode 100644 index 00000000..efde9398 --- /dev/null +++ b/macos/Tests/LitheTests/MacDebugPortAvailabilityCheckerTests.swift @@ -0,0 +1,47 @@ +import Darwin +import Testing +@testable import Lithe + +@Suite("macOS Debug port availability") +@MainActor +struct MacDebugPortAvailabilityCheckerTests { + @Test + func reportsAListeningPortAsUnavailableAndAReleasedPortAsAvailable() throws { + var descriptor = socket(AF_INET, SOCK_STREAM, 0) + #expect(descriptor >= 0) + guard descriptor >= 0 else { return } + defer { + if descriptor >= 0 { _ = close(descriptor) } + } + + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.stride) + address.sin_family = sa_family_t(AF_INET) + address.sin_port = 0 + address.sin_addr = in_addr(s_addr: INADDR_ANY) + let bindResult = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(descriptor, $0, socklen_t(MemoryLayout.stride)) + } + } + #expect(bindResult == 0) + #expect(listen(descriptor, 1) == 0) + + var boundAddress = sockaddr_in() + var boundLength = socklen_t(MemoryLayout.stride) + let nameResult = withUnsafeMutablePointer(to: &boundAddress) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + getsockname(descriptor, $0, &boundLength) + } + } + #expect(nameResult == 0) + let port = Int(UInt16(bigEndian: boundAddress.sin_port)) + #expect(port > 0) + + let checker = MacDebugPortAvailabilityChecker() + #expect(!checker.isPortAvailable(port)) + #expect(close(descriptor) == 0) + descriptor = -1 + #expect(checker.isPortAvailable(port)) + } +} diff --git a/macos/Tests/LitheTests/MacJdtWorkspaceStateTests.swift b/macos/Tests/LitheTests/MacJdtWorkspaceStateTests.swift index fac3884b..404df7b5 100644 --- a/macos/Tests/LitheTests/MacJdtWorkspaceStateTests.swift +++ b/macos/Tests/LitheTests/MacJdtWorkspaceStateTests.swift @@ -5,16 +5,16 @@ import Testing @Suite("macOS JDT LS workspace state") struct MacJdtWorkspaceStateTests { @Test - func fingerprintIncludesVersionWithoutRootBuildFiles() throws { + func fingerprintIncludesLanguageServerVersionAndInstallationPath() throws { let fixture = try makeFixture() defer { fixture.remove() } let state = MacJdtWorkspaceState( cacheDirectoryURL: fixture.cacheURL, - workspaceFingerprintResolver: { buildFiles, modules, version in + workspaceFingerprintResolver: { buildFiles, modules, identity in #expect(buildFiles.isEmpty) #expect(modules.isEmpty) - #expect(version == "7.6.5") + #expect(identity == "7.6.5|installation=\(fixture.executableURL.path)") return "core-fingerprint" }, workspaceKeyResolver: { _, _ in String(repeating: "a", count: 64) } @@ -90,7 +90,7 @@ struct MacJdtWorkspaceStateTests { ) #expect(Set(capture.last?.modules ?? []) == Set(["alpha", "zeta"])) - #expect(capture.last?.version == "7.6.5") + #expect(capture.last?.version == "7.6.5|installation=\(fixture.executableURL.path)") } @Test diff --git a/macos/Tests/LitheTests/MacProcessRunnerTests.swift b/macos/Tests/LitheTests/MacProcessRunnerTests.swift index 6bd050e9..d544103e 100644 --- a/macos/Tests/LitheTests/MacProcessRunnerTests.swift +++ b/macos/Tests/LitheTests/MacProcessRunnerTests.swift @@ -209,6 +209,23 @@ struct MacProcessRunnerTests { #expect(!processIsRunning(descendantPID)) } + @Test + func stoppedChildProcessReaperCollectsAnExitedChild() async throws { + let processID = try spawnExitedTestChild() + defer { reapTestChildIfNecessary(processID) } + let reaped = TestGate() + + MacStoppedChildProcessReaper().reapWhenExited(processID) { + reaped.open() + } + + #expect(await reaped.waitUntilOpen()) + var waitStatus: Int32 = 0 + errno = 0 + #expect(Darwin.waitpid(processID, &waitStatus, WNOHANG) == -1) + #expect(errno == ECHILD) + } + private func terminationResistantRequest(operationID: String) -> ProcessRequest { ProcessRequest( operationID: operationID, @@ -261,6 +278,46 @@ struct MacProcessRunnerTests { _ = Darwin.kill(pid, SIGKILL) } } + + private func reapTestChildIfNecessary(_ pid: pid_t) { + var waitStatus: Int32 = 0 + var waitResult = Darwin.waitpid(pid, &waitStatus, WNOHANG) + guard waitResult == 0 else { return } + _ = Darwin.kill(pid, SIGKILL) + repeat { + waitResult = Darwin.waitpid(pid, &waitStatus, 0) + } while waitResult == -1 && errno == EINTR + } + + private func spawnExitedTestChild() throws -> pid_t { + let executablePath = "/usr/bin/true" + var processID: pid_t = 0 + var arguments = [strdup(executablePath), nil] + var environment = ProcessInfo.processInfo.environment + .sorted { $0.key < $1.key } + .map { strdup("\($0.key)=\($0.value)") } + environment.append(nil) + defer { + arguments.forEach { free($0) } + environment.forEach { free($0) } + } + let result = arguments.withUnsafeMutableBufferPointer { argumentBuffer in + environment.withUnsafeMutableBufferPointer { environmentBuffer in + Darwin.posix_spawn( + &processID, + executablePath, + nil, + nil, + argumentBuffer.baseAddress, + environmentBuffer.baseAddress + ) + } + } + guard result == 0 else { + throw POSIXError(POSIXErrorCode(rawValue: result) ?? .EIO) + } + return processID + } } private struct DescendantFixture { diff --git a/macos/Tests/LitheTests/RealJavaDebugIntegrationTests.swift b/macos/Tests/LitheTests/RealJavaDebugIntegrationTests.swift index 5c2569fc..8f8dee0b 100644 --- a/macos/Tests/LitheTests/RealJavaDebugIntegrationTests.swift +++ b/macos/Tests/LitheTests/RealJavaDebugIntegrationTests.swift @@ -51,10 +51,24 @@ struct RealJavaDebugIntegrationTests { "src/main/java/com/example/demo/user/UserService.java" ) let serviceSource = try String(contentsOf: serviceURL, encoding: .utf8) - let breakpointLine = try #require(Self.line( + let serviceBreakpointLine = try #require(Self.line( containing: "return repository.findAll();", in: serviceSource )) + let serviceConstructorLine = try #require(Self.line( + containing: "this.repository = repository;", + in: serviceSource + )) + let controllerSource = try String(contentsOf: rootURL.appendingPathComponent( + "src/main/java/com/example/demo/user/UserController.java" + ), encoding: .utf8) + let controllerURL = rootURL.appendingPathComponent( + "src/main/java/com/example/demo/user/UserController.java" + ) + let controllerBreakpointLine = try #require(Self.line( + containing: "return service.listUsers();", + in: controllerSource + )) let core = RustCoreBridge() #expect(core.isAvailable) @@ -154,7 +168,32 @@ struct RealJavaDebugIntegrationTests { logs: Self.languageServerLogSummary(languageManager.languageServerLogs) ) } - feature.toggleBreakpoint(fileURL: serviceURL, line: breakpointLine) + // Start at the controller call so the real integration test exercises + // both Java step-into and step-out, not only a step-over at a leaf line. + feature.toggleBreakpoint(fileURL: controllerURL, line: controllerBreakpointLine) + feature.toggleBreakpoint(fileURL: serviceURL, line: serviceBreakpointLine) + feature.toggleBreakpoint(fileURL: serviceURL, line: serviceConstructorLine) + // Verify the Java adapter receives and honors the condition field, + // rather than only exercising an unconditional source breakpoint. + feature.updateBreakpoint( + fileURL: controllerURL, + line: controllerBreakpointLine, + enabled: true, + condition: "true", + hitCondition: "1", + logMessage: nil + ) + // A logpoint must emit a Debug Console message without stopping the + // application. Keep it on the constructor so it is exercised during + // Spring Boot startup before the HTTP request breakpoint. + feature.updateBreakpoint( + fileURL: serviceURL, + line: serviceConstructorLine, + enabled: true, + condition: nil, + hitCondition: nil, + logMessage: "entered UserService constructor" + ) var arguments: [String: ToolingJSONValue] = [ "mainClass": .string(target.mainClass), "cwd": .string(rootURL.path), @@ -184,13 +223,31 @@ struct RealJavaDebugIntegrationTests { feature.state == .running }, "Java Debug Server did not reach the running state. Output:\n\(feature.output)") #expect(await Self.waitUntil(timeout: .seconds(120)) { - feature.breakpoints.first?.verified == true - }, "The Java breakpoint was not verified. Output:\n\(feature.output)") + feature.breakpoints.count == 3 && feature.breakpoints.allSatisfy(\.verified) + }, "The Java breakpoints were not verified. Output:\n\(feature.output)") + #expect( + protocolTrace.entries.contains { $0.contains("\"condition\":\"true\"") }, + "The Java condition breakpoint was not sent to the adapter." + ) + #expect( + protocolTrace.entries.contains { $0.contains("\"hitCondition\":\"1\"") }, + "The Java hit-count breakpoint was not sent to the adapter." + ) + #expect( + protocolTrace.entries.contains { + $0.contains("\"logMessage\":\"entered UserService constructor\"") + }, + "The Java logpoint was not sent to the adapter." + ) guard await Self.waitForSpringServer(port: springPort, timeout: .seconds(120)) else { throw RealJavaDebugIntegrationError.springServerDidNotStart( "expectedPort=\(springPort)\n" + Self.debugSnapshot(feature, protocolTrace: protocolTrace) ) } + #expect( + feature.output.contains("entered UserService constructor"), + "The Java logpoint did not produce a Debug Console message." + ) var request = URLRequest( url: URL(string: "http://127.0.0.1:\(springPort)/api/users")! @@ -206,8 +263,8 @@ struct RealJavaDebugIntegrationTests { } guard await Self.waitUntil(timeout: .seconds(30), condition: { feature.selectedFrame?.sourceURL?.standardizedFileURL - == serviceURL.standardizedFileURL - && feature.selectedFrame?.line == breakpointLine + == controllerURL.standardizedFileURL + && feature.selectedFrame?.line == controllerBreakpointLine }) else { throw RealJavaDebugIntegrationError.stoppedFrameUnavailable( Self.debugSnapshot(feature, protocolTrace: protocolTrace) @@ -216,15 +273,53 @@ struct RealJavaDebugIntegrationTests { #expect(await Self.waitUntil(timeout: .seconds(30)) { !feature.variables.isEmpty }, "No variables were loaded for the stopped Java frame.") + // Exercise the same frame-scoped evaluation path used by the Debug + // console and inline inspection, not only the variables request. + let outputBeforeEvaluation = feature.output + feature.evaluate("service") + #expect(await Self.waitUntil(timeout: .seconds(30)) { + feature.output.count > outputBeforeEvaluation.count + && feature.output.contains("service =") + }, "The stopped Java frame did not evaluate the service expression.") + + feature.execute(.stepIn) + #expect(await Self.waitUntil(timeout: .seconds(30)) { + feature.state == .paused + && feature.selectedFrame?.sourceURL?.standardizedFileURL + == serviceURL.standardizedFileURL + && feature.selectedFrame?.line == serviceBreakpointLine + }, "Step into did not enter UserService.listUsers().\n\(Self.debugSnapshot(feature, protocolTrace: protocolTrace))") + + feature.execute(.stepOut) + #expect(await Self.waitUntil(timeout: .seconds(30)) { + feature.state == .paused + && feature.selectedFrame?.sourceURL?.standardizedFileURL + == controllerURL.standardizedFileURL + && feature.selectedFrame?.line == controllerBreakpointLine + }, "Step out did not return to UserController.list().\n\(Self.debugSnapshot(feature, protocolTrace: protocolTrace))") - let stoppedFrame = try #require(feature.selectedFrame) + let frameBeforeStepOver = try #require(feature.selectedFrame) feature.execute(.next) #expect(await Self.waitUntil(timeout: .seconds(30)) { - feature.state == .paused && feature.selectedFrame != stoppedFrame - }, "Step over did not reach the next Java frame.") + feature.state == .paused && feature.selectedFrame?.id != frameBeforeStepOver.id + }, "Step over did not reach the next Java source position.\n\(Self.debugSnapshot(feature, protocolTrace: protocolTrace))") feature.execute(.continueExecution) + guard await Self.waitUntil(timeout: .seconds(10), condition: { + feature.state == .running || feature.state == .terminated + }) else { + throw RealJavaDebugIntegrationError.debuggerDidNotResume( + "Continue did not leave the paused state.\n" + Self.debugSnapshot(feature, protocolTrace: protocolTrace) + ) + } - let response = try await Self.value(of: try #require(requestTask), timeout: .seconds(60)) + let response: (Data, URLResponse) + do { + response = try await Self.value(of: try #require(requestTask), timeout: .seconds(60)) + } catch { + throw RealJavaDebugIntegrationError.debuggerDidNotResume( + "(error)\n" + Self.debugSnapshot(feature, protocolTrace: protocolTrace) + ) + } let httpResponse = try #require(response.1 as? HTTPURLResponse) #expect(httpResponse.statusCode == 200) let body = String(decoding: response.0, as: UTF8.self) @@ -456,16 +551,19 @@ struct RealJavaDebugIntegrationTests { "\($0.title) enabled=\($0.enabled) verified=\($0.verified) message=\($0.message ?? "nil")" }.joined(separator: "\n") let threadSummary = feature.threads.map { "\($0.id):\($0.name)" }.joined(separator: ", ") + let recentTrace = protocolTrace.entries.suffix(40).joined(separator: "\n") return """ state=\(feature.state) stoppedReason=\(feature.stoppedReason ?? "nil") + selectedThreadID=\(feature.selectedThreadID.map(String.init) ?? "nil") + selectedFrame=\(feature.selectedFrame.map { "\($0.name) @ \($0.line)" } ?? "nil") threads=\(threadSummary) breakpoints: \(breakpointSummary) output: \(feature.output) - DAP trace: - \(protocolTrace.entries.joined(separator: "\n")) + Recent DAP trace: + \(recentTrace) """ } @@ -760,6 +858,7 @@ private enum RealJavaDebugIntegrationError: Error { case languageToolingFailed(message: String, logs: String) case springServerDidNotStart(String) case debuggerDidNotPause(String) + case debuggerDidNotResume(String) case stoppedFrameUnavailable(String) case invalidFixture(String) } diff --git a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift index f9ffa108..cdb44a3c 100644 --- a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -9,6 +9,16 @@ import Testing @Suite("Run configuration integration") @MainActor struct RunConfigurationIntegrationTests { + @Test + func portConflictTitleIncludesTheActualPortAndConfigurations() { + let conflict = RunPortConflict( + port: 18080, + configurationNames: ["api", "worker"] + ) + + #expect(conflict.title == "Port 18080 is used by api, worker") + } + @Test func javaBreakpointLocationPreflightRejectsNonExecutableLines() { let source = """ @@ -30,6 +40,26 @@ struct RunConfigurationIntegrationTests { #expect(!DebugBreakpointLocationValidator.isExecutableJavaLine(source: source, line: 8)) } + @Test + func javaBreakpointLocationPreflightRejectsTypeDeclarationsWithAnyModifierOrder() { + let source = """ + public final class Main { + private static interface Nested { + void run(); + } + protected abstract record Value(String text) { } + static enum Kind { ONE } + void execute() { } + } + """ + + #expect(!DebugBreakpointLocationValidator.isExecutableJavaLine(source: source, line: 1)) + #expect(!DebugBreakpointLocationValidator.isExecutableJavaLine(source: source, line: 2)) + #expect(!DebugBreakpointLocationValidator.isExecutableJavaLine(source: source, line: 5)) + #expect(!DebugBreakpointLocationValidator.isExecutableJavaLine(source: source, line: 6)) + #expect(DebugBreakpointLocationValidator.isExecutableJavaLine(source: source, line: 7)) + } + @Test func providerCapabilitiesKeepProcessEditorsLanguageNeutral() { let process = RunConfigurationKind.process(provider: "python.script").capabilities @@ -372,6 +402,159 @@ struct RunConfigurationIntegrationTests { #expect(configuration.arguments["env"] == .object(["APP_ENV": .string("dev")])) } + @Test + func selectedSpringBootConfigurationResolvesItsMainSourceWithoutAnOpenJavaEditor() throws { + let root = URL(fileURLWithPath: "/workspace/demo", isDirectory: true) + let readme = root.appendingPathComponent("README.md") + let application = root.appendingPathComponent( + "src/main/java/com/example/demo/DemoApplication.java" + ) + let controller = root.appendingPathComponent( + "src/main/java/com/example/demo/user/UserController.java" + ) + let configuration = RunConfiguration( + id: "spring-boot:demo", + name: "DemoApplication", + kind: .springBoot, + execution: .service, + modulePath: nil, + mainClass: "com.example.demo.DemoApplication" + ) + + let source = DebugLaunchSourceResolver().resolve( + configuration: configuration, + activeDocumentURL: readme, + projectFiles: [controller, application, readme], + workspaceURL: root + ) + + #expect(source == application) + } + + @Test + func selectedJavaConfigurationUsesItsModuleToDisambiguateDuplicateMainClasses() throws { + let root = URL(fileURLWithPath: "/workspace/multi-module", isDirectory: true) + let first = root.appendingPathComponent("first/src/main/java/com/acme/Main.java") + let second = root.appendingPathComponent("second/src/main/java/com/acme/Main.java") + let configuration = RunConfiguration( + id: "java-main:second", + name: "Second Main", + kind: .javaMain, + execution: .application, + modulePath: "second", + mainClass: "com.acme.Main" + ) + + let source = DebugLaunchSourceResolver().resolve( + configuration: configuration, + activeDocumentURL: first, + projectFiles: [first, second], + workspaceURL: root + ) + + #expect(source == second) + } + + @Test + func currentFileDebugStillUsesTheActiveEditorDocument() throws { + let root = URL(fileURLWithPath: "/workspace/current-file", isDirectory: true) + let current = root.appendingPathComponent("src/main/java/com/acme/Main.java") + let other = root.appendingPathComponent("src/main/java/com/acme/Other.java") + + let source = DebugLaunchSourceResolver().resolve( + configuration: .currentFile, + activeDocumentURL: current, + projectFiles: [other], + workspaceURL: root + ) + + #expect(source == current) + } + + @Test + func debugFallsBackFromNonLaunchableCurrentJavaFileToSpringBootConfiguration() { + let current = RunConfiguration( + id: "current-file", + name: "Current File", + kind: .currentFile, + execution: .application, + modulePath: nil, + mainClass: nil + ) + let springBoot = RunConfiguration( + id: "spring-boot:demo", + name: "DemoApplication", + kind: .springBoot, + execution: .service, + modulePath: nil, + mainClass: "com.example.demo.DemoApplication" + ) + + let selected = DebugLaunchSourceResolver().configurationForDebug( + selected: current, + activeDocumentText: "@Repository class UserRepository { }", + configurations: [current, springBoot] + ) + + #expect(selected.id == springBoot.id) + } + + @Test + func debugFallsBackWhenCurrentEditorTextIsUnavailable() { + let current = RunConfiguration( + id: "current-file", + name: "Current File", + kind: .currentFile, + execution: .application, + modulePath: nil, + mainClass: nil + ) + let springBoot = RunConfiguration( + id: "spring-boot:demo", + name: "DemoApplication", + kind: .springBoot, + execution: .service, + modulePath: nil, + mainClass: "com.example.demo.DemoApplication" + ) + + let selected = DebugLaunchSourceResolver().configurationForDebug( + selected: current, + activeDocumentText: nil, + configurations: [current, springBoot] + ) + + #expect(selected.id == springBoot.id) + } + + @Test + func debugKeepsCurrentJavaFileWhenItHasAMainMethod() { + let current = RunConfiguration( + id: "current-file", + name: "Current File", + kind: .currentFile, + execution: .application, + modulePath: nil, + mainClass: nil + ) + let springBoot = RunConfiguration( + id: "spring-boot:demo", + name: "DemoApplication", + kind: .springBoot, + execution: .service, + modulePath: nil, + mainClass: "com.example.demo.DemoApplication" + ) + + let selected = DebugLaunchSourceResolver().configurationForDebug( + selected: current, + activeDocumentText: "public static void main(String[] args) { }", + configurations: [current, springBoot] + ) + + #expect(selected.id == current.id) + } + @Test func javaTestDebugLaunchUsesTheSharedRustConfiguration() throws { let core = RustCoreBridge() @@ -544,6 +727,49 @@ struct RunConfigurationIntegrationTests { #expect(manager.activeAdapterIDs.isEmpty) } + @Test + func restoredJavaBreakpointInAnotherSourceIsSentWhenDebugStarts() throws { + let root = URL(fileURLWithPath: "/tmp/restored-java-debug", isDirectory: true) + let launchSource = root.appendingPathComponent("src/main/java/demo/DemoApplication.java") + let breakpointSource = root.appendingPathComponent("src/main/java/demo/UserController.java") + let persistence = RestoredBreakpointPersistence(snapshot: DebugBreakpointSnapshot( + breakpoints: [PersistedDebugBreakpoint( + relativePath: "src/main/java/demo/UserController.java", + line: 23 + )] + )) + var adapter: TestDebugAdapterSession? + let manager = DebugAdapterSessionManager( + providers: LanguageProviderCatalog.standard.debugProviders, + makeSession: { _, _ in + let value = TestDebugAdapterSession() + adapter = value + return value + } + ) + let feature = GenericDebugFeatureModel( + sessions: manager, + breakpointPersistence: persistence + ) + feature.openWorkspace(at: root) + + let started = feature.start( + fileURL: launchSource, + rootURL: root, + configuration: DebugLaunchConfiguration( + name: "DemoApplication", + request: .launch, + arguments: [:] + ) + ) + + #expect(started) + let update = try #require(adapter?.breakpointUpdates.first(where: { + $0.0 == breakpointSource.standardizedFileURL + })) + #expect(update.1 == [DebugSourceBreakpoint(line: 23)]) + } + @Test func standardTestProvidersDiscoverFilesAndBuildLanguageNeutralPlans() throws { let root = URL(fileURLWithPath: "/tmp/polyglot-tests", isDirectory: true) @@ -5016,6 +5242,20 @@ private final class TestDebugAdapterSession: DebugAdapterControllingSession { ) {} } +private final class RestoredBreakpointPersistence: DebugBreakpointPersisting, @unchecked Sendable { + private let snapshot: DebugBreakpointSnapshot + + init(snapshot: DebugBreakpointSnapshot) { + self.snapshot = snapshot + } + + func loadBreakpoints(for workspaceURL: URL) throws -> DebugBreakpointSnapshot? { + snapshot + } + + func saveBreakpoints(_ snapshot: DebugBreakpointSnapshot, for workspaceURL: URL) throws {} +} + @MainActor private final class TestDebugLanguageProviderRuntime: LanguageProviderRuntime { let descriptor: LanguageProviderDescriptor diff --git a/rust/lithe-core/src/debug/engine.rs b/rust/lithe-core/src/debug/engine.rs index 4fdf3ad7..5c77341a 100644 --- a/rust/lithe-core/src/debug/engine.rs +++ b/rust/lithe-core/src/debug/engine.rs @@ -2409,6 +2409,9 @@ mod tests { }) .unwrap(); assert!(defaults.class_name_filters.contains(&"$JDK".to_string())); + assert!(defaults + .class_name_filters + .contains(&"$Libraries".to_string())); assert!(defaults.skip_synthetics); assert!(!defaults.skip_constructors); diff --git a/rust/lithe-core/src/debug/types.rs b/rust/lithe-core/src/debug/types.rs index 4855c16f..87520386 100644 --- a/rust/lithe-core/src/debug/types.rs +++ b/rust/lithe-core/src/debug/types.rs @@ -125,6 +125,7 @@ impl Default for DebugSteppingFilters { fn default_java_class_name_filters() -> Vec { [ "$JDK", + "$Libraries", "com.ibm.ws.*", "com.springsource.loaded.*", "com.sun.proxy.*", diff --git a/scripts/test-macos.sh b/scripts/test-macos.sh index 4c04e1cf..10b00413 100755 --- a/scripts/test-macos.sh +++ b/scripts/test-macos.sh @@ -11,6 +11,22 @@ SWIFT_ARGS=( -Xcc -include -Xcc "$ROOT_DIR/scripts/MacOS13SDKCompatibility.h" ) + +# Real process-backed integration tests need the same Rust Core static library +# that is force-loaded into the application and the bridge verification binary. +# Keep it opt-in so the normal unit-test build remains lightweight and does not +# change its existing linkage behavior. +if [[ "${LITHE_RUN_JAVA_DEBUG_INTEGRATION:-0}" == "1" \ + || "${LITHE_RUN_JAVA_TEST_DEBUG_INTEGRATION:-0}" == "1" ]]; then + case "$(uname -m)" in + arm64) RUST_TARGET="aarch64-apple-darwin" ;; + x86_64) RUST_TARGET="x86_64-apple-darwin" ;; + *) print -u2 -- "Unsupported host architecture for Rust Core integration tests: $(uname -m)"; exit 1 ;; + esac + RUST_LIBRARY="$(scripts/build-rust-core.sh --debug --target "$RUST_TARGET")" + SWIFT_ARGS+=(-Xlinker -force_load -Xlinker "$RUST_LIBRARY") +fi + if ! /usr/bin/xcrun ld -help 2>&1 | /usr/bin/grep -q -- '-no_warn_duplicate_libraries'; then SWIFT_ARGS+=(-Xswiftc "-ld-path=$ROOT_DIR/scripts/ld-macos13-compat.sh") fi diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 37daf6a5..70347fed 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -415,6 +415,8 @@ fields inside a supplied value are empty or false, so future adapters never inherit Java policy accidentally. Java class patterns support `$JDK`, `$Libraries`, and adapter-compatible wildcards. Other adapters default to an unfiltered policy until their integration defines one. +Java defaults include both `$JDK` and `$Libraries`, matching the IDE convention +of collapsing platform and dependency frames while retaining project frames. The portable cases are in `shared/fixtures/debug/stepping-filters-v1.json`. diff --git a/shared/fixtures/debug/stepping-filters-v1.json b/shared/fixtures/debug/stepping-filters-v1.json index 61676859..1fa70005 100644 --- a/shared/fixtures/debug/stepping-filters-v1.json +++ b/shared/fixtures/debug/stepping-filters-v1.json @@ -9,6 +9,7 @@ "expected": { "classNameFilters": [ "$JDK", + "$Libraries", "com.ibm.ws.*", "com.springsource.loaded.*", "com.sun.proxy.*", From ef55aee8568c471f1a1ab7cc59509b04bb7e7034 Mon Sep 17 00:00:00 2001 From: fenghp Date: Mon, 31 Aug 2026 09:40:10 +0800 Subject: [PATCH 58/66] =?UTF-8?q?fix(macos):=20=E7=94=A8=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=8C=BA=E4=B8=96=E4=BB=A3=E9=97=AD=E7=8E=AF=E5=90=8C=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E9=87=8D=E5=BC=80=E7=9A=84=E8=BA=AB=E4=BB=BD=E5=88=A4?= =?UTF-8?q?=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同一路径重开时 URL 相同,旧会话的入口任务与旧 rebuild 都会被判为当前。改为每次 open/close 推进世代,入口任务、await 后的守卫与 rebuild 一并比较 URL + 世代。 --- .../AppModel/AppModel+Development.swift | 184 ++++++++++-------- .../Lithe/Models/AppModel/AppModel.swift | 9 +- .../Application/WorkspaceFeatureModel.swift | 18 +- .../LitheTests/LitheCoreLogicTests.swift | 74 +++++++ .../Tests/LitheTests/RunEntryPointTests.swift | 118 ++++++++++- 5 files changed, 317 insertions(+), 86 deletions(-) diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift index 025ecd4d..b9c2dab7 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift @@ -3,12 +3,23 @@ import LitheCoreContracts import LitheExecutionModule import LitheModuleAPI +/// One opening of one workspace. +/// +/// The path alone repeats when the same project is closed and reopened, so a +/// task that started before the reopen would still compare equal to the current +/// workspace. Pairing the path with the opening's generation is what lets such a +/// task be recognized as belonging to a session that is over. +struct WorkspaceIdentity: Equatable { + let url: URL + let generation: Int +} + /// An action deferred until the run feature holds the current workspace snapshot. /// -/// The workspace it was deferred for is part of the value so a snapshot applied -/// for a different workspace cannot resume it. Direct launch entry points also -/// keep the concrete configuration (or the all-services intent) so resume can -/// re-issue the same action the UI asked for. +/// The opening it was deferred for is part of the value so a snapshot applied for +/// a different workspace — or for a later opening of the same one — cannot resume +/// it. Direct launch entry points also keep the concrete configuration (or the +/// all-services intent) so resume can re-issue the same action the UI asked for. struct PendingRunAction: Equatable { enum Kind: Equatable { case run @@ -19,17 +30,17 @@ struct PendingRunAction: Equatable { } let kind: Kind - let workspace: URL + let identity: WorkspaceIdentity } -/// Result of bringing the run feature up to a specific workspace's snapshot. +/// Result of bringing the run feature up to a specific opening's snapshot. /// -/// Distinguishing "still waiting on this workspace" from "this entry task is -/// stale" is what stops a cross-workspace await from re-recording the old -/// action against the new project's pending slot. +/// Distinguishing "still waiting on this opening" from "this entry task is +/// stale" is what stops an await that outlived a project switch or a reopen from +/// re-recording the old action against the current pending slot. private enum RunProjectReadiness: Equatable { case ready - case waitingForSnapshot(workspace: URL) + case waitingForSnapshot(identity: WorkspaceIdentity) case stale } @@ -181,10 +192,10 @@ extension AppModel { /// inventory, and scanning it would overwrite `generated.json` with stale /// entry points. func generateRunConfigurations() async { - guard let workspace = workspaceURL?.standardizedFileURL else { return } + guard let identity = currentWorkspaceIdentity else { return } guard let runFeature = await activateExecutionModule()?.runFeature else { return } - guard isCurrentWorkspace(workspace) else { return } - switch await ensureRunProjectReady(runFeature, for: workspace) { + guard isCurrentWorkspace(identity) else { return } + switch await ensureRunProjectReady(runFeature, for: identity) { case .ready: await runFeature.generateRunConfigurations() case .waitingForSnapshot: @@ -239,7 +250,9 @@ extension AppModel { resumesDeferredRunAction: Bool = false ) async { let target = workspaceURL.standardizedFileURL - guard isCurrentWorkspace(target) else { return } + // The caller established that this load belongs to the current opening, + // so the identity is captured here and re-checked after every await. + guard let identity = currentWorkspaceIdentity, identity.url == target else { return } prepareJavaLanguageServerForWorkspaceIfNeeded( at: target, files: files @@ -252,9 +265,10 @@ extension AppModel { }) ) guard let execution = await activateExecutionModule() else { return } - // Module activation suspends; a project switch must not let this load - // write the captured inventory into the new workspace's run service. - guard isCurrentWorkspace(target) else { return } + // Module activation suspends; a project switch or a reopen must not let + // this load write the captured inventory into the new opening's run + // service. + guard isCurrentWorkspace(identity) else { return } execution.tests.discover(workspaceURL: target, files: files) // `files` and `snapshotID` are captured together by the caller. Reading // the applied snapshot here instead would pair this file list with a @@ -264,24 +278,30 @@ extension AppModel { files: files, snapshotID: snapshotID ) - guard isCurrentWorkspace(target) else { return } + guard isCurrentWorkspace(identity) else { return } guard resumesDeferredRunAction else { return } resumeDeferredRunAction( execution.runFeature, - workspace: target, + identity: identity, snapshotID: snapshotID ) } - private func isCurrentWorkspace(_ workspace: URL) -> Bool { - workspaceURL?.standardizedFileURL == workspace.standardizedFileURL + /// The opening an entry task captures before its first await. + var currentWorkspaceIdentity: WorkspaceIdentity? { + guard let url = workspaceURL?.standardizedFileURL else { return nil } + return WorkspaceIdentity(url: url, generation: workspaceFeature.workspaceGeneration) + } + + private func isCurrentWorkspace(_ identity: WorkspaceIdentity) -> Bool { + currentWorkspaceIdentity == identity } - /// Brings the run feature up to the snapshot for a captured workspace. + /// Brings the run feature up to the snapshot for a captured opening. /// - /// Entry tasks capture the workspace before any await so a cross-project - /// switch can be reported as `.stale` instead of being re-deferred against - /// whatever URL is current when the load finishes. + /// Entry tasks capture the opening before any await so a project switch — or + /// a close and reopen of the same path — can be reported as `.stale` instead + /// of being re-deferred against whatever is current when the load finishes. /// /// When a newer snapshot is published but the run service still holds an /// older `.ready` inventory for this workspace, the snapshot callback owns @@ -293,15 +313,15 @@ extension AppModel { /// window) so readiness does not wait on a callback that may never arrive. private func ensureRunProjectReady( _ runFeature: RunFeatureModel, - for workspace: URL + for identity: WorkspaceIdentity ) async -> RunProjectReadiness { - let target = workspace.standardizedFileURL - guard isCurrentWorkspace(target) else { return .stale } + guard isCurrentWorkspace(identity) else { return .stale } + let target = identity.url // One read, so the file list and the identity describe the same scan. let applied = workspaceFeature.appliedSnapshot if runFeature.isProjectReady(for: target, snapshotID: applied?.id) { return .ready } if applied != nil, runFeature.hasReadyInventory(for: target) { - return .waitingForSnapshot(workspace: target) + return .waitingForSnapshot(identity: identity) } // No matching ready inventory: bind provisionally, or apply the // published scan when one already exists. @@ -314,32 +334,33 @@ extension AppModel { // flight, including its deferred-run resume with nothing pending yet. // Comparing against the pre-await capture would then treat a ready // project as not ready, defer the action, and leave it stranded. - guard isCurrentWorkspace(target) else { return .stale } + guard isCurrentWorkspace(identity) else { return .stale } let current = workspaceFeature.appliedSnapshot if runFeature.isProjectReady(for: target, snapshotID: current?.id) { return .ready } - return .waitingForSnapshot(workspace: target) + return .waitingForSnapshot(identity: identity) } /// Continues an action that arrived before the snapshot did. The workspace /// rebuild always finishes by applying a snapshot, so recording the intent is /// enough to resume without polling or waiting. /// - /// A load for workspace A must not clear a pending action that belongs to B: - /// openProject already cleared A's pending on the switch, and a stale A - /// callback arriving later would otherwise wipe B's newly recorded intent. + /// A load for one opening must not clear a pending action that belongs to + /// another: openProject already cleared the old pending on the switch, and a + /// stale callback arriving later would otherwise wipe the newly recorded + /// intent. /// `snapshotID` is the scan this load just applied, not whatever the /// workspace holds now. Reading the current identity here would compare the /// run feature against a scan it has not consumed. private func resumeDeferredRunAction( _ runFeature: RunFeatureModel, - workspace: URL, + identity: WorkspaceIdentity, snapshotID: UUID? ) { guard let action = pendingRunAction else { return } - guard action.workspace == workspace.standardizedFileURL else { return } - guard runFeature.isProjectReady(for: workspace, snapshotID: snapshotID) else { + guard action.identity == identity else { return } + guard runFeature.isProjectReady(for: identity.url, snapshotID: snapshotID) else { return } setPendingRunAction(nil) @@ -352,10 +373,10 @@ extension AppModel { } } - /// Records an action for the workspace the entry task captured, not whatever - /// URL happens to be current after an await. - private func clearPendingRunAction(for workspace: URL) { - guard pendingRunAction?.workspace == workspace.standardizedFileURL else { return } + /// Records an action for the opening the entry task captured, not whatever + /// workspace happens to be current after an await. + private func clearPendingRunAction(for identity: WorkspaceIdentity) { + guard pendingRunAction?.identity == identity else { return } setPendingRunAction(nil) } @@ -366,23 +387,22 @@ extension AppModel { scheduleObjectWillChangeRelay() } - private func deferRunAction(_ kind: PendingRunAction.Kind, for workspace: URL) { - let target = workspace.standardizedFileURL - guard isCurrentWorkspace(target) else { return } - setPendingRunAction(PendingRunAction(kind: kind, workspace: target)) + private func deferRunAction(_ kind: PendingRunAction.Kind, for identity: WorkspaceIdentity) { + guard isCurrentWorkspace(identity) else { return } + setPendingRunAction(PendingRunAction(kind: kind, identity: identity)) } private func runSelectedConfigurationAfterActivation() async { - guard let workspace = workspaceURL?.standardizedFileURL else { return } + guard let identity = currentWorkspaceIdentity else { return } guard let runFeature = await activateExecutionModule()?.runFeature else { return } - guard isCurrentWorkspace(workspace) else { return } - switch await ensureRunProjectReady(runFeature, for: workspace) { + guard isCurrentWorkspace(identity) else { return } + switch await ensureRunProjectReady(runFeature, for: identity) { case .ready: - clearPendingRunAction(for: workspace) - case .waitingForSnapshot(let waitingWorkspace): + clearPendingRunAction(for: identity) + case .waitingForSnapshot(let waitingIdentity): // Launching from a provisional inventory resolves toolchains without // the Maven project, so wait for the snapshot instead of running. - deferRunAction(.run, for: waitingWorkspace) + deferRunAction(.run, for: waitingIdentity) return case .stale: return @@ -399,7 +419,7 @@ extension AppModel { )) { return } - guard isCurrentWorkspace(workspace) else { return } + guard isCurrentWorkspace(identity) else { return } if configuration.usesCurrentEditorFile, let activeDocument, activeDocument.isDirty { @@ -412,7 +432,7 @@ extension AppModel { return } } - guard isCurrentWorkspace(workspace) else { return } + guard isCurrentWorkspace(identity) else { return } runFeature.runSelected(currentFileURL: activeDocument?.url) isRunVisible = true isGitLogVisible = false @@ -426,15 +446,15 @@ extension AppModel { isRunVisible = true Task { [weak self] in guard let self else { return } - guard let workspace = workspaceURL?.standardizedFileURL else { return } + guard let identity = currentWorkspaceIdentity else { return } guard let runFeature = await activateExecutionModule()?.runFeature else { return } - guard isCurrentWorkspace(workspace) else { return } + guard isCurrentWorkspace(identity) else { return } guard runFeature.lastConfiguration != nil else { return } - switch await ensureRunProjectReady(runFeature, for: workspace) { + switch await ensureRunProjectReady(runFeature, for: identity) { case .ready: - clearPendingRunAction(for: workspace) - case .waitingForSnapshot(let waitingWorkspace): - deferRunAction(.restart, for: waitingWorkspace) + clearPendingRunAction(for: identity) + case .waitingForSnapshot(let waitingIdentity): + deferRunAction(.restart, for: waitingIdentity) return case .stale: return @@ -447,7 +467,7 @@ extension AppModel { )) { return } - guard isCurrentWorkspace(workspace) else { return } + guard isCurrentWorkspace(identity) else { return } runFeature.restart() } } @@ -455,19 +475,19 @@ extension AppModel { func startRunConfiguration(_ configuration: RunConfiguration) { Task { [weak self] in guard let self else { return } - guard let workspace = workspaceURL?.standardizedFileURL else { return } + guard let identity = currentWorkspaceIdentity else { return } guard let runFeature = await activateExecutionModule()?.runFeature else { return } - guard isCurrentWorkspace(workspace) else { return } - switch await ensureRunProjectReady(runFeature, for: workspace) { + guard isCurrentWorkspace(identity) else { return } + switch await ensureRunProjectReady(runFeature, for: identity) { case .ready: - clearPendingRunAction(for: workspace) - case .waitingForSnapshot(let waitingWorkspace): + clearPendingRunAction(for: identity) + case .waitingForSnapshot(let waitingIdentity): // Direct play buttons reach here without going through // `runSelectedConfiguration`, so they need the same readiness // gate and must remember which configuration to resume — bound - // to the workspace this task started for, not the URL after an - // await. - deferRunAction(.startConfiguration(configuration), for: waitingWorkspace) + // to the opening this task started for, not whatever is current + // after an await. + deferRunAction(.startConfiguration(configuration), for: waitingIdentity) return case .stale: return @@ -477,7 +497,7 @@ extension AppModel { currentFileURL: activeDocument?.url, runFeature: runFeature ) else { return } - guard isCurrentWorkspace(workspace) else { return } + guard isCurrentWorkspace(identity) else { return } runFeature.startConfiguration(configuration) } } @@ -485,14 +505,14 @@ extension AppModel { func runAllServiceConfigurations() { Task { [weak self] in guard let self else { return } - guard let workspace = workspaceURL?.standardizedFileURL else { return } + guard let identity = currentWorkspaceIdentity else { return } guard let runFeature = await activateExecutionModule()?.runFeature else { return } - guard isCurrentWorkspace(workspace) else { return } - switch await ensureRunProjectReady(runFeature, for: workspace) { + guard isCurrentWorkspace(identity) else { return } + switch await ensureRunProjectReady(runFeature, for: identity) { case .ready: - clearPendingRunAction(for: workspace) - case .waitingForSnapshot(let waitingWorkspace): - deferRunAction(.runAllServices, for: waitingWorkspace) + clearPendingRunAction(for: identity) + case .waitingForSnapshot(let waitingIdentity): + deferRunAction(.runAllServices, for: waitingIdentity) return case .stale: return @@ -503,7 +523,7 @@ extension AppModel { currentFileURL: nil, runFeature: runFeature ) else { return } - guard isCurrentWorkspace(workspace) else { return } + guard isCurrentWorkspace(identity) else { return } } runFeature.runAllServices() } @@ -598,16 +618,16 @@ extension AppModel { } private func startDebuggingAfterActivation() async { - guard let workspace = workspaceURL?.standardizedFileURL else { return } + guard let identity = currentWorkspaceIdentity else { return } guard let execution = await activateExecutionModule(), let debug = await activateDebugModule() else { return } - guard isCurrentWorkspace(workspace) else { return } + guard isCurrentWorkspace(identity) else { return } let runFeature = execution.runFeature - switch await ensureRunProjectReady(runFeature, for: workspace) { + switch await ensureRunProjectReady(runFeature, for: identity) { case .ready: - clearPendingRunAction(for: workspace) - case .waitingForSnapshot(let waitingWorkspace): - deferRunAction(.debug, for: waitingWorkspace) + clearPendingRunAction(for: identity) + case .waitingForSnapshot(let waitingIdentity): + deferRunAction(.debug, for: waitingIdentity) return case .stale: return diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 05e7b2f3..1f8137ea 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -960,11 +960,18 @@ final class AppModel: ObservableObject, Identifiable { pendingProjectItemDeletion = nil recentProjects = recentProjectsStore.record(normalizedURL, in: recentProjects) + // The rebuild belongs to this opening. Reopening the same path advances + // the generation, so a rebuild left over from the previous opening stops + // instead of publishing its snapshot into this one. + let generation = workspaceFeature.workspaceGeneration Task { _ = await workspaceFeature.rebuild( at: normalizedURL, rules: visibilityRules, - isCurrent: { [weak self] in self?.workspaceURL == normalizedURL } + isCurrent: { [weak self] in + self?.workspaceURL == normalizedURL + && self?.workspaceFeature.workspaceGeneration == generation + } ) } } diff --git a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift index f78595d0..c1d0a7a8 100644 --- a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift +++ b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift @@ -19,6 +19,14 @@ package final class WorkspaceFeatureModel: ObservableObject { @Published package private(set) var appliedSnapshot: WorkspaceSnapshot? package var projectFiles: [URL] { appliedSnapshot?.files ?? [] } + + /// Counts openings of a workspace, so consumers can tell one opening apart + /// from the next even when both use the same path. + /// + /// A URL alone repeats when the same project is closed and reopened: a task + /// that started before the reopen would still compare equal and be treated + /// as current. Every open and every reset advances this instead. + @Published package private(set) var workspaceGeneration = 0 @Published package private(set) var isLoadingWorkspace = false @Published package private(set) var isRefreshingWorkspace = false @Published package private(set) var loadErrorMessage: String? @@ -167,6 +175,7 @@ package final class WorkspaceFeatureModel: ObservableObject { } package func reset() { + workspaceGeneration += 1 if let workspaceURL { scheduleSearchIndexInvalidation(at: workspaceURL, rules: visibilityRules) } @@ -208,6 +217,7 @@ package final class WorkspaceFeatureModel: ObservableObject { } package func beginWorkspace(at url: URL, visibilityRules: FileVisibilityRules) { + workspaceGeneration += 1 workspaceURL = url.standardizedFileURL self.visibilityRules = visibilityRules hasRestoredWorkspaceSession = false @@ -339,10 +349,16 @@ package final class WorkspaceFeatureModel: ObservableObject { refreshTask?.cancel() pendingExternalPaths.removeAll() externalRefreshGeneration += 1 + // A refresh belongs to the opening that started it. Comparing the path + // alone would let a refresh that outlives a close/reopen of the same + // project publish its snapshot into the new opening. + let generation = workspaceGeneration _ = await rebuild( at: workspaceURL, rules: visibilityRules, - isCurrent: { [weak self] in self?.workspaceURL == workspaceURL } + isCurrent: { [weak self] in + self?.workspaceURL == workspaceURL && self?.workspaceGeneration == generation + } ) } diff --git a/macos/Tests/LitheTests/LitheCoreLogicTests.swift b/macos/Tests/LitheTests/LitheCoreLogicTests.swift index a549e213..200acc5d 100644 --- a/macos/Tests/LitheTests/LitheCoreLogicTests.swift +++ b/macos/Tests/LitheTests/LitheCoreLogicTests.swift @@ -3287,6 +3287,80 @@ struct EditorDocumentTests { #expect(snapshotLoadCount == 0, "the stale rebuild must not deliver onSnapshotLoaded") } + /// Closing and reopening the same path leaves the workspace URL unchanged, so + /// only the opening's generation can tell a refresh that outlived the close + /// from one that belongs to the current session. Without it, the earlier + /// refresh would publish its scan into the new opening after `reset`. + @Test + @MainActor + func refreshFromAnEarlierOpeningOfTheSamePathDoesNotDeliverItsSnapshot() async { + let enteredRestore = TestGate() + let releaseRestore = TestGate() + defer { releaseRestore.open() } + + let operations = SequencedWorkspaceOperations(snapshotAvailability: [true]) + let sessionStore = WorkspaceSessionStore(store: MutableKeyValueStore()) + let workspace = URL(fileURLWithPath: "/tmp/lithe-same-path-reopen-refresh") + sessionStore.save( + WorkspaceSession(openPaths: [], activePath: nil, selectedSidebar: "project"), + for: workspace + ) + + var snapshotLoadCount = 0 + let model = WorkspaceFeatureModel( + operations: operations, + fileOperations: EmptyWorkspaceFileOperations(), + fileStorage: InMemoryFileStorage(), + gitWatchContextProvider: SequencedGitWatchContextProvider([nil]), + directoryWatcherFactory: TestDirectoryWatcherFactory(), + workspaceSessionStore: sessionStore + ) + model.configure( + documentsProvider: { [] }, + activeDocumentProvider: { nil }, + selectedSidebarProvider: { "project" }, + setSelectedSidebar: { _ in }, + restoreSession: { _, _ in + enteredRestore.open() + _ = await releaseRestore.waitUntilOpen(timeout: .seconds(5)) + }, + openFile: { _ in }, + notify: { _ in }, + recordHistory: { _, _ in }, + relocateHistory: { _, _ in }, + relocateOpenDocuments: { _, _ in }, + closeDocuments: { _ in }, + processExternalChanges: { _ in false }, + reloadProjectServices: {}, + refreshGit: {}, + updateHistoryVisibilityRules: { _ in }, + onSnapshotLoaded: { _, _, _ in snapshotLoadCount += 1 } + ) + + model.beginWorkspace(at: workspace, visibilityRules: .default) + // The refresh builds its own current guard, so this exercises production + // identity rather than a guard supplied by the test. + let refreshTask = Task { await model.refreshCurrent() } + + #expect(await enteredRestore.waitUntilOpen(timeout: .seconds(5))) + #expect(model.appliedSnapshot != nil, "the snapshot should already be published") + + // Close and reopen the same path while the refresh is suspended. + model.reset() + model.beginWorkspace(at: workspace, visibilityRules: .default) + releaseRestore.open() + await refreshTask.value + + #expect( + snapshotLoadCount == 0, + "a refresh from the previous opening must not deliver its snapshot to the new one" + ) + #expect( + model.appliedSnapshot == nil, + "the new opening has not scanned yet, so no snapshot should be applied" + ) + } + @Test @MainActor func capturedProjectDeletionSurvivesConfirmationDialogDismissal() async throws { diff --git a/macos/Tests/LitheTests/RunEntryPointTests.swift b/macos/Tests/LitheTests/RunEntryPointTests.swift index 5ec817c8..dc5c626b 100644 --- a/macos/Tests/LitheTests/RunEntryPointTests.swift +++ b/macos/Tests/LitheTests/RunEntryPointTests.swift @@ -418,7 +418,7 @@ struct RunEntryPointTests { runConfigurations.release(2) let deferredForB = await awaitLoadDrivenChange(on: model) { model.pendingRunAction?.kind == .startConfiguration(configurationB) - && model.pendingRunAction?.workspace == workspaceB.root.standardizedFileURL + && model.pendingRunAction?.identity.url == workspaceB.root.standardizedFileURL } #expect(deferredForB, "B should record its own deferred direct start") @@ -434,11 +434,82 @@ struct RunEntryPointTests { ) #expect( model.pendingRunAction?.kind == .startConfiguration(configurationB) - && model.pendingRunAction?.workspace == workspaceB.root.standardizedFileURL, + && model.pendingRunAction?.identity.url == workspaceB.root.standardizedFileURL, "B's pending action must survive the stale A task finishing" ) } + /// Reopening the same path starts a new session while the URL stays the same, + /// so only the opening's generation separates the two. An entry task from the + /// previous opening must be discarded — otherwise it finds the new opening's + /// snapshot already applied, reads that as "ready", and launches its own + /// configuration into a session the user has replaced. + @Test + func directStartFromAnEarlierOpeningOfTheSameWorkspaceIsDiscarded() async throws { + let workspace = try JavaWorkspaceFixture() + defer { workspace.remove() } + + // The first opening never gets a snapshot, so the direct start takes the + // pre-snapshot path and suspends in its own load; the second opening + // scans normally. + let workspaceOperations = UnavailableThenReadyWorkspaceOperations(snapshot: workspace.snapshot) + let runConfigurations = InspectionGatedRunConfigurationOperations() + defer { runConfigurations.releaseAll() } + // This test does not coordinate on the watch configuration, and the real + // provider would run Git twice for the two openings. + let watchContext = GatedGitWatchContextProvider() + watchContext.releaseAll() + let model = makeAppModel( + workspaceOperations: workspaceOperations, + runConfigurationOperations: runConfigurations, + gitWatchContextProvider: watchContext + ) + let earlierConfiguration = ReadyRunConfigurationOperations.entryPoint + + model.openProjectDirectly(workspace.root) + model.startRunConfiguration(earlierConfiguration) + #expect( + await runConfigurations.inspectionEntered(1), + "the direct start never began its own load" + ) + + // Reopen the same path and let this opening reach a fully loaded state. + model.openProjectDirectly(workspace.root) + #expect(model.pendingRunAction == nil, "reopening must clear the previous pending") + #expect( + await runConfigurations.inspectionEntered(2), + "the reopened project never loaded its own snapshot" + ) + runConfigurations.release(2) + let readyForCurrentOpening = await awaitLoadDrivenChange(on: model) { + model.runFeatureIfActive?.isProjectReady( + for: workspace.root, + snapshotID: model.workspaceSnapshotID + ) == true + } + #expect(readyForCurrentOpening, "the reopened project never became ready") + let currentSnapshotID = model.workspaceSnapshotID + + // The earlier opening's task finishes last. Its captured URL still + // matches, so only the generation can reject it. + runConfigurations.release(1) + #expect( + await runConfigurations.launchPlanNotRequested(within: .seconds(1)), + "a task from the previous opening must not launch into the current one" + ) + #expect( + model.pendingRunAction == nil, + "a discarded task must not record a pending action either" + ) + #expect( + model.runFeatureIfActive?.isProjectReady( + for: workspace.root, + snapshotID: currentSnapshotID + ) == true, + "the current opening's inventory must survive the discarded task" + ) + } + /// When snapshot B is already published but its callback has not consumed it /// into the run service, generation must stop rather than scan the still-ready /// A inventory. @@ -671,6 +742,43 @@ private final class SequencedGatedWorkspaceOperations: WorkspaceOperations, @unc } } +/// Reports "the project folder could not be scanned yet" for the first scan and +/// a real snapshot afterwards. +/// +/// A reopen test needs the first opening to sit before any applied snapshot while +/// the second opening loads normally. Reporting the first scan as unavailable +/// reaches that state without suspending a scan, which would occupy a thread of +/// the pool the workspace feature scans on for the whole test. +private final class UnavailableThenReadyWorkspaceOperations: WorkspaceOperations, @unchecked Sendable { + private let lock = NSLock() + private let preparedSnapshot: WorkspaceSnapshot + private var scanCount = 0 + + init(snapshot: WorkspaceSnapshot) { + preparedSnapshot = snapshot + } + + func snapshot(at rootURL: URL, visibilityRules: FileVisibilityRules) -> WorkspaceSnapshot? { + lock.lock() + let ordinal = scanCount + scanCount += 1 + lock.unlock() + return ordinal == 0 ? nil : preparedSnapshot + } + + func readFile(at rootURL: URL, relativePath: String) -> String? { + try? String(contentsOf: rootURL.appendingPathComponent(relativePath), encoding: .utf8) + } + + func writeFile(_ text: String, at rootURL: URL, relativePath: String) -> Bool { + (try? text.write( + to: rootURL.appendingPathComponent(relativePath), + atomically: true, + encoding: .utf8 + )) != nil + } +} + /// Holds each workspace root's snapshot behind its own gate so a test can keep /// an old project's scan suspended while a new project opens. private final class MultiRootGatedWorkspaceOperations: WorkspaceOperations, @unchecked Sendable { @@ -846,6 +954,12 @@ private final class InspectionGatedRunConfigurationOperations: RunConfigurationO await launchPlanRequests[ordinal - 1].waitUntilOpen(timeout: .seconds(30)) } + /// Asserting that no launch happens needs a short deadline: the whole wait is + /// paid on the passing path, so it must not carry a load-sized one. + func launchPlanNotRequested(within duration: Duration) async -> Bool { + await !launchPlanRequests[0].waitUntilOpen(timeout: duration) + } + var resolveCallCount: Int { lock.lock() defer { lock.unlock() } From 9d661a0b2ebf50096601ec1dd35c9c2c41e0032b Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Mon, 31 Aug 2026 10:50:56 +0800 Subject: [PATCH 59/66] fix(macos): ignore stale watcher context after workspace reopen --- .../Application/WorkspaceFeatureModel.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift index c1d0a7a8..6aca435b 100644 --- a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift +++ b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift @@ -625,8 +625,12 @@ package final class WorkspaceFeatureModel: ObservableObject { private func updateWatchConfiguration(forceRebuild: Bool = false) async { guard let workspaceURL else { return } + // The same path can be closed and reopened while the Git context is + // being resolved. Keep the watcher tied to the opening that requested it. + let generation = workspaceGeneration let context = await gitWatchContextProvider.watchContext(for: workspaceURL) - guard self.workspaceURL == workspaceURL else { return } + guard self.workspaceURL == workspaceURL, + self.workspaceGeneration == generation else { return } let configuration = DirectoryWatchConfiguration( workspaceRoot: workspaceURL, gitContext: context From 5a66af1bf5ea83cb6a3ddd162343fcbc3d474cfa Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Mon, 31 Aug 2026 11:50:52 +0800 Subject: [PATCH 60/66] merge preview into java debug integration --- .github/workflows/ci-windows.yml | 1 + docs/architecture/language-tooling.md | 7 +- .../zh-Hans.lproj/Localizable.strings | 16 + .../NavigationHistoryFeatureModel.swift | 7 +- .../Features/SpringFeatureModel.swift | 8 + .../Lithe/Core/Rust/RustCoreBridge.swift | 81 +- .../Lithe/Core/Rust/RustGitOperations.swift | 23 + .../Core/Rust/RustJavaMavenOperations.swift | 50 +- .../RustLanguageServerRuntimeAdapter.swift | 37 + macos/Sources/Lithe/LitheApp.swift | 95 +- .../AppModel/AppModel+Development.swift | 374 ++++++- .../AppModel/AppModel+FeatureState.swift | 6 +- .../Models/AppModel/AppModel+FindInFile.swift | 123 +++ .../Models/AppModel/AppModel+GitModule.swift | 9 + .../Models/AppModel/AppModel+GoToLine.swift | 45 + .../AppModel/AppModel+HistoryModule.swift | 23 + .../Models/AppModel/AppModel+JavaIndex.swift | 7 + .../Models/AppModel/AppModel+Terminal.swift | 30 +- .../AppModel/AppModel+WorkbenchClose.swift | 61 ++ .../Lithe/Models/AppModel/AppModel.swift | 118 +-- .../AppModel/AppModelSupportTypes.swift | 8 + .../Models/Editor/EditorChromeModel.swift | 35 + .../Models/Editor/FindInFileMatcher.swift | 136 +++ .../Lithe/Models/Editor/GoToLineInput.swift | 92 ++ .../Models/Editor/GoToLineSelection.swift | 51 + .../Models/Java/JavaNavigationModels.swift | 3 + .../Models/Keymap/LitheCommandCatalog.swift | 2 + macos/Sources/Lithe/Models/LitheAction.swift | 2 + .../Workspace/ProjectSessionManager.swift | 55 +- .../Platform/MacOS/MacServiceContainer.swift | 3 +- .../MacMavenConfigurationStore.swift | 119 +++ .../MacRunConfigurationStore.swift | 22 +- .../MacOS/Runtime/MacRuntimeDiscovery.swift | 21 +- .../Services/Java/ProjectRuntimeService.swift | 21 +- macos/Sources/Lithe/Views/App/RootView.swift | 137 ++- .../Lithe/Views/Editor/CodeEditorView.swift | 223 ++++- .../Lithe/Views/Editor/EditorAreaView.swift | 5 +- .../Lithe/Views/Editor/FindBarView.swift | 148 ++- .../Lithe/Views/Editor/GoToLineDialog.swift | 223 +++++ .../Views/Editor/StandaloneEditorView.swift | 1 + .../Views/Git/BranchSwitcherPopover.swift | 499 +++++++-- .../Lithe/Views/Git/ChangesSidebarView.swift | 29 - .../Lithe/Views/Git/GitGraphView.swift | 17 +- .../Lithe/Views/Git/GitLogFilterPopover.swift | 946 ++++++++++++++++++ .../Sources/Lithe/Views/Git/GitLogView.swift | 559 ++++++++--- .../Run/JavaRunConfigurationEditorView.swift | 46 +- macos/Sources/Lithe/Views/Run/MavenView.swift | 349 ++++++- macos/Sources/Lithe/Views/Run/RunView.swift | 6 + .../Lithe/Views/Terminal/TerminalView.swift | 6 +- .../Workbench/WorkbenchStatusViews.swift | 15 +- .../Lithe/Views/Workbench/WorkbenchView.swift | 4 +- .../Workspace/ProjectSwitcherPopover.swift | 15 +- .../Execution/ExecutionContracts.swift | 15 +- .../Execution/MavenContracts.swift | 187 +++- .../Execution/RunConfigurationContracts.swift | 45 + .../Execution/RunModels.swift | 6 +- .../LanguageServerRuntimeContracts.swift | 54 + .../Language/LanguageToolingContracts.swift | 16 +- .../Workspace/WorkspaceModels.swift | 2 + .../GenericDebugFeatureModel.swift | 23 +- .../Application/ExecutionFeatureModels.swift | 67 +- .../Module/ExecutionFeatureGraph.swift | 10 +- .../Services/MavenService.swift | 551 ++++++++-- .../Services/RunService.swift | 101 +- .../Application/GitCommitFilesLoader.swift | 450 +++++++++ .../Application/GitFeatureModel.swift | 225 ++++- .../LitheGitModule/Models/GitModels.swift | 3 + .../LitheGitModule/Services/GitService.swift | 24 +- .../Runtime/LanguageServerSession.swift | 13 + .../LanguageToolingSessionManager.swift | 11 +- .../Application/WorkspaceFeatureModel.swift | 6 + .../DebugModuleTests.swift | 41 + .../ExecutionModuleTests.swift | 330 +++++- .../LitheGitModuleTests/GitModuleTests.swift | 766 +++++++++++++- .../LanguageIntelligenceModuleTests.swift | 50 +- .../LitheTests/EditorChromeModelTests.swift | 75 ++ .../LitheTests/FindInFileMatcherTests.swift | 165 +++ .../GitLogCommitSelectionTests.swift | 60 ++ .../LitheTests/GitLogFilterListTests.swift | 298 ++++++ .../GitPushDialogPresentationTests.swift | 37 + .../Tests/LitheTests/GoToLineInputTests.swift | 145 +++ .../LitheTests/GoToLineSelectionTests.swift | 144 +++ .../LitheTests/KeyboardShortcutTests.swift | 2 +- .../LitheTests/LitheCoreLogicTests.swift | 356 ++++++- .../Tests/LitheTests/MavenRuntimeTests.swift | 232 +++++ .../RunConfigurationIntegrationTests.swift | 73 +- .../TerminalPlacementFeatureModelTests.swift | 74 ++ .../lithe-core/src/execution/configuration.rs | 190 +++- rust/lithe-core/src/git/mod.rs | 258 ++++- rust/lithe-core/src/git/mutations.rs | 101 ++ rust/lithe-core/src/languages/spring.rs | 442 +++++++- rust/lithe-core/src/lsp/interface/engine.rs | 374 ++++++- rust/lithe-core/src/lsp/languages/jdt.rs | 170 +++- rust/lithe-core/src/project/maven.rs | 330 +++++- rust/lithe-core/src/protocol/command.rs | 3 + rust/lithe-core/src/protocol/contracts.rs | 20 + rust/lithe-core/src/runtime/dispatcher.rs | 18 + rust/lithe-core/src/tests/git.rs | 246 +++++ rust/lithe-core/src/tests/languages.rs | 95 ++ .../lithe-core/src/tests/run_configuration.rs | 114 ++- rust/lithe-core/src/tests/spring.rs | 180 ++++ scripts/verify-shared-contracts.sh | 28 + shared/contracts/application-boundary.md | 26 + .../maven-launch-context-v1.schema.json | 20 + ...aven-portable-configuration-v1.schema.json | 22 + .../run-configuration-v1.schema.json | 1 + shared/contracts/rust-core-api.md | 65 +- shared/fixtures/git/history-response-v1.json | 49 + shared/fixtures/git/write.json | 24 + shared/fixtures/maven/launch-plan-v1.json | 86 ++ .../fixtures/maven/platform-contract-v1.json | 30 + windows/tauri/src-tauri/src/platform.rs | 145 ++- .../components/file-explorer-tree.tsx | 32 +- .../hooks/use-file-explorer-context-menu.tsx | 35 +- .../lib/java-clipboard-class.test.ts | 82 ++ .../file-explorer/lib/java-clipboard-class.ts | 118 +++ .../lib/paste-into-explorer-directory.ts | 50 + .../paste-java-class-from-clipboard.test.ts | 43 + .../lib/paste-java-class-from-clipboard.ts | 101 ++ .../src/features/git/api/git-branches-api.ts | 27 +- .../src/features/git/api/git-diff-api.ts | 20 + .../git/api/git-integration-api.test.ts | 40 +- .../features/git/api/git-integration-api.ts | 101 +- .../src/features/git/components/git-view.tsx | 2 +- .../components/log/git-log-tool-window.tsx | 121 ++- .../components/log/git-reference-tree.test.ts | 50 + .../git/components/log/git-reference-tree.tsx | 107 +- .../git/hooks/use-git-diff-actions.ts | 45 +- .../git/hooks/use-git-log-controller.ts | 7 +- .../src/features/git/stores/git.store.test.ts | 21 +- .../tauri/src/features/git/types/git.types.ts | 1 + windows/tauri/src/i18n/locale.ts | 42 + .../core-result-adapter.history.test.ts | 19 + .../tauri/src/platform/core-result-adapter.ts | 9 + 134 files changed, 12795 insertions(+), 891 deletions(-) create mode 100644 macos/Sources/Lithe/Models/AppModel/AppModel+FindInFile.swift create mode 100644 macos/Sources/Lithe/Models/AppModel/AppModel+GoToLine.swift create mode 100644 macos/Sources/Lithe/Models/AppModel/AppModel+WorkbenchClose.swift create mode 100644 macos/Sources/Lithe/Models/Editor/FindInFileMatcher.swift create mode 100644 macos/Sources/Lithe/Models/Editor/GoToLineInput.swift create mode 100644 macos/Sources/Lithe/Models/Editor/GoToLineSelection.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/Persistence/MacMavenConfigurationStore.swift create mode 100644 macos/Sources/Lithe/Views/Editor/GoToLineDialog.swift create mode 100644 macos/Sources/Lithe/Views/Git/GitLogFilterPopover.swift create mode 100644 macos/Sources/LitheGitModule/Application/GitCommitFilesLoader.swift create mode 100644 macos/Tests/LitheTests/FindInFileMatcherTests.swift create mode 100644 macos/Tests/LitheTests/GitLogCommitSelectionTests.swift create mode 100644 macos/Tests/LitheTests/GitLogFilterListTests.swift create mode 100644 macos/Tests/LitheTests/GitPushDialogPresentationTests.swift create mode 100644 macos/Tests/LitheTests/GoToLineInputTests.swift create mode 100644 macos/Tests/LitheTests/GoToLineSelectionTests.swift create mode 100644 rust/lithe-core/src/git/mutations.rs create mode 100644 shared/contracts/maven-launch-context-v1.schema.json create mode 100644 shared/contracts/maven-portable-configuration-v1.schema.json create mode 100644 shared/fixtures/git/history-response-v1.json create mode 100644 shared/fixtures/maven/launch-plan-v1.json create mode 100644 shared/fixtures/maven/platform-contract-v1.json create mode 100644 windows/tauri/src/features/file-explorer/lib/java-clipboard-class.test.ts create mode 100644 windows/tauri/src/features/file-explorer/lib/java-clipboard-class.ts create mode 100644 windows/tauri/src/features/file-explorer/lib/paste-into-explorer-directory.ts create mode 100644 windows/tauri/src/features/file-explorer/lib/paste-java-class-from-clipboard.test.ts create mode 100644 windows/tauri/src/features/file-explorer/lib/paste-java-class-from-clipboard.ts create mode 100644 windows/tauri/src/features/git/components/log/git-reference-tree.test.ts diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index 591321cc..b21c52d9 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -237,6 +237,7 @@ jobs: bun test src/features/editor/lsp/language-server-navigation.test.ts src/features/editor/lsp/java-navigation-marker-loader.test.ts src/features/editor/engines/monaco/definition-link-scheduler.test.ts src/features/editor/engines/monaco/java-implementation-markers.test.ts bun test src/features/editor/lsp/java-workspace-language-server.test.ts src/features/editor/lsp/java-workspace-change-scheduler.test.ts bun test src/features/run + bun test src/features/file-explorer/lib/java-clipboard-class.test.ts src/features/file-explorer/lib/paste-java-class-from-clipboard.test.ts - name: Test shared Rust Core if: needs.changes.outputs.rust_core == 'true' diff --git a/docs/architecture/language-tooling.md b/docs/architecture/language-tooling.md index 05eee24a..bdb47604 100644 --- a/docs/architecture/language-tooling.md +++ b/docs/architecture/language-tooling.md @@ -157,9 +157,10 @@ timeout。Core、service 和 adapter 只返回稳定原因,面向用户的提 1. 平台完成可执行文件、运行时和 provider 专用资源发现,向 Rust 提交 typed `startServer`;JDTLS 包内计划必须包含 `jdtlsLaunchResources`; 2. Rust provider adapter 生成最终参数,engine 创建 session、直接启动目标进程并安装 stdout/stderr reader,再发送 `initialize`; -3. 收到响应后,Rust 保存服务器 capability,发送 `initialized` 和 provider adapter 通知;JDTLS 继续等待 `language/status: ServiceReady`,项目导入完成前仍是 `initializing`; -4. manager 只在真实 `ready` 后发布 capability;打开文档发送 `didOpen`,后续编辑优先按服务器协商结果发送增量 `didChange`; -5. Rust 以 LSP request ID 关联 deadline,并用不透明 operation ID 把 terminal result 投影给 Swift。 +3. 收到响应后,Rust 保存服务器 capability,发送 `initialized` 和 provider adapter 通知;JDTLS 的配置通知和 `workspace/configuration` 响应通过 `java.configuration.maven.userSettings` 消费当前 Maven `settings.xml`,随后继续等待 `language/status: ServiceReady`; +4. JDTLS 报告 `ServiceReady` 后,Rust 对 reactor 和每个递归 Maven module 发送 `java.project.updateSettings`,以 `org.eclipse.m2e.core.selectedProfiles` 应用排序后的 Profiles;所有响应成功前仍保持 `initializing`,拒绝或超时会明确失败而不沿用旧 Maven model; +5. manager 只在真实 `ready` 后发布 capability;打开文档发送 `didOpen`,后续编辑优先按服务器协商结果发送增量 `didChange`; +6. Rust 以 LSP request ID 关联 deadline,并用不透明 operation ID 把 terminal result 投影给 Swift。 `initializeTimeoutMilliseconds` 只约束标准 LSP 握手。JDTLS 返回 initialize 结果后,Core 立即切换到独立的 `ServiceReady` 等待:连续 45 秒没有变化的 diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 0bc7db47..387166bf 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -249,6 +249,10 @@ "Navigate" = "导航"; "Search Everywhere…" = "全局搜索…"; "Find in File…" = "在文件中查找…"; +"Replace in File…" = "在文件中替换…"; +"Go to Line…" = "跳转到行…"; +"Go to Line:Column" = "跳转到行:列"; +"[Line] [:column]:" = "[行] [:列]:"; "Find Next" = "查找下一个"; "Find Previous" = "查找上一个"; "Go to Usage" = "跳转到调用位置"; @@ -762,6 +766,11 @@ "Search for branches and actions" = "搜索分支和操作"; "Loading branches…" = "正在加载分支…"; "No matching branches" = "没有匹配的分支"; +"All Branches" = "全部分支"; +"All Users" = "所有用户"; +"Me" = "我"; +"Search users" = "搜索用户"; +"No matching users" = "没有匹配的用户"; "Create" = "创建"; "Create from '%@'." = "从“%@”创建。"; "Checkout branch after creation" = "创建后检出分支"; @@ -966,6 +975,10 @@ "Search text across the workspace" = "搜索整个工作区的文本"; "Find in File" = "在文件中查找"; "Search within the active editor" = "在当前编辑器中搜索"; +"Replace in File" = "在文件中替换"; +"Replace within the active editor" = "在当前编辑器中替换"; +"Go to Line" = "跳转到行"; +"Jump to a line and column in the active editor" = "在当前编辑器中跳转到指定的行和列"; "Navigate to a call site of the selected Java symbol" = "导航到所选 Java 符号的调用位置"; "Find references to the selected Java symbol" = "查找所选 Java 符号的引用"; "Open history for the active file" = "打开当前文件的历史记录"; @@ -1278,6 +1291,9 @@ "Notifications" = "通知"; "Clear All" = "全部清除"; "No notifications" = "暂无通知"; +"Close Running Terminal?" = "关闭正在运行的终端?"; +"Close Terminal" = "关闭终端"; +"Closing this terminal will stop its shell and any running command." = "关闭此终端将停止其 Shell 和所有正在运行的命令。"; "Log: %@" = "日志:%@"; "Console" = "控制台"; "Debugger" = "调试器"; diff --git a/macos/Sources/Lithe/Application/Features/NavigationHistoryFeatureModel.swift b/macos/Sources/Lithe/Application/Features/NavigationHistoryFeatureModel.swift index 1196dc25..1007e28f 100644 --- a/macos/Sources/Lithe/Application/Features/NavigationHistoryFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/NavigationHistoryFeatureModel.swift @@ -8,6 +8,9 @@ struct EditorNavigationLocation: Hashable, Sendable { let isReadOnly: Bool let displayPath: String? let virtualProviderID: String? + /// Consume the location with the whole target line selected (Go to Line); + /// symbol and find navigation keep a zero-length caret. + let selectsWholeLine: Bool init( url: URL, @@ -15,7 +18,8 @@ struct EditorNavigationLocation: Hashable, Sendable { utf16Column: Int, isReadOnly: Bool = false, displayPath: String? = nil, - virtualProviderID: String? = nil + virtualProviderID: String? = nil, + selectsWholeLine: Bool = false ) { self.url = url.isFileURL ? url.standardizedFileURL : url self.line = max(0, line) @@ -23,6 +27,7 @@ struct EditorNavigationLocation: Hashable, Sendable { self.isReadOnly = isReadOnly self.displayPath = displayPath self.virtualProviderID = virtualProviderID + self.selectsWholeLine = selectsWholeLine } } diff --git a/macos/Sources/Lithe/Application/Features/SpringFeatureModel.swift b/macos/Sources/Lithe/Application/Features/SpringFeatureModel.swift index 2bd9a0d6..b5bd6147 100644 --- a/macos/Sources/Lithe/Application/Features/SpringFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/SpringFeatureModel.swift @@ -92,6 +92,14 @@ final class SpringFeatureModel: ObservableObject { } } + func scheduleLoad(workspaceURL: URL, files: [URL], textOverrides: [URL: String]) { + reloadTask?.cancel() + reloadTask = Task { @MainActor [weak self] in + guard let self else { return } + await self.load(workspaceURL: workspaceURL, files: files, textOverrides: textOverrides) + } + } + func handles(_ url: URL) -> Bool { let name = url.lastPathComponent.lowercased() return name == "application.properties" diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index bcc79109..c544c1f7 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -296,6 +296,28 @@ struct RustCoreBridge: Sendable { let issues: [Issue] } + struct MavenLaunchPlanPayload: Decodable, Sendable { + struct Executable: Decodable, Sendable { + let toolchain: String + } + + let version: Int + let executable: Executable + let arguments: [String] + let workingDirectory: String + let configurationFingerprint: String + + func makeModel() -> MavenLaunchPlan { + MavenLaunchPlan( + version: version, + toolchain: executable.toolchain, + arguments: arguments, + workingDirectory: workingDirectory, + configurationFingerprint: configurationFingerprint + ) + } + } + struct JavaRunConfigurationsPayload: Decodable, Sendable { struct MainClass: Decodable, Sendable { let path: String @@ -410,6 +432,7 @@ struct RustCoreBridge: Sendable { let jvmArguments: [String]? let programArguments: [String]? let profiles: [String]? + let skipTests: Bool? } struct Java: Codable, Sendable { let homePath: String? @@ -892,6 +915,7 @@ struct RustCoreBridge: Sendable { } let references: [Reference] + let recentReferences: [Reference]? let commits: [Commit] let hasMore: Bool let userName: String? @@ -909,6 +933,16 @@ struct RustCoreBridge: Sendable { upstreamShortName: reference.upstreamShortName ) }, + recentReferences: (recentReferences ?? []).compactMap { reference in + guard let kind = GitReferenceKind(rawValue: reference.kind) else { return nil } + return GitReference( + fullName: reference.fullName, + shortName: reference.shortName, + kind: kind, + isCurrent: reference.isCurrent, + upstreamShortName: reference.upstreamShortName + ) + }, commits: commits.map { commit in GitCommit( hash: commit.hash, @@ -1178,6 +1212,13 @@ struct RustCoreBridge: Sendable { let paths: [String] } + private struct MavenLaunchPlanRequest: Encodable { + let root: String + let context: MavenLaunchContext + let module: String? + let goals: [String] + } + private struct MarkdownRenderRequest: Encodable { let source: String } @@ -1416,6 +1457,7 @@ struct RustCoreBridge: Sendable { let jdtlsLaunchResources: LspJdtlsLaunchResourcesRequest? let cacheDirectory: String? let workspaceFingerprint: String? + let mavenContext: MavenLaunchContext? let initializeTimeoutMilliseconds: Int let requestTimeoutMilliseconds: Int let shutdownTimeoutMilliseconds: Int @@ -1601,6 +1643,7 @@ struct RustCoreBridge: Sendable { let arguments: String let environment: [String: String] let mavenProfiles: [String] + let mavenSkipTests: Bool? let javaHomePath: String let mavenExecutablePath: String let mavenJavaHomePath: String @@ -1620,6 +1663,7 @@ struct RustCoreBridge: Sendable { let currentFile: String? let classPath: String? let debugPort: Int? + let mavenContext: MavenLaunchContext? } private struct JavaStructureRequest: Encodable { @@ -2252,13 +2296,38 @@ struct RustCoreBridge: Sendable { } func scanMaven(at rootURL: URL, paths: [String] = []) -> MavenScanPayload? { - execute( + try? scanMavenResult(at: rootURL, paths: paths).get() + } + + func scanMavenResult( + at rootURL: URL, + paths: [String] = [] + ) -> Result { + let result: Result, CoreCallError> = decodeEnvelope( command: "maven.scan", payload: MavenScanRequest( root: rootURL.standardizedFileURL.path, paths: paths ) ) + return result.map(\.data) + } + + func mavenLaunchPlan( + at rootURL: URL, + context: MavenLaunchContext, + module: String?, + goals: [String] + ) -> Result { + executeResult( + command: "maven.launchPlan", + payload: MavenLaunchPlanRequest( + root: rootURL.standardizedFileURL.path, + context: context, + module: module, + goals: goals + ) + ) } func mavenDiagnostics(at rootURL: URL, output: String) -> MavenDiagnosticsPayload? { @@ -2326,7 +2395,8 @@ struct RustCoreBridge: Sendable { configurationID: String, currentFile: String? = nil, classPath: String? = nil, - debugPort: Int? = nil + debugPort: Int? = nil, + mavenContext: MavenLaunchContext? = nil ) -> Result { executeResult( command: "runConfig.createLaunchPlan", @@ -2335,7 +2405,8 @@ struct RustCoreBridge: Sendable { configurationId: configurationID, currentFile: currentFile, classPath: classPath, - debugPort: debugPort + debugPort: debugPort, + mavenContext: mavenContext ) ) } @@ -2357,6 +2428,7 @@ struct RustCoreBridge: Sendable { arguments: options.arguments, environment: options.environment, mavenProfiles: options.activeProfiles.sorted(), + mavenSkipTests: options.mavenSkipTests, javaHomePath: options.javaHomePath, mavenExecutablePath: options.mavenExecutablePath, mavenJavaHomePath: options.mavenJavaHomePath, @@ -2383,6 +2455,7 @@ struct RustCoreBridge: Sendable { arguments: options.arguments, environment: options.environment, mavenProfiles: options.activeProfiles.sorted(), + mavenSkipTests: options.mavenSkipTests, javaHomePath: options.javaHomePath, mavenExecutablePath: options.mavenExecutablePath, mavenJavaHomePath: options.mavenJavaHomePath, @@ -2954,6 +3027,7 @@ struct RustCoreBridge: Sendable { jdtlsLaunchResources: JDTLSLaunchResources? = nil, cacheDirectoryURL: URL? = nil, workspaceFingerprint: String? = nil, + mavenContext: MavenLaunchContext? = nil, initializeTimeout: TimeInterval = 30, requestTimeout: TimeInterval = 30, shutdownTimeout: TimeInterval = 2 @@ -2980,6 +3054,7 @@ struct RustCoreBridge: Sendable { }, cacheDirectory: cacheDirectoryURL?.standardizedFileURL.path, workspaceFingerprint: workspaceFingerprint, + mavenContext: mavenContext, initializeTimeoutMilliseconds: Self.milliseconds(initializeTimeout), requestTimeoutMilliseconds: Self.milliseconds(requestTimeout), shutdownTimeoutMilliseconds: Self.milliseconds(shutdownTimeout) diff --git a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift index 1fa72270..cbc5f34f 100644 --- a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift @@ -156,10 +156,33 @@ struct RustGitOperations: GitOperations, Sendable { write(at: rootURL, operation: "rebase", reference: reference.fullName) } + func checkoutAndRebase(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { + write( + at: rootURL, + operation: "checkoutAndRebase", + reference: reference.fullName, + referenceKind: reference.kind + ) + } + func updateCurrentBranch(at rootURL: URL, strategy: GitPullStrategy = .ffOnly) -> GitProcessResult? { write(at: rootURL, operation: "pull", mode: strategy.rawValue) } + func pullRemoteReference( + _ reference: GitReference, + strategy: GitPullStrategy, + at rootURL: URL + ) -> GitProcessResult? { + write( + at: rootURL, + operation: "pull", + reference: reference.fullName, + referenceKind: reference.kind, + mode: strategy.rawValue + ) + } + /// Staged files still containing conflict markers. func conflictMarkerPaths(at rootURL: URL) -> [String] { core.gitConflictMarkerPaths(at: rootURL)?.paths ?? [] diff --git a/macos/Sources/Lithe/Core/Rust/RustJavaMavenOperations.swift b/macos/Sources/Lithe/Core/Rust/RustJavaMavenOperations.swift index 6f59fabc..482fcb19 100644 --- a/macos/Sources/Lithe/Core/Rust/RustJavaMavenOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustJavaMavenOperations.swift @@ -6,7 +6,13 @@ protocol JavaMavenOperations: MavenProjectOperations, RunServerPortParsing, Send files: [URL], changedFiles: [URL] ) -> JavaWorkspacePolicyResult? - func scanMavenProject(at rootURL: URL, files: [URL]) -> MavenProject? + func scanMavenProject(at rootURL: URL, files: [URL]) throws -> MavenProject? + func mavenLaunchPlan( + at rootURL: URL, + context: MavenLaunchContext, + module: String?, + goals: [String] + ) throws -> MavenLaunchPlan func mavenDiagnostics(output: String, projectRoot: URL) -> [MavenBuildIssue] func codeVision( at rootURL: URL, @@ -38,6 +44,18 @@ protocol JavaMavenOperations: MavenProjectOperations, RunServerPortParsing, Send } extension JavaMavenOperations { + func mavenLaunchPlan( + at _: URL, + context _: MavenLaunchContext, + module _: String?, + goals _: [String] + ) throws -> MavenLaunchPlan { + throw MavenOperationError( + code: "not_supported", + message: "Maven launch planning is unavailable." + ) + } + func javaWorkspacePolicy( at _: URL, files _: [URL], @@ -132,7 +150,7 @@ struct RustJavaMavenOperations: JavaMavenOperations, Sendable { ) } - func scanMavenProject(at rootURL: URL, files: [URL]) -> MavenProject? { + func scanMavenProject(at rootURL: URL, files: [URL]) throws -> MavenProject? { let root = rootURL.standardizedFileURL let rootComponents = root.pathComponents let paths = files.compactMap { fileURL -> String? in @@ -143,7 +161,27 @@ struct RustJavaMavenOperations: JavaMavenOperations, Sendable { .dropFirst(rootComponents.count) .joined(separator: "/") } - return core.scanMaven(at: root, paths: paths)?.makeProject(workspaceRootURL: root) + return try core.scanMavenResult(at: root, paths: paths) + .mapError(MavenOperationError.init) + .get()? + .makeProject(workspaceRootURL: root) + } + + func mavenLaunchPlan( + at rootURL: URL, + context: MavenLaunchContext, + module: String?, + goals: [String] + ) throws -> MavenLaunchPlan { + try core.mavenLaunchPlan( + at: rootURL, + context: context, + module: module, + goals: goals + ) + .mapError(MavenOperationError.init) + .get() + .makeModel() } func mavenDiagnostics(output: String, projectRoot: URL) -> [MavenBuildIssue] { @@ -344,3 +382,9 @@ struct RustJavaMavenOperations: JavaMavenOperations, Sendable { ) } } + +private extension MavenOperationError { + init(_ error: RustCoreBridge.CoreCallError) { + self.init(code: error.code, message: error.message, details: error.details) + } +} diff --git a/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift b/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift index 935f236b..ad94c084 100644 --- a/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift +++ b/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift @@ -18,6 +18,42 @@ extension RustCoreBridge: LanguageServerRuntimeCore { initializeTimeout: TimeInterval, requestTimeout: TimeInterval, shutdownTimeout: TimeInterval + ) -> Result { + startLanguageServer( + providerID: providerID, + executableURL: executableURL, + arguments: arguments, + environment: environment, + rootURL: rootURL, + workingDirectoryURL: workingDirectoryURL, + initializationOptions: initializationOptions, + runtimeExecutableURL: runtimeExecutableURL, + jdtlsLaunchResources: jdtlsLaunchResources, + cacheDirectoryURL: cacheDirectoryURL, + workspaceFingerprint: workspaceFingerprint, + mavenContext: nil, + initializeTimeout: initializeTimeout, + requestTimeout: requestTimeout, + shutdownTimeout: shutdownTimeout + ) + } + + func startLanguageServer( + providerID: String, + executableURL: URL, + arguments: [String], + environment: [String: String], + rootURL: URL, + workingDirectoryURL: URL, + initializationOptions: ToolingJSONValue?, + runtimeExecutableURL: URL?, + jdtlsLaunchResources: JDTLSLaunchResources?, + cacheDirectoryURL: URL?, + workspaceFingerprint: String?, + mavenContext: MavenLaunchContext?, + initializeTimeout: TimeInterval, + requestTimeout: TimeInterval, + shutdownTimeout: TimeInterval ) -> Result { lspStartServer( providerID: providerID, @@ -31,6 +67,7 @@ extension RustCoreBridge: LanguageServerRuntimeCore { jdtlsLaunchResources: jdtlsLaunchResources, cacheDirectoryURL: cacheDirectoryURL, workspaceFingerprint: workspaceFingerprint, + mavenContext: mavenContext, initializeTimeout: initializeTimeout, requestTimeout: requestTimeout, shutdownTimeout: shutdownTimeout diff --git a/macos/Sources/Lithe/LitheApp.swift b/macos/Sources/Lithe/LitheApp.swift index fbf7b191..95e22a1d 100644 --- a/macos/Sources/Lithe/LitheApp.swift +++ b/macos/Sources/Lithe/LitheApp.swift @@ -3,9 +3,40 @@ import SwiftUI private let litheProcessLaunchDate = Date() +@MainActor +protocol UnsavedDocumentHandling: AnyObject { + var hasUnsavedDocuments: Bool { get } + var unsavedDocumentNames: [String] { get } + + @discardableResult + func saveAllDocuments() -> Bool +} + +enum UnsavedDocumentsConfirmationContext { + case applicationTermination + case projectWindowClose + + var messageText: String { + switch self { + case .applicationTermination: + "Save changes before quitting?" + case .projectWindowClose: + "Save changes before closing this window?" + } + } +} + @MainActor final class LitheAppDelegate: NSObject, NSApplicationDelegate { + private enum TerminationCleanupState { + case idle + case cleaning + case approved + } + private var pendingFileURLs: [URL] = [] + private var terminationCleanupTask: Task? + private var terminationCleanupState: TerminationCleanupState = .idle weak var projectSessions: ProjectSessionManager? { didSet { guard let projectSessions else { return } @@ -34,7 +65,38 @@ final class LitheAppDelegate: NSObject, NSApplicationDelegate { func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { guard let projectSessions else { return .terminateNow } - return Self.confirmUnsavedDocuments(for: projectSessions) ? .terminateNow : .terminateCancel + + // AppKit may ask more than once while a previous asynchronous reply is + // pending. Do not show another confirmation dialog or start a second + // module shutdown graph in that interval. Once the cleanup reply has + // been approved, allow AppKit's follow-up request to finish normally. + switch terminationCleanupState { + case .cleaning: + return .terminateLater + case .approved: + return .terminateNow + case .idle: + break + } + return Self.confirmUnsavedDocuments( + for: projectSessions, + context: .applicationTermination + ) ? beginTerminationCleanup(for: projectSessions, sender: sender) : .terminateCancel + } + + private func beginTerminationCleanup( + for projectSessions: ProjectSessionManager, + sender: NSApplication + ) -> NSApplication.TerminateReply { + terminationCleanupState = .cleaning + terminationCleanupTask = Task { @MainActor [weak self, projectSessions, sender] in + await projectSessions.stopAllSessions() + guard let self, self.terminationCleanupState == .cleaning else { return } + self.terminationCleanupTask = nil + self.terminationCleanupState = .approved + sender.reply(toApplicationShouldTerminate: true) + } + return .terminateLater } func applicationWillTerminate(_ notification: Notification) { @@ -42,7 +104,6 @@ final class LitheAppDelegate: NSObject, NSApplicationDelegate { forEventClass: AEEventClass(kCoreEventClass), andEventID: AEEventID(kAEOpenDocuments) ) - projectSessions?.stopAllSessions() recordCleanPluginShutdown?() } @@ -103,20 +164,23 @@ final class LitheAppDelegate: NSObject, NSApplicationDelegate { } } - static func confirmUnsavedDocuments(for projectSessions: ProjectSessionManager) -> Bool { - guard projectSessions.hasUnsavedDocuments else { return true } + static func confirmUnsavedDocuments( + for documentOwner: any UnsavedDocumentHandling, + context: UnsavedDocumentsConfirmationContext + ) -> Bool { + guard documentOwner.hasUnsavedDocuments else { return true } let alert = NSAlert() alert.alertStyle = .warning - alert.messageText = "Save changes before quitting?" - alert.informativeText = projectSessions.unsavedDocumentNames.joined(separator: ", ") + alert.messageText = context.messageText + alert.informativeText = documentOwner.unsavedDocumentNames.joined(separator: ", ") alert.addButton(withTitle: "Save All") alert.addButton(withTitle: "Don't Save") alert.addButton(withTitle: "Cancel") switch alert.runModal() { case .alertFirstButtonReturn: - return projectSessions.saveAllDocuments() + return documentOwner.saveAllDocuments() case .alertSecondButtonReturn: return true default: @@ -335,6 +399,12 @@ struct LitheApp: App { .litheKeyboardShortcut(model.keyboardShortcutFeature.primaryKeyPress(for: "find-in-file")) .disabled(model.activeDocument == nil) + Button("Replace in File…") { + model.showReplaceBar() + } + .litheKeyboardShortcut(model.keyboardShortcutFeature.primaryKeyPress(for: "replace-in-file")) + .disabled(model.activeDocument == nil) + Button("Find Next") { model.navigateFind(offset: 1) } @@ -346,6 +416,12 @@ struct LitheApp: App { } .litheKeyboardShortcut(model.keyboardShortcutFeature.primaryKeyPress(for: "find-previous")) .disabled(!model.isFindBarVisible || model.findMatchCount == 0) + + Button("Go to Line…") { + model.showGoToLine() + } + .litheKeyboardShortcut(model.keyboardShortcutFeature.primaryKeyPress(for: "go-to-line")) + .disabled(model.activeDocument == nil) } Divider() @@ -604,7 +680,10 @@ private func settingsWindowTitle(for language: AppLanguage) -> String { ) } -private extension AppThemePreference { +extension AppThemePreference { + /// NSAppearance applied to app windows for the selected theme; `nil` + /// means follow the system appearance. Shared by every presenting + /// window, including the Go to Line dialog. var windowAppearance: NSAppearance? { switch self { case .system: nil diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift index 1d7b2a9e..36d1277d 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift @@ -4,6 +4,47 @@ import LitheDebugModule import LitheExecutionModule import LitheModuleAPI +/// One opening of one workspace. +/// +/// The path alone repeats when the same project is closed and reopened, so a +/// task that started before the reopen would still compare equal to the current +/// workspace. Pairing the path with the opening's generation is what lets such a +/// task be recognized as belonging to a session that is over. +struct WorkspaceIdentity: Equatable { + let url: URL + let generation: Int +} + +/// An action deferred until the run feature holds the current workspace snapshot. +/// +/// The opening it was deferred for is part of the value so a snapshot applied for +/// a different workspace — or for a later opening of the same one — cannot resume +/// it. Direct launch entry points also keep the concrete configuration (or the +/// all-services intent) so resume can re-issue the same action the UI asked for. +struct PendingRunAction: Equatable { + enum Kind: Equatable { + case run + case debug + case startConfiguration(RunConfiguration) + case runAllServices + case restart + } + + let kind: Kind + let identity: WorkspaceIdentity +} + +/// Result of bringing the run feature up to a specific opening's snapshot. +/// +/// Distinguishing "still waiting on this opening" from "this entry task is +/// stale" is what stops an await that outlived a project switch or a reopen from +/// re-recording the old action against the current pending slot. +private enum RunProjectReadiness: Equatable { + case ready + case waitingForSnapshot(identity: WorkspaceIdentity) + case stale +} + @MainActor final class JavaTestWorkflowState { var resultServer: (any JavaTestResultServing)? @@ -43,7 +84,7 @@ extension AppModel { guard let self else { return } guard await activateExecutionModule() != nil else { return } if let workspaceURL { - await loadProjectServices(at: workspaceURL, files: projectFiles) + await loadProjectServicesForAppliedSnapshot(at: workspaceURL) } } isTestsVisible = false @@ -66,7 +107,7 @@ extension AppModel { Task { [weak self] in guard let self, await activateExecutionModule() != nil, let workspaceURL else { return } - await loadProjectServices(at: workspaceURL, files: projectFiles) + await loadProjectServicesForAppliedSnapshot(at: workspaceURL) } isTestsVisible = false isGitLogVisible = false @@ -80,15 +121,14 @@ extension AppModel { guard let self else { return } let capability = await self.activateExecutionModule() if capability?.mavenFeature.project == nil { - await self.loadProjectServices(at: workspaceURL, files: self.projectFiles) + await self.loadProjectServicesForAppliedSnapshot(at: workspaceURL) } } } func runMaven( phase: MavenLifecyclePhase, - module: MavenModule?, - profiles: Set + module: MavenModule? ) { isMavenVisible = true isGitLogVisible = false @@ -99,7 +139,15 @@ extension AppModel { isDebugVisible = false Task { [weak self] in guard let feature = await self?.activateExecutionModule()?.mavenFeature else { return } - feature.run(phase: phase, module: module, profiles: profiles) + feature.run(phase: phase, module: module) + } + } + + func runMavenGoal(_ goal: String, module: MavenModule?) { + isMavenVisible = true + Task { [weak self] in + guard let feature = await self?.activateExecutionModule()?.mavenFeature else { return } + feature.runCustomGoal(goal, module: module) } } @@ -174,6 +222,30 @@ extension AppModel { runFeatureIfActive?.select(configuration) } + /// The single entry point for identification. + /// + /// Routing it through here is what keeps the run service from scanning a + /// superseded snapshot: the service can only compare its own state, so the + /// caller has to bring it up to the current snapshot first. When that fails, + /// generation must stop — the service may still hold an older `.ready` + /// inventory, and scanning it would overwrite `generated.json` with stale + /// entry points. + func generateRunConfigurations() async { + guard let identity = currentWorkspaceIdentity else { return } + guard let runFeature = await activateExecutionModule()?.runFeature else { return } + guard isCurrentWorkspace(identity) else { return } + switch await ensureRunProjectReady(runFeature, for: identity) { + case .ready: + await runFeature.generateRunConfigurations() + case .waitingForSnapshot: + // Report the pending workspace through the generation state when the + // snapshot has not arrived, which the run panel surfaces as a notice. + runFeature.reportGenerationProjectNotReady() + case .stale: + return + } + } + func openRunConfiguration(relativePath: String?) { guard let workspaceURL else { return } let url = workspaceURL.appendingPathComponent(relativePath ?? ".lithe/run/generated.json") @@ -185,8 +257,195 @@ extension AppModel { Task { [weak self] in await self?.runSelectedConfigurationAfterActivation() } } + /// Loads project services for the scan currently applied to the workspace. + /// + /// Callers that only want "whatever the workspace has now" use this so the + /// file list and its identity are captured in a single read. + func loadProjectServicesForAppliedSnapshot(at workspaceURL: URL) async { + let applied = workspaceFeature.appliedSnapshot + await loadProjectServices( + at: workspaceURL, + files: applied?.files ?? [], + snapshotID: applied?.id + ) + } + + /// Loads build-system and run state at the workspace boundary. The generic + /// run lifecycle is intentionally not owned by JavaFeatureModel. + /// + /// Spring indexing is scheduled rather than awaited. It scales with the + /// number of Java sources, and run configurations, test discovery, and the + /// Git refresh that follows this call must not wait for it. + /// + /// `files` and `snapshotID` must describe the same scan; the caller captures + /// them together. `resumesDeferredRunAction` is set only by the workspace + /// snapshot callback, because a deferred Run waits for a snapshot and + /// resuming from any other load would either re-enter through + /// `ensureRunProjectReady` or fire the action from an unrelated reload. + func loadProjectServices( + at workspaceURL: URL, + files: [URL], + snapshotID: UUID?, + resumesDeferredRunAction: Bool = false + ) async { + let target = workspaceURL.standardizedFileURL + // The caller established that this load belongs to the current opening, + // so the identity is captured here and re-checked after every await. + guard let identity = currentWorkspaceIdentity, identity.url == target else { return } + prepareJavaLanguageServerForWorkspaceIfNeeded( + at: target, + files: files + ) + springFeature.scheduleLoad( + workspaceURL: target, + files: files, + textOverrides: Dictionary(uniqueKeysWithValues: openDocuments.map { + ($0.url.standardizedFileURL, $0.text) + }) + ) + guard let execution = await activateExecutionModule() else { return } + // Module activation suspends; a project switch or a reopen must not let + // this load write the captured inventory into the new opening's run + // service. + guard isCurrentWorkspace(identity) else { return } + execution.tests.discover(workspaceURL: target, files: files) + // `files` and `snapshotID` are captured together by the caller. Reading + // the applied snapshot here instead would pair this file list with a + // newer scan's identity, which the readiness comparison cannot detect. + await execution.projectDevelopment.loadProject( + at: target, + files: files, + snapshotID: snapshotID + ) + guard isCurrentWorkspace(identity) else { return } + guard resumesDeferredRunAction else { return } + resumeDeferredRunAction( + execution.runFeature, + identity: identity, + snapshotID: snapshotID + ) + } + + /// The opening an entry task captures before its first await. + var currentWorkspaceIdentity: WorkspaceIdentity? { + guard let url = workspaceURL?.standardizedFileURL else { return nil } + return WorkspaceIdentity(url: url, generation: workspaceFeature.workspaceGeneration) + } + + private func isCurrentWorkspace(_ identity: WorkspaceIdentity) -> Bool { + currentWorkspaceIdentity == identity + } + + /// Brings the run feature up to the snapshot for a captured opening. + /// + /// Entry tasks capture the opening before any await so a project switch — or + /// a close and reopen of the same path — can be reported as `.stale` instead + /// of being re-deferred against whatever is current when the load finishes. + /// + /// When a newer snapshot is published but the run service still holds an + /// older `.ready` inventory for this workspace, the snapshot callback owns + /// the transition. Loading from the entry path would race that callback and + /// let Restart proceed from a half-applied refresh. + /// + /// When the run service is not already ready for this workspace, the entry + /// path applies the published scan itself (open-before-run, prune, tool + /// window) so readiness does not wait on a callback that may never arrive. + private func ensureRunProjectReady( + _ runFeature: RunFeatureModel, + for identity: WorkspaceIdentity + ) async -> RunProjectReadiness { + guard isCurrentWorkspace(identity) else { return .stale } + let target = identity.url + // One read, so the file list and the identity describe the same scan. + let applied = workspaceFeature.appliedSnapshot + if runFeature.isProjectReady(for: target, snapshotID: applied?.id) { return .ready } + if applied != nil, runFeature.hasReadyInventory(for: target) { + return .waitingForSnapshot(identity: identity) + } + // No matching ready inventory: bind provisionally, or apply the + // published scan when one already exists. + await loadProjectServices( + at: target, + files: applied?.files ?? [], + snapshotID: applied?.id + ) + // A snapshot may land and be fully consumed while this load is in + // flight, including its deferred-run resume with nothing pending yet. + // Comparing against the pre-await capture would then treat a ready + // project as not ready, defer the action, and leave it stranded. + guard isCurrentWorkspace(identity) else { return .stale } + let current = workspaceFeature.appliedSnapshot + if runFeature.isProjectReady(for: target, snapshotID: current?.id) { + return .ready + } + return .waitingForSnapshot(identity: identity) + } + + /// Continues an action that arrived before the snapshot did. The workspace + /// rebuild always finishes by applying a snapshot, so recording the intent is + /// enough to resume without polling or waiting. + /// + /// A load for one opening must not clear a pending action that belongs to + /// another: openProject already cleared the old pending on the switch, and a + /// stale callback arriving later would otherwise wipe the newly recorded + /// intent. + /// `snapshotID` is the scan this load just applied, not whatever the + /// workspace holds now. Reading the current identity here would compare the + /// run feature against a scan it has not consumed. + private func resumeDeferredRunAction( + _ runFeature: RunFeatureModel, + identity: WorkspaceIdentity, + snapshotID: UUID? + ) { + guard let action = pendingRunAction else { return } + guard action.identity == identity else { return } + guard runFeature.isProjectReady(for: identity.url, snapshotID: snapshotID) else { + return + } + setPendingRunAction(nil) + switch action.kind { + case .run: runSelectedConfiguration() + case .debug: startDebugging() + case .startConfiguration(let configuration): startRunConfiguration(configuration) + case .runAllServices: runAllServiceConfigurations() + case .restart: restartSelectedRun() + } + } + + /// Records an action for the opening the entry task captured, not whatever + /// workspace happens to be current after an await. + private func clearPendingRunAction(for identity: WorkspaceIdentity) { + guard pendingRunAction?.identity == identity else { return } + setPendingRunAction(nil) + } + + private func setPendingRunAction(_ action: PendingRunAction?) { + pendingRunAction = action + // pendingRunAction is not @Published; relay so tests and any UI that + // observes AppModel learn about defer/resume without a feature load. + scheduleObjectWillChangeRelay() + } + + private func deferRunAction(_ kind: PendingRunAction.Kind, for identity: WorkspaceIdentity) { + guard isCurrentWorkspace(identity) else { return } + setPendingRunAction(PendingRunAction(kind: kind, identity: identity)) + } + private func runSelectedConfigurationAfterActivation() async { + guard let identity = currentWorkspaceIdentity else { return } guard let runFeature = await activateExecutionModule()?.runFeature else { return } + guard isCurrentWorkspace(identity) else { return } + switch await ensureRunProjectReady(runFeature, for: identity) { + case .ready: + clearPendingRunAction(for: identity) + case .waitingForSnapshot(let waitingIdentity): + // Launching from a provisional inventory resolves toolchains without + // the Maven project, so wait for the snapshot instead of running. + deferRunAction(.run, for: waitingIdentity) + return + case .stale: + return + } guard runFeature.configurationStatus == .ready else { runFeature.requestRunConfigurationGeneration(intent: .run) return @@ -199,6 +458,7 @@ extension AppModel { )) { return } + guard isCurrentWorkspace(identity) else { return } if configuration.usesCurrentEditorFile, let activeDocument, activeDocument.isDirty { @@ -211,6 +471,7 @@ extension AppModel { return } } + guard isCurrentWorkspace(identity) else { return } runFeature.runSelected(currentFileURL: activeDocument?.url) isRunVisible = true isGitLogVisible = false @@ -223,8 +484,20 @@ extension AppModel { func restartSelectedRun() { isRunVisible = true Task { [weak self] in - guard let self, - let runFeature = await activateExecutionModule()?.runFeature else { return } + guard let self else { return } + guard let identity = currentWorkspaceIdentity else { return } + guard let runFeature = await activateExecutionModule()?.runFeature else { return } + guard isCurrentWorkspace(identity) else { return } + guard runFeature.lastConfiguration != nil else { return } + switch await ensureRunProjectReady(runFeature, for: identity) { + case .ready: + clearPendingRunAction(for: identity) + case .waitingForSnapshot(let waitingIdentity): + deferRunAction(.restart, for: waitingIdentity) + return + case .stale: + return + } guard let configuration = runFeature.lastConfiguration else { return } if !(await activateLanguageRunExtensionIfNeeded( for: configuration, @@ -233,33 +506,63 @@ extension AppModel { )) { return } + guard isCurrentWorkspace(identity) else { return } runFeature.restart() } } func startRunConfiguration(_ configuration: RunConfiguration) { Task { [weak self] in - guard let self, - let runFeature = await activateExecutionModule()?.runFeature, - await activateLanguageRunExtensionIfNeeded( - for: configuration, - currentFileURL: activeDocument?.url, - runFeature: runFeature - ) else { return } + guard let self else { return } + guard let identity = currentWorkspaceIdentity else { return } + guard let runFeature = await activateExecutionModule()?.runFeature else { return } + guard isCurrentWorkspace(identity) else { return } + switch await ensureRunProjectReady(runFeature, for: identity) { + case .ready: + clearPendingRunAction(for: identity) + case .waitingForSnapshot(let waitingIdentity): + // Direct play buttons reach here without going through + // `runSelectedConfiguration`, so they need the same readiness + // gate and must remember which configuration to resume — bound + // to the opening this task started for, not whatever is current + // after an await. + deferRunAction(.startConfiguration(configuration), for: waitingIdentity) + return + case .stale: + return + } + guard await activateLanguageRunExtensionIfNeeded( + for: configuration, + currentFileURL: activeDocument?.url, + runFeature: runFeature + ) else { return } + guard isCurrentWorkspace(identity) else { return } runFeature.startConfiguration(configuration) } } func runAllServiceConfigurations() { Task { [weak self] in - guard let self, - let runFeature = await activateExecutionModule()?.runFeature else { return } + guard let self else { return } + guard let identity = currentWorkspaceIdentity else { return } + guard let runFeature = await activateExecutionModule()?.runFeature else { return } + guard isCurrentWorkspace(identity) else { return } + switch await ensureRunProjectReady(runFeature, for: identity) { + case .ready: + clearPendingRunAction(for: identity) + case .waitingForSnapshot(let waitingIdentity): + deferRunAction(.runAllServices, for: waitingIdentity) + return + case .stale: + return + } for configuration in runFeature.configurations where configuration.execution == .service { guard await activateLanguageRunExtensionIfNeeded( for: configuration, currentFileURL: nil, runFeature: runFeature ) else { return } + guard isCurrentWorkspace(identity) else { return } } runFeature.runAllServices() } @@ -336,7 +639,7 @@ extension AppModel { guard let self else { return } guard await activateExecutionModule() != nil else { return } if let workspaceURL { - await loadProjectServices(at: workspaceURL, files: projectFiles) + await loadProjectServicesForAppliedSnapshot(at: workspaceURL) } _ = await activateDebugModule() } @@ -419,9 +722,23 @@ extension AppModel { } private func startDebuggingAfterActivation() async { - guard let runFeature = await activateExecutionModule()?.runFeature, - await activateDebugModule() != nil, - let workspaceURL else { return } + guard let identity = currentWorkspaceIdentity else { return } + guard let execution = await activateExecutionModule(), + isCurrentWorkspace(identity) else { return } + let runFeature = execution.runFeature + switch await ensureRunProjectReady(runFeature, for: identity) { + case .ready: + clearPendingRunAction(for: identity) + case .waitingForSnapshot(let waitingIdentity): + deferRunAction(.debug, for: waitingIdentity) + return + case .stale: + return + } + let workspaceURL = identity.url + guard isCurrentWorkspace(identity) else { return } + guard await activateDebugModule() != nil, + isCurrentWorkspace(identity) else { return } guard runFeature.configurationStatus == .ready else { runFeature.requestRunConfigurationGeneration(intent: .debug) return @@ -1217,7 +1534,8 @@ extension AppModel { line: Int, utf16Column: Int, isReadOnly: Bool = false, - displayPath: String? = nil + displayPath: String? = nil, + selectsWholeLine: Bool = false ) { navigate( to: EditorNavigationLocation( @@ -1226,7 +1544,8 @@ extension AppModel { utf16Column: utf16Column, isReadOnly: isReadOnly, displayPath: displayPath, - virtualProviderID: nil + virtualProviderID: nil, + selectsWholeLine: selectsWholeLine ), recordsHistory: true ) @@ -1247,7 +1566,8 @@ extension AppModel { editorNavigationTarget = EditorNavigationTarget( url: location.url, line: location.line, - utf16Column: location.utf16Column + utf16Column: location.utf16Column, + selectsWholeLine: location.selectsWholeLine ) return } @@ -1289,7 +1609,8 @@ extension AppModel { self.editorNavigationTarget = EditorNavigationTarget( url: location.url, line: location.line, - utf16Column: location.utf16Column + utf16Column: location.utf16Column, + selectsWholeLine: location.selectsWholeLine ) case .failure(let error): onFailure?() @@ -1316,7 +1637,8 @@ extension AppModel { editorNavigationTarget = EditorNavigationTarget( url: location.url.standardizedFileURL, line: location.line, - utf16Column: location.utf16Column + utf16Column: location.utf16Column, + selectsWholeLine: location.selectsWholeLine ) } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift index b9c2d33f..a84a747b 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift @@ -215,6 +215,7 @@ extension AppModel { var isCommitting: Bool { gitFeatureIfActive?.isCommitting ?? false } var gitBlameLines: [URL: [GitBlameLine]] { gitFeatureIfActive?.gitBlameLines ?? [:] } var gitReferences: [GitReference] { gitFeatureIfActive?.gitReferences ?? [] } + var recentGitReferences: [GitReference] { gitFeatureIfActive?.recentGitReferences ?? [] } var gitCommits: [GitCommit] { gitFeatureIfActive?.gitCommits ?? [] } var gitLogMatchedCommitHashes: Set? { gitFeatureIfActive?.gitLogMatchedCommitHashes @@ -229,6 +230,9 @@ extension AppModel { set { gitFeatureIfActive?.selectedGitCommit = newValue } } var selectedGitCommitFiles: [GitCommitFile] { gitFeatureIfActive?.selectedGitCommitFiles ?? [] } + var selectedGitCommitFilesLoadState: GitCommitFilesLoadState { + gitFeatureIfActive?.selectedGitCommitFilesLoadState ?? .idle + } var selectedGitCommitFile: GitCommitFile? { get { gitFeatureIfActive?.selectedGitCommitFile } set { gitFeatureIfActive?.selectedGitCommitFile = newValue } @@ -330,7 +334,7 @@ extension AppModel { switch id { case "open-project", "settings": true - case "save", "find-in-file", "local-history", "reveal-in-finder", "toggle-breakpoint": + case "save", "find-in-file", "replace-in-file", "go-to-line", "local-history", "reveal-in-finder", "toggle-breakpoint": activeDocument != nil case "find-next", "find-previous": isFindBarVisible && findMatchCount > 0 diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+FindInFile.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+FindInFile.swift new file mode 100644 index 00000000..f81def96 --- /dev/null +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+FindInFile.swift @@ -0,0 +1,123 @@ +import Foundation + +/// AppModel 的文件内查找/替换门面:读写 `EditorChromeModel` 的查找状态, +/// 并通过既有通知通路驱动当前编辑器的文本视图。 +extension AppModel { + var isFindBarVisible: Bool { + get { editorChrome.isFindBarVisible } + set { editorChrome.setFindBarVisible(newValue) } + } + var findBarQuery: String { + get { editorChrome.findBarQuery } + set { editorChrome.setFindBarQuery(newValue) } + } + var findOptions: FindInFileOptions { + get { editorChrome.findOptions } + set { editorChrome.setFindOptions(newValue) } + } + var isReplaceVisible: Bool { + get { editorChrome.isReplaceVisible } + set { editorChrome.setReplaceVisible(newValue) } + } + var findReplaceText: String { + get { editorChrome.findReplaceText } + set { editorChrome.setFindReplaceText(newValue) } + } + var findMatchCount: Int { editorChrome.findMatchCount } + var currentFindMatchIndex: Int { editorChrome.currentFindMatchIndex } + + func showFindBar() { + guard activeDocument != nil else { return } + editorChrome.setFindBarVisible(true) + editorChrome.setReplaceVisible(false) + } + + /// Cmd+R:查找栏未显示时带替换行打开,否则在查找/替换之间切换。 + func showReplaceBar() { + guard activeDocument != nil else { return } + if isFindBarVisible { + editorChrome.setReplaceVisible(!editorChrome.isReplaceVisible) + } else { + editorChrome.setFindBarVisible(true) + editorChrome.setReplaceVisible(true) + } + } + + func hideFindBar() { + editorChrome.resetFindBar() + NotificationCenter.default.post(name: .litheFindDismiss, object: nil) + } + + func toggleFindBar() { + if isFindBarVisible { + hideFindBar() + } else { + showFindBar() + } + } + + func setFindBarQuery(_ query: String) { + editorChrome.setFindBarQuery(query) + postFindQueryChangedNotification() + } + + func setFindOptions(_ options: FindInFileOptions) { + editorChrome.setFindOptions(options) + postFindQueryChangedNotification() + } + + func setFindReplaceText(_ text: String) { + editorChrome.setFindReplaceText(text) + } + + private func postFindQueryChangedNotification() { + let options = editorChrome.findOptions + NotificationCenter.default.post( + name: .litheFindQueryChanged, + object: nil, + userInfo: [ + FindNotificationKeys.query: editorChrome.findBarQuery, + FindNotificationKeys.matchCase: options.matchCase, + FindNotificationKeys.wholeWords: options.wholeWords, + FindNotificationKeys.regularExpression: options.regularExpression + ] + ) + } + + func navigateFind(offset: Int) { + NotificationCenter.default.post( + name: .litheFindNavigate, + object: nil, + userInfo: [FindNotificationKeys.direction: offset] + ) + } + + func replaceNextFindMatch() { + guard let documentID = activeDocument?.id else { return } + // 携带目标文档标识:分栏时只有绑定同一文档的编辑器执行替换 + NotificationCenter.default.post( + name: .litheFindReplaceNext, + object: nil, + userInfo: [ + FindNotificationKeys.documentID: documentID, + FindNotificationKeys.replacement: editorChrome.findReplaceText + ] + ) + } + + func replaceAllFindMatches() { + guard let documentID = activeDocument?.id else { return } + NotificationCenter.default.post( + name: .litheFindReplaceAll, + object: nil, + userInfo: [ + FindNotificationKeys.documentID: documentID, + FindNotificationKeys.replacement: editorChrome.findReplaceText + ] + ) + } + + func updateFindState(currentIndex: Int, count: Int) { + editorChrome.updateFindState(currentIndex: currentIndex, count: count) + } +} diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+GitModule.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+GitModule.swift index c3d9956d..23284824 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+GitModule.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+GitModule.swift @@ -41,4 +41,13 @@ extension AppModel { return nil } } + + func previewGitCommitSelection(_ commit: GitCommit) { + gitFeatureIfActive?.previewGitCommitSelection(commit) + } + + func loadGitCommitFiles(for commit: GitCommit) async { + guard let gitFeature = await activateGitModule() else { return } + await gitFeature.loadGitCommitFiles(for: commit) + } } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+GoToLine.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+GoToLine.swift new file mode 100644 index 00000000..be7ca972 --- /dev/null +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+GoToLine.swift @@ -0,0 +1,45 @@ +import Foundation + +/// AppModel facade for the Go to Line feature: drives the `EditorChromeModel` +/// visibility flag that the dialog presenter observes, and routes the +/// submitted "line" or "line:column" input through the existing +/// `navigateToEditorLocation` pathway so jumps enter the navigation history. +extension AppModel { + var isGoToLineVisible: Bool { editorChrome.isGoToLineVisible } + + func showGoToLine() { + guard activeDocument != nil else { return } + if isFindBarVisible { + hideFindBar() + } + editorChrome.setGoToLineVisible(true) + } + + func hideGoToLine() { + editorChrome.setGoToLineVisible(false) + } + + /// Parse "120" or "120:35" and jump; unparseable input or a missing + /// active document is a no-op. Line and column are converged against the + /// current document text right before jumping, without caching stale + /// line counts, and the jump enters the navigation history so Cmd+[ + /// returns to the departure position. Line-only jumps select the whole + /// target line; an explicitly entered column places the caret at that + /// column instead, so the entered position is never discarded. + func goToLine(_ text: String) { + guard let document = activeDocument, + let parsed = GoToLineInput.parse(text) else { return } + let target = GoToLineInput.clamped( + line: parsed.line, + column: parsed.column, + hasExplicitColumn: parsed.hasExplicitColumn, + in: document.text + ) + navigateToEditorLocation( + url: document.url, + line: target.line, + utf16Column: target.column, + selectsWholeLine: !target.hasExplicitColumn + ) + } +} diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+HistoryModule.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+HistoryModule.swift index 79e1b6a7..48d71d2e 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+HistoryModule.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+HistoryModule.swift @@ -45,4 +45,27 @@ extension AppModel { await action(feature) } } + + func loadExternalVersion(of document: EditorDocument) { + documentFeature.loadExternalVersion(of: document) + } + + func keepEditorVersion(of document: EditorDocument) { + documentFeature.keepEditorVersion(of: document) + } + + func relativePath(for url: URL) -> String { + guard let workspaceURL else { return url.lastPathComponent } + return workspaceRelativePath(for: url, root: workspaceURL) ?? url.lastPathComponent + } + + func recordSave(_ document: EditorDocument, previousText: String) { + let snapshot = LocalHistoryDocumentSnapshot(id: document.id, url: document.url, text: document.text) + withHistoryModule { $0.recordSave(snapshot, previousText: previousText) } + } + + func recordDiscardedEditorText(_ document: EditorDocument) { + let snapshot = LocalHistoryDocumentSnapshot(id: document.id, url: document.url, text: document.text) + withHistoryModule { $0.recordDiscardedEditorText(snapshot) } + } } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+JavaIndex.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+JavaIndex.swift index c16c7056..77294059 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+JavaIndex.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+JavaIndex.swift @@ -46,6 +46,13 @@ extension AppModel { self?.handleLanguageSessionChange() } ) + capability.sessions.configureMavenContextProvider { [weak self] descriptor, rootURL in + guard descriptor.id == "java", + self?.workspaceURL?.standardizedFileURL == rootURL.standardizedFileURL else { + return nil + } + return self?.mavenFeatureIfActive?.launchContext + } capability.tools.onCandidatesChanged = { [weak self] providerID in guard let self, self.languageToolingFeature.shouldRetryCandidate(providerID: providerID), diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift index 8acbb003..fb73bed7 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift @@ -329,8 +329,36 @@ extension AppModel { isTerminalVisible = true } - func closeTerminalSession(_ session: TerminalSession) { + func requestCloseTerminalSession(_ session: TerminalSession) { guard terminalSessions.contains(where: { $0.id == session.id }) else { return } + guard session.isRunning else { + closeTerminalSession(session) + return + } + pendingTerminalCloseSessionID = session.id + } + + var pendingTerminalCloseSession: TerminalSession? { + guard let pendingTerminalCloseSessionID else { return nil } + return terminalSessions.first { $0.id == pendingTerminalCloseSessionID } + } + + func confirmTerminalClose() { + guard let session = pendingTerminalCloseSession else { + pendingTerminalCloseSessionID = nil + return + } + pendingTerminalCloseSessionID = nil + closeTerminalSession(session) + } + + func cancelTerminalClose() { + pendingTerminalCloseSessionID = nil + } + + private func closeTerminalSession(_ session: TerminalSession) { + guard terminalSessions.contains(where: { $0.id == session.id }) else { return } + pendingTerminalCloseSessionID = nil debugTerminalSessionIDs.remove(session.id) for debugSessionID in debugTerminalSessionIDsByDebugSession.keys { debugTerminalSessionIDsByDebugSession[debugSessionID]?.remove(session.id) diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+WorkbenchClose.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+WorkbenchClose.swift new file mode 100644 index 00000000..91ca0571 --- /dev/null +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+WorkbenchClose.swift @@ -0,0 +1,61 @@ +import Foundation + +extension AppModel { + /// Closes the content currently occupying the editor surface before the + /// native window close command is allowed to reach AppKit. + @discardableResult + func requestCloseActiveWorkbenchItem() -> Bool { + if standaloneFileURL != nil { + closeStandaloneFile() + return true + } + if pendingCloseDocument != nil || pendingTerminalCloseSessionID != nil { + return true + } + if isImplementationChooserVisible { + closeLanguageNavigationResults() + return true + } + if selectedSidebar == .database { + selectedSidebar = .project + return true + } + if branchComparison != nil { + closeBranchComparison() + return true + } + if selectedGitCommitDiffContext != nil { + closeGitCommitDiff() + return true + } + if selectedChange != nil { + selectedChange = nil + return true + } + if let session = activeEditorTerminalSession { + requestCloseTerminalSession(session) + return true + } + if let document = activeDocument { + requestCloseDocument(document) + return true + } + guard let item = editorTabItems.last else { return false } + switch item { + case .document(let documentID): + guard let document = openDocuments.first(where: { $0.id == documentID }) else { + editorTabOrderFeature.remove(item) + return true + } + requestCloseDocument(document) + case .terminal(let sessionID): + guard let session = terminalSessions.first(where: { $0.id == sessionID }) else { + editorTabOrderFeature.remove(item) + terminalPlacementFeature.removeSession(sessionID) + return true + } + requestCloseTerminalSession(session) + } + return true + } +} diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index e27a9161..6d442cd4 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -86,16 +86,6 @@ final class AppModel: ObservableObject, Identifiable { } /// 递增令牌:搜索侧栏观察它来把焦点移回输入框。 @Published var searchSidebarFocusRequest = 0 - var isFindBarVisible: Bool { - get { editorChrome.isFindBarVisible } - set { editorChrome.setFindBarVisible(newValue) } - } - var findBarQuery: String { - get { editorChrome.findBarQuery } - set { editorChrome.setFindBarQuery(newValue) } - } - var findMatchCount: Int { editorChrome.findMatchCount } - var currentFindMatchIndex: Int { editorChrome.currentFindMatchIndex } var projectItemEditRequest: ProjectItemEditRequest? { get { workspaceFeature.projectItemEditRequest } set { workspaceFeature.projectItemEditRequest = newValue } @@ -116,6 +106,8 @@ final class AppModel: ObservableObject, Identifiable { @Published private(set) var pendingGeneratedCommitMessage: String? @Published var isGitLogVisible = false @Published var isTerminalVisible = false + @Published var pendingTerminalCloseSessionID: UUID? + var pendingRunAction: PendingRunAction? @Published var isReferencesVisible = false @Published var isProblemsVisible = false @Published var isMavenVisible = false @@ -141,6 +133,9 @@ final class AppModel: ObservableObject, Identifiable { @Published var gitLogSearchQuery = "" private var shortcutDetector: (any ShortcutDetector)? private var sidebarRefreshTask: Task? + // Keep the runtime shutdown operation alive and coalesce close paths that + // can race (for example, project close followed by window teardown). + private var moduleRuntimeShutdownTask: Task? private var shortcutSettingsObservation: AnyCancellable? private var shortcutRecordingObservation: AnyCancellable? private var workbenchBackgroundFeatureObservation: AnyCancellable? @@ -699,7 +694,7 @@ final class AppModel: ObservableObject, Identifiable { } } - func shutdownProjectSession() { + func shutdownProjectSession() async { shortcutDetector?.stop() Task { [weak self] in await self?.services.moduleRuntime.shutdownAll() @@ -713,6 +708,25 @@ final class AppModel: ObservableObject, Identifiable { settings.removeFileVisibilityRulesObserver(fileVisibilityRulesObserverID) self.fileVisibilityRulesObserverID = nil } + await shutdownModuleRuntime() + } + + /// Shuts down this session's module graph once, even when multiple + /// lifecycle paths request cleanup at the same time. + private func shutdownModuleRuntime() async { + if let moduleRuntimeShutdownTask { + await moduleRuntimeShutdownTask.value + return + } + + let moduleRuntime = services.moduleRuntime + let shutdownTask = Task { @MainActor in + await moduleRuntime.shutdownAll() + } + moduleRuntimeShutdownTask = shutdownTask + await shutdownTask.value + moduleRuntimeShutdownTask = nil + clearModuleBindings(for: .database) } private func reloadJavaRuntimeServices() { @@ -897,10 +911,7 @@ final class AppModel: ObservableObject, Identifiable { let normalizedURL = url.standardizedFileURL Task { [weak self] in guard let self else { return } - await self.services.moduleRuntime.shutdownAll() - await MainActor.run { - self.clearModuleBindings(for: .database) - } + await self.shutdownModuleRuntime() } if let previousWorkspaceURL = workspaceURL { workspaceFeature.persistWorkspaceSession(for: previousWorkspaceURL) @@ -987,10 +998,7 @@ final class AppModel: ObservableObject, Identifiable { cancelJavaLanguageServerPreparation() Task { [weak self] in guard let self else { return } - await self.services.moduleRuntime.shutdownAll() - await MainActor.run { - self.clearModuleBindings(for: .database) - } + await self.shutdownModuleRuntime() } if let workspaceURL { workspaceFeature.persistWorkspaceSession(for: workspaceURL) @@ -1051,6 +1059,7 @@ final class AppModel: ObservableObject, Identifiable { standaloneFileURL = nil documentFeature.reset() editorChrome.resetFindBar() + editorChrome.setGoToLineVisible(false) didCloseProject?() } @@ -1395,45 +1404,6 @@ final class AppModel: ObservableObject, Identifiable { } } - func showFindBar() { - guard activeDocument != nil else { return } - editorChrome.setFindBarVisible(true) - } - - func hideFindBar() { - editorChrome.resetFindBar() - NotificationCenter.default.post(name: .litheFindDismiss, object: nil) - } - - func toggleFindBar() { - if isFindBarVisible { - hideFindBar() - } else { - showFindBar() - } - } - - func setFindBarQuery(_ query: String) { - editorChrome.setFindBarQuery(query) - NotificationCenter.default.post( - name: .litheFindQueryChanged, - object: nil, - userInfo: [FindNotificationKeys.query: query] - ) - } - - func navigateFind(offset: Int) { - NotificationCenter.default.post( - name: .litheFindNavigate, - object: nil, - userInfo: [FindNotificationKeys.direction: offset] - ) - } - - func updateFindState(currentIndex: Int, count: Int) { - editorChrome.updateFindState(currentIndex: currentIndex, count: count) - } - func commitStagedChanges() async { guard let gitFeature = await activateGitModule() else { return } if await gitFeature.commitStagedChanges(message: commitMessage, amend: amendCommit) { @@ -1710,6 +1680,16 @@ final class AppModel: ObservableObject, Identifiable { await gitFeature.rebaseCurrentBranch(onto: reference) } + func checkoutAndRebase(_ reference: GitReference) async { + guard let gitFeature = await activateGitModule() else { return } + await gitFeature.checkoutAndRebase(reference) + } + + func pullRemoteReference(_ reference: GitReference, strategy: GitPullStrategy) async { + guard let gitFeature = await activateGitModule() else { return } + await gitFeature.pullRemoteReference(reference, strategy: strategy) + } + func updateCurrentBranch(_ reference: GitReference) async { guard let gitFeature = await activateGitModule() else { return } await gitFeature.updateCurrentBranch(reference) @@ -1758,26 +1738,4 @@ final class AppModel: ObservableObject, Identifiable { await gitFeature.pushBranch(reference) } - func loadExternalVersion(of document: EditorDocument) { - documentFeature.loadExternalVersion(of: document) - } - - func keepEditorVersion(of document: EditorDocument) { - documentFeature.keepEditorVersion(of: document) - } - - func relativePath(for url: URL) -> String { - guard let workspaceURL else { return url.lastPathComponent } - return workspaceRelativePath(for: url, root: workspaceURL) ?? url.lastPathComponent - } - - func recordSave(_ document: EditorDocument, previousText: String) { - let snapshot = LocalHistoryDocumentSnapshot(id: document.id, url: document.url, text: document.text) - withHistoryModule { $0.recordSave(snapshot, previousText: previousText) } - } - - private func recordDiscardedEditorText(_ document: EditorDocument) { - let snapshot = LocalHistoryDocumentSnapshot(id: document.id, url: document.url, text: document.text) - withHistoryModule { $0.recordDiscardedEditorText(snapshot) } - } } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift b/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift index 9981b1a2..530c543c 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift @@ -94,12 +94,20 @@ typealias ProjectItemDeletionRequest = LitheCoreContracts.ProjectItemDeletionReq enum FindNotificationKeys { static let query = "query" static let direction = "direction" + static let matchCase = "matchCase" + static let wholeWords = "wholeWords" + static let regularExpression = "regularExpression" + static let replacement = "replacement" + /// 替换通知的目标文档标识;接收编辑器必须与之匹配才执行替换。 + static let documentID = "documentID" } extension Notification.Name { static let litheFindQueryChanged = Notification.Name("litheFindQueryChanged") static let litheFindNavigate = Notification.Name("litheFindNavigate") static let litheFindDismiss = Notification.Name("litheFindDismiss") + static let litheFindReplaceNext = Notification.Name("litheFindReplaceNext") + static let litheFindReplaceAll = Notification.Name("litheFindReplaceAll") } struct ProjectTreeRevealRequest: Equatable { diff --git a/macos/Sources/Lithe/Models/Editor/EditorChromeModel.swift b/macos/Sources/Lithe/Models/Editor/EditorChromeModel.swift index 9e5e7609..2e81a82a 100644 --- a/macos/Sources/Lithe/Models/Editor/EditorChromeModel.swift +++ b/macos/Sources/Lithe/Models/Editor/EditorChromeModel.swift @@ -9,7 +9,11 @@ final class EditorChromeModel: ObservableObject { @Published private(set) var caret: EditorCaret? @Published private(set) var selectedText = "" @Published private(set) var isFindBarVisible = false + @Published private(set) var isGoToLineVisible = false @Published private(set) var findBarQuery = "" + @Published private(set) var findOptions = FindInFileOptions() + @Published private(set) var isReplaceVisible = false + @Published private(set) var findReplaceText = "" private(set) var findMatchCount = 0 private(set) var currentFindMatchIndex = 0 @@ -26,6 +30,19 @@ final class EditorChromeModel: ObservableObject { func setFindBarVisible(_ isVisible: Bool) { guard isFindBarVisible != isVisible else { return } isFindBarVisible = isVisible + // The find bar and the go-to-line dialog are mutually exclusive; + // opening either dismisses the other. + if isVisible, isGoToLineVisible { + setGoToLineVisible(false) + } + } + + func setGoToLineVisible(_ isVisible: Bool) { + guard isGoToLineVisible != isVisible else { return } + isGoToLineVisible = isVisible + if isVisible, isFindBarVisible { + setFindBarVisible(false) + } } func setFindBarQuery(_ query: String) { @@ -33,6 +50,21 @@ final class EditorChromeModel: ObservableObject { findBarQuery = query } + func setFindOptions(_ options: FindInFileOptions) { + guard findOptions != options else { return } + findOptions = options + } + + func setReplaceVisible(_ isVisible: Bool) { + guard isReplaceVisible != isVisible else { return } + isReplaceVisible = isVisible + } + + func setFindReplaceText(_ text: String) { + guard findReplaceText != text else { return } + findReplaceText = text + } + func updateFindState(currentIndex: Int, count: Int) { guard currentFindMatchIndex != currentIndex || findMatchCount != count else { return } objectWillChange.send() @@ -40,9 +72,11 @@ final class EditorChromeModel: ObservableObject { findMatchCount = count } + /// 查找选项与替换文本在当前工作区会话内保留,关闭查找栏不重置。 func resetFindBar() { setFindBarVisible(false) setFindBarQuery("") + setReplaceVisible(false) updateFindState(currentIndex: 0, count: 0) } @@ -50,5 +84,6 @@ final class EditorChromeModel: ObservableObject { update(caret: nil) update(selectedText: "") resetFindBar() + setGoToLineVisible(false) } } diff --git a/macos/Sources/Lithe/Models/Editor/FindInFileMatcher.swift b/macos/Sources/Lithe/Models/Editor/FindInFileMatcher.swift new file mode 100644 index 00000000..2e11a349 --- /dev/null +++ b/macos/Sources/Lithe/Models/Editor/FindInFileMatcher.swift @@ -0,0 +1,136 @@ +import Foundation + +/// 文件内查找的匹配选项:Match Case、Whole Words、Regular Expression。 +/// 三个选项默认全部关闭,保持既有的大小写与音调不敏感行为。 +struct FindInFileOptions: Equatable, Sendable { + var matchCase = false + var wholeWords = false + var regularExpression = false + + static let `default` = FindInFileOptions() +} + +/// 按选项枚举文件内匹配并展开替换模板的纯 Foundation 值类型。 +/// 不依赖 AppKit,保证匹配结果可确定、可单测。 +struct FindInFileMatcher { + let query: String + let options: FindInFileOptions + + /// 正则模式编译失败时为 false;其余模式始终有效。 + var isValid: Bool { + !options.regularExpression || expression != nil + } + + /// 仅正则模式使用;字面量扫描走 NSString 比较。 + private let expression: NSRegularExpression? + + init(query: String, options: FindInFileOptions) { + self.query = query + self.options = options + if options.regularExpression { + // Whole Words 与正则同时开启时按设计外包一层 \b(?:…)\b; + // 使用非捕获分组,保持模板中 $1 等分组编号不变。 + let pattern = options.wholeWords ? "\\b(?:\(query))\\b" : query + self.expression = try? NSRegularExpression( + pattern: pattern, + options: options.matchCase ? [] : [.caseInsensitive] + ) + } else { + self.expression = nil + } + } + + /// 枚举全文匹配:升序、互不重叠、跳过零宽度匹配。 + func matchRanges(in source: NSString) -> [NSRange] { + matchRanges(in: source, range: NSRange(location: 0, length: source.length)) + } + + /// 枚举指定范围内的匹配。枚举以全文为底、仅限制范围, + /// 使 \b 与词边界校验始终基于真实上下文而不是窗口局部文本。 + func matchRanges(in source: NSString, range: NSRange) -> [NSRange] { + guard !query.isEmpty, range.location >= 0, NSMaxRange(range) <= source.length else { return [] } + if let expression { + return regexMatchRanges(expression: expression, in: source, range: range) + } + return literalMatchRanges(in: source, range: range) + } + + /// 展开替换模板:正则模式按 NSRegularExpression 语义展开 $n 数字捕获组 + /// (${name} 命名分组模板当前 SDK 不支持,原样返回),字面量模式原样返回。匹配列表与当前文本不一致时按字面量处理。 + func replacement(for source: NSString, matchRange: NSRange, template: String) -> String { + guard let expression else { return template } + let searchRange = NSRange( + location: matchRange.location, + length: max(0, source.length - matchRange.location) + ) + guard let match = expression.firstMatch(in: source as String, range: searchRange), + match.range == matchRange else { + return template + } + // offset 仅锚定模板中的 \G;$n 数字分组展开不受影响 + return expression.replacementString(for: match, in: source as String, offset: 0, template: template) + } + + private func regexMatchRanges( + expression: NSRegularExpression, + in source: NSString, + range: NSRange + ) -> [NSRange] { + var ranges: [NSRange] = [] + expression.enumerateMatches(in: source as String, options: [], range: range) { match, _, _ in + guard let match else { return } + // 零宽度匹配(如 a*)不参与高亮和替换 + guard match.range.length > 0 else { return } + ranges.append(match.range) + } + return ranges + } + + private func literalMatchRanges(in source: NSString, range: NSRange) -> [NSRange] { + let compareOptions: String.CompareOptions = options.matchCase + ? [] + : [.caseInsensitive, .diacriticInsensitive] + var ranges: [NSRange] = [] + var cursor = range + while cursor.length > 0 { + let found = source.range(of: query, options: compareOptions, range: cursor) + guard found.location != NSNotFound else { break } + if !options.wholeWords || isWholeWordMatch(found, in: source) { + ranges.append(found) + cursor.location = NSMaxRange(found) + } else { + // 拒绝候选后从下一字符继续,保证重叠位置的合法匹配不被漏掉 + cursor.location = found.location + 1 + } + cursor.length = max(0, NSMaxRange(range) - cursor.location) + } + return ranges + } + + /// 全词判定:匹配两侧都不是词字符(串首串尾视为边界)。 + private func isWholeWordMatch(_ range: NSRange, in source: NSString) -> Bool { + !Self.isWordCharacter(at: range.location - 1, in: source) + && !Self.isWordCharacter(at: NSMaxRange(range), in: source) + } + + /// 词字符 = 字母、数字和下划线;越界(串首串尾)返回 false。 + /// 按 UTF-16 位置判断,必要时组合代理对还原完整字符后再分类。 + private static func isWordCharacter(at index: Int, in source: NSString) -> Bool { + guard index >= 0, index < source.length else { return false } + var value = UInt32(source.character(at: index)) + if (0xDC00...0xDFFF).contains(value), index > 0 { + // 低代理半区:与前面的高代理组合成完整字符 + let previous = UInt32(source.character(at: index - 1)) + guard (0xD800...0xDBFF).contains(previous) else { return false } + value = 0x10000 + (previous - 0xD800) * 0x400 + (value - 0xDC00) + } else if (0xD800...0xDBFF).contains(value), index + 1 < source.length { + let next = UInt32(source.character(at: index + 1)) + guard (0xDC00...0xDFFF).contains(next) else { return false } + value = 0x10000 + (value - 0xD800) * 0x400 + (next - 0xDC00) + } + guard let scalar = Unicode.Scalar(value) else { return false } + return scalar == "_" + || scalar.properties.isAlphabetic + || scalar.properties.numericType != nil + } +} diff --git a/macos/Sources/Lithe/Models/Editor/GoToLineInput.swift b/macos/Sources/Lithe/Models/Editor/GoToLineInput.swift new file mode 100644 index 00000000..82c07a7d --- /dev/null +++ b/macos/Sources/Lithe/Models/Editor/GoToLineInput.swift @@ -0,0 +1,92 @@ +import Foundation + +/// Parsing and document-range convergence for the Go to Line input. The +/// input is 1-based text — "120" or "120:35" — and the parsed result is the +/// internal 0-based line and column. The 1-based → 0-based conversion happens +/// only here; the status bar adds 1 back for display, avoiding split offsets. +struct GoToLineInput: Equatable { + let line: Int + let column: Int + /// True when the input carried an explicit ":column" part. Line-only + /// jumps select the whole target line; column jumps place the caret at + /// the column so the entered position is never discarded. + let hasExplicitColumn: Bool + + init(line: Int, column: Int, hasExplicitColumn: Bool = false) { + self.line = line + self.column = column + self.hasExplicitColumn = hasExplicitColumn + } + + /// Parses "120", "120:35", or whitespace-padded equivalents. Empty input, + /// non-numeric text, multiple colons, zero, and negative values are all + /// invalid and yield `nil`, making the jump a no-op. + static func parse(_ text: String) -> GoToLineInput? { + let trimmed = text.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return nil } + let parts = trimmed.split(separator: ":", omittingEmptySubsequences: false) + guard parts.count == 1 || parts.count == 2 else { return nil } + guard let line = oneBasedNumber(in: parts[0]) else { return nil } + let hasExplicitColumn = parts.count == 2 + guard let column = hasExplicitColumn ? oneBasedNumber(in: parts[1]) : 1 else { + return nil + } + return GoToLineInput(line: line - 1, column: column - 1, hasExplicitColumn: hasExplicitColumn) + } + + /// Converges 0-based line and column into the given document content: + /// an out-of-range line collapses to the last line, an out-of-range + /// column to the end of that line's content (excluding its terminator), + /// negatives to the origin. An empty document only ever addresses the + /// document start. Lines break on LF, CRLF, and CR — the same + /// terminators the editor's `TextLineIndex` recognizes — and a trailing + /// terminator yields a final empty line. Callers must re-converge with + /// the live document text right before jumping; line counts are never + /// cached. + static func clamped( + line: Int, + column: Int, + hasExplicitColumn: Bool = false, + in content: String + ) -> GoToLineInput { + let text = content as NSString + let length = text.length + let requestedLine = max(line, 0) + var lineIndex = 0 + var lineStart = 0 + while true { + var scan = lineStart + var terminatorLength = 0 + while scan < length { + let character = text.character(at: scan) + if character == 10 { + terminatorLength = 1 + break + } + if character == 13 { + terminatorLength = (scan + 1 < length && text.character(at: scan + 1) == 10) ? 2 : 1 + break + } + scan += 1 + } + if lineIndex == requestedLine || scan == length { + // Hit the requested line, or ran past the last content line + // and converge onto this final line. + return GoToLineInput( + line: lineIndex, + column: min(max(column, 0), scan - lineStart), + hasExplicitColumn: hasExplicitColumn + ) + } + lineIndex += 1 + lineStart = scan + terminatorLength + } + } + + private static func oneBasedNumber(in part: Substring) -> Int? { + guard let value = Int(part.trimmingCharacters(in: .whitespaces)), value >= 1 else { + return nil + } + return value + } +} diff --git a/macos/Sources/Lithe/Models/Editor/GoToLineSelection.swift b/macos/Sources/Lithe/Models/Editor/GoToLineSelection.swift new file mode 100644 index 00000000..699bf9ed --- /dev/null +++ b/macos/Sources/Lithe/Models/Editor/GoToLineSelection.swift @@ -0,0 +1,51 @@ +import Foundation + +/// Editor selection computation for a Go to Line navigation target. +/// Line-only targets select the whole target line's content with the line +/// terminator excluded (LF, CRLF, or CR); column targets place a zero-length +/// caret at the converged column so an explicitly entered column is never +/// discarded. Line breaks follow the same LF/CRLF/CR rules as +/// `TextLineIndex` and `GoToLineInput.clamped`, keeping jump targets, +/// gutter numbering, and the status bar caret on one line-index definition. +enum GoToLineSelection { + static func targetRange( + line: Int, + utf16Column: Int, + selectsWholeLine: Bool, + in text: NSString + ) -> NSRange { + let length = text.length + let requestedLine = max(line, 0) + var lineIndex = 0 + var lineStart = 0 + var contentEnd = 0 + while true { + var scan = lineStart + var terminatorLength = 0 + while scan < length { + let character = text.character(at: scan) + if character == 10 { + terminatorLength = 1 + break + } + if character == 13 { + terminatorLength = (scan + 1 < length && text.character(at: scan + 1) == 10) ? 2 : 1 + break + } + scan += 1 + } + contentEnd = scan + if lineIndex == requestedLine || scan == length { + // Hit the requested line, or converge onto the final line. + break + } + lineIndex += 1 + lineStart = scan + terminatorLength + } + if selectsWholeLine { + return NSRange(location: lineStart, length: contentEnd - lineStart) + } + let location = min(contentEnd, lineStart + max(utf16Column, 0)) + return NSRange(location: location, length: 0) + } +} diff --git a/macos/Sources/Lithe/Models/Java/JavaNavigationModels.swift b/macos/Sources/Lithe/Models/Java/JavaNavigationModels.swift index 755d9fb3..a8f4d624 100644 --- a/macos/Sources/Lithe/Models/Java/JavaNavigationModels.swift +++ b/macos/Sources/Lithe/Models/Java/JavaNavigationModels.swift @@ -12,6 +12,9 @@ struct EditorNavigationTarget: Equatable, Identifiable { let url: URL let line: Int let utf16Column: Int + /// Select the whole target line on arrival (Go to Line); symbol and find + /// navigation keep a zero-length caret. + var selectsWholeLine: Bool = false } struct LanguageNavigationLocation: Identifiable, Hashable, Sendable { diff --git a/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift b/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift index baa2dc07..3c721360 100644 --- a/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift +++ b/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift @@ -43,6 +43,8 @@ enum LitheCommandCatalog { command("find-in-file", "Find in File", "Search within the active editor", .navigation, "f", [.command]), command("find-next", "Find Next", "Move to the next match in the active editor", .navigation, "g", [.command]), command("find-previous", "Find Previous", "Move to the previous match in the active editor", .navigation, "g", [.shift, .command]), + command("replace-in-file", "Replace in File", "Replace within the active editor", .navigation, "r", [.command]), + command("go-to-line", "Go to Line", "Jump to a line and column in the active editor", .navigation, "l", [.command]), command("go-to-definition", "Go to Definition", "Navigate to the declaration of the selected symbol", .navigation, "b", [.command]), command("go-to-implementation", "Go to Implementation", "Navigate to an implementation of the selected symbol", .navigation, "b", [.option, .command]), command("find-usages", "Find Usages", "Find references to the selected symbol", .navigation, "u", [.option, .command]), diff --git a/macos/Sources/Lithe/Models/LitheAction.swift b/macos/Sources/Lithe/Models/LitheAction.swift index 8656db0c..9da18636 100644 --- a/macos/Sources/Lithe/Models/LitheAction.swift +++ b/macos/Sources/Lithe/Models/LitheAction.swift @@ -86,6 +86,8 @@ enum LitheActionRegistry { action("search-in-project", model: model) { model.openProjectSearch() }, action("replace-in-project", model: model) { model.openProjectReplace() }, action("find-in-file", model: model) { model.showFindBar() }, + action("replace-in-file", model: model) { model.showReplaceBar() }, + action("go-to-line", model: model) { model.showGoToLine() }, action("go-to-definition", model: model) { model.goToDefinition() }, action("find-usages", model: model) { model.findReferences() }, action("spring-endpoints", model: model) { model.toggleSpringEndpoints() }, diff --git a/macos/Sources/Lithe/Models/Workspace/ProjectSessionManager.swift b/macos/Sources/Lithe/Models/Workspace/ProjectSessionManager.swift index ca9d074d..8aab3080 100644 --- a/macos/Sources/Lithe/Models/Workspace/ProjectSessionManager.swift +++ b/macos/Sources/Lithe/Models/Workspace/ProjectSessionManager.swift @@ -24,6 +24,10 @@ final class ProjectSessionManager: ObservableObject { private let modelFactory: () -> AppModel private let newWindowOpener: (URL) -> Void private var modelObservations: [UUID: AnyCancellable] = [:] + // A closed model can disappear from `sessions` before its asynchronous + // module teardown finishes. Keep the task here so the manager remains the + // owner of that cleanup until it has completed. + private var sessionShutdownTasks: [UUID: Task] = [:] init( settings: AppSettings, @@ -144,6 +148,11 @@ final class ProjectSessionManager: ObservableObject { activeModel.closeProject() } + @discardableResult + func requestCloseActiveWorkbenchItem() -> Bool { + activeModel.requestCloseActiveWorkbenchItem() + } + func requestCloseActiveSession() -> Bool { if activeModel.workspaceURL != nil { closeActiveProject() @@ -159,6 +168,22 @@ final class ProjectSessionManager: ObservableObject { return true } + func resetForProjectWindowClose() async { + let previousSessions = sessions + + pendingProjectOpen = nil + modelObservations.removeAll() + for model in previousSessions { + await scheduleSessionShutdown(for: model).value + } + await waitForPendingSessionShutdowns() + + let replacement = modelFactory() + configure(replacement) + sessions = [replacement] + activeSessionID = replacement.id + } + func closeProject(_ id: UUID) { guard sessions.contains(where: { $0.id == id }) else { return } if id != activeSessionID { @@ -176,10 +201,11 @@ final class ProjectSessionManager: ObservableObject { return savedAll } - func stopAllSessions() { + func stopAllSessions() async { for model in sessions { - model.shutdownProjectSession() + await scheduleSessionShutdown(for: model).value } + await waitForPendingSessionShutdowns() } func resumeGitObservationAfterActivation() async { @@ -229,7 +255,7 @@ final class ProjectSessionManager: ObservableObject { let removedIndex = sessions.firstIndex(where: { $0.id == model.id }) else { return } let wasActive = model.id == activeSessionID - model.shutdownProjectSession() + _ = scheduleSessionShutdown(for: model) modelObservations[model.id] = nil sessions.remove(at: removedIndex) @@ -253,4 +279,27 @@ final class ProjectSessionManager: ObservableObject { model.refreshRecentProjects() } } + + private func scheduleSessionShutdown(for model: AppModel) -> Task { + if let existingTask = sessionShutdownTasks[model.id] { + return existingTask + } + + let modelID = model.id + let task = Task { @MainActor [weak self, model] in + defer { self?.sessionShutdownTasks[modelID] = nil } + await model.shutdownProjectSession() + } + sessionShutdownTasks[modelID] = task + return task + } + + private func waitForPendingSessionShutdowns() async { + while !sessionShutdownTasks.isEmpty { + let pendingTasks = Array(sessionShutdownTasks.values) + for task in pendingTasks { + await task.value + } + } + } } diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index a4b37f74..fe21e91f 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -313,7 +313,8 @@ final class MacServiceContainer { maven: MavenService( runtimeService: runtimeService, process: MacStreamingProcess(processRegistry: processRegistry, moduleID: .execution), - mavenOperations: javaMavenOperations + mavenOperations: javaMavenOperations, + configurationStore: MacMavenConfigurationStore(storage: fileStorage) ), run: RunService( runtime: runtimeService, diff --git a/macos/Sources/Lithe/Platform/MacOS/Persistence/MacMavenConfigurationStore.swift b/macos/Sources/Lithe/Platform/MacOS/Persistence/MacMavenConfigurationStore.swift new file mode 100644 index 00000000..d59be955 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Persistence/MacMavenConfigurationStore.swift @@ -0,0 +1,119 @@ +import CryptoKit +import Foundation +import LitheCoreContracts + +struct MacMavenConfigurationStore: MavenConfigurationStoring, Sendable { + private let storage: any FileStorage + + init(storage: any FileStorage) { + self.storage = storage + } + + func loadMavenConfiguration( + workspaceURL: URL, + reactorPath: String + ) throws -> MavenStoredConfiguration { + let portable = try decodeIfPresent( + MavenPortableConfiguration.self, + at: portableConfigurationURL(workspaceURL: workspaceURL) + ) + let local = try decodeIfPresent( + MavenLocalConfiguration.self, + at: localConfigurationURL(workspaceURL: workspaceURL, reactorPath: reactorPath) + ) + guard portable?.version == nil || portable?.version == MavenPortableConfiguration.currentVersion, + local?.version == nil || local?.version == MavenLocalConfiguration.currentVersion else { + throw MacMavenConfigurationStoreError.unsupportedVersion + } + return MavenStoredConfiguration(portable: portable, local: local) + } + + func saveMavenConfiguration( + _ configuration: MavenStoredConfiguration, + workspaceURL: URL, + reactorPath: String + ) throws { + try write( + configuration.portable, + to: portableConfigurationURL(workspaceURL: workspaceURL) + ) + try write( + configuration.local, + to: localConfigurationURL(workspaceURL: workspaceURL, reactorPath: reactorPath) + ) + } + + private func portableConfigurationURL(workspaceURL: URL) -> URL { + workspaceURL.standardizedFileURL + .appendingPathComponent(".lithe", isDirectory: true) + .appendingPathComponent("maven", isDirectory: true) + .appendingPathComponent("config.json") + } + + private func localConfigurationURL(workspaceURL: URL, reactorPath: String) -> URL { + let identity = Self.storageIdentity( + workspacePath: workspaceURL.standardizedFileURL.path, + reactorPath: reactorPath + ) + let digest = SHA256.hash(data: Data(identity.utf8)) + .map { String(format: "%02x", $0) } + .joined() + return storage.applicationSupportDirectory() + .appendingPathComponent("Lithe", isDirectory: true) + .appendingPathComponent("Maven", isDirectory: true) + .appendingPathComponent(digest + ".json") + } + + static func storageIdentity(workspacePath: String, reactorPath: String) -> String { + workspacePath + "\0" + reactorPath + } + + private func decodeIfPresent( + _ type: Value.Type, + at url: URL + ) throws -> Value? { + guard storage.fileExists(at: url) else { return nil } + do { + return try JSONDecoder().decode(type, from: storage.readData(from: url, options: [])) + } catch { + throw MacMavenConfigurationStoreError.invalidConfiguration(url.lastPathComponent) + } + } + + private func write(_ value: Value?, to url: URL) throws { + guard let value else { + if storage.fileExists(at: url) { + try storage.removeItem(at: url) + } + return + } + do { + try storage.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + try storage.writeData(encoder.encode(value), to: url, options: .atomic) + } catch { + throw MacMavenConfigurationStoreError.writeFailed(error.localizedDescription) + } + } +} + +private enum MacMavenConfigurationStoreError: LocalizedError { + case invalidConfiguration(String) + case unsupportedVersion + case writeFailed(String) + + var errorDescription: String? { + switch self { + case .invalidConfiguration(let name): + "The Maven configuration in \(name) is invalid." + case .unsupportedVersion: + "The Maven configuration was created by an unsupported version of Lithe." + case .writeFailed(let details): + "Unable to save Maven configuration: \(details)" + } + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacRunConfigurationStore.swift b/macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacRunConfigurationStore.swift index f4515378..4bc99bbe 100644 --- a/macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacRunConfigurationStore.swift +++ b/macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacRunConfigurationStore.swift @@ -100,6 +100,7 @@ struct MacRunConfigurationStore: RunConfigurationOperations, @unchecked Sendable vmArguments: (maven?.jvmArguments ?? []).joined(separator: " "), programArguments: (maven?.programArguments ?? value.args ?? []).joined(separator: " "), activeProfiles: Set(maven?.profiles ?? []), + mavenSkipTests: maven?.skipTests, mavenExecutablePath: java?.mavenExecutablePath ?? "", mavenJavaHomePath: java?.mavenJavaHomePath ?? "", environment: value.env ?? [:] @@ -125,6 +126,24 @@ struct MacRunConfigurationStore: RunConfigurationOperations, @unchecked Sendable currentFile: String?, classPath: String?, debugPort: Int? + ) throws -> SharedLaunchPlan { + try launchPlan( + at: projectURL, + configurationID: configurationID, + currentFile: currentFile, + classPath: classPath, + debugPort: debugPort, + mavenContext: nil + ) + } + + func launchPlan( + at projectURL: URL, + configurationID: String, + currentFile: String?, + classPath: String?, + debugPort: Int?, + mavenContext: MavenLaunchContext? ) throws -> SharedLaunchPlan { let value: RustCoreBridge.LaunchPlanPayload switch core.createLaunchPlan( @@ -132,7 +151,8 @@ struct MacRunConfigurationStore: RunConfigurationOperations, @unchecked Sendable configurationID: configurationID, currentFile: currentFile, classPath: classPath, - debugPort: debugPort + debugPort: debugPort, + mavenContext: mavenContext ) { case .success(let payload): value = payload case .failure(let error): throw RunConfigurationOperationFailure(message: error.userMessage) diff --git a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeDiscovery.swift b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeDiscovery.swift index 9181dea4..f7d09372 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeDiscovery.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeDiscovery.swift @@ -22,11 +22,22 @@ enum MacRuntimeDiscovery { static func mavenExecutable(forHomePath path: String) -> URL? { let expanded = (path as NSString).expandingTildeInPath let url = URL(fileURLWithPath: expanded).standardizedFileURL - let candidates = [ - url, - url.appendingPathComponent("bin/mvn") - ] - return candidates.first(where: { FileManager.default.isExecutableFile(atPath: $0.path) }) + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) else { + return nil + } + let executable = isDirectory.boolValue + ? url.appendingPathComponent("bin/mvn") + : url + var executableIsDirectory: ObjCBool = false + guard FileManager.default.fileExists( + atPath: executable.path, + isDirectory: &executableIsDirectory + ), !executableIsDirectory.boolValue, + FileManager.default.isExecutableFile(atPath: executable.path) else { + return nil + } + return executable.standardizedFileURL } static func validJavaHome(_ path: String) -> URL? { diff --git a/macos/Sources/Lithe/Services/Java/ProjectRuntimeService.swift b/macos/Sources/Lithe/Services/Java/ProjectRuntimeService.swift index dc711cca..7bb8ab4e 100644 --- a/macos/Sources/Lithe/Services/Java/ProjectRuntimeService.swift +++ b/macos/Sources/Lithe/Services/Java/ProjectRuntimeService.swift @@ -19,12 +19,8 @@ extension ProjectRuntimeService: LanguageToolRuntimePort { } extension ProjectRuntimeService: MavenRuntimePort { - package func mavenExecutable(for project: MavenProject) -> URL? { - mavenExecutable(for: project, overridePath: nil) - } - - package func mavenProcessEnvironment() -> [String: String] { - environment(for: .maven) + package func mavenProcessEnvironment(javaHomePath: String?) -> [String: String] { + environment(for: .maven, javaHomeOverride: javaHomePath) } } @@ -344,14 +340,13 @@ final class ProjectRuntimeService: ObservableObject { let resolved = configured.hasPrefix("/") ? URL(fileURLWithPath: configured) : rootURL.appendingPathComponent(configured) - let candidates = [ - resolved, - resolved.appendingPathComponent("bin/mvn") - ] - if let candidate = candidates.first(where: { runtimeLocator.isExecutable(at: $0.standardizedFileURL) }) { - return candidate.standardizedFileURL + let standardized = resolved.standardizedFileURL + if runtimeLocator.isExecutable(at: standardized) { + return standardized } - return nil + return runtimeLocator.mavenExecutable( + forHomePath: standardized.path + ) } let wrapper = rootURL.appendingPathComponent("mvnw") if runtimeLocator.isExecutable(at: wrapper) { diff --git a/macos/Sources/Lithe/Views/App/RootView.swift b/macos/Sources/Lithe/Views/App/RootView.swift index 4210aefe..c8dd1729 100644 --- a/macos/Sources/Lithe/Views/App/RootView.swift +++ b/macos/Sources/Lithe/Views/App/RootView.swift @@ -166,6 +166,20 @@ private struct ActiveSessionChrome: View { ProjectLocalHistoryView(request: request) .environmentObject(model) } + .confirmationDialog( + "Close Running Terminal?", + isPresented: terminalCloseConfirmationPresented, + titleVisibility: .visible + ) { + Button("Close Terminal", role: .destructive) { + model.confirmTerminalClose() + } + Button("Cancel", role: .cancel) { + model.cancelTerminalClose() + } + } message: { + Text("Closing this terminal will stop its shell and any running command.") + } } private var windowLayout: LitheWindowLayout { @@ -174,6 +188,17 @@ private struct ActiveSessionChrome: View { return activeModel.workspaceURL == nil ? .welcome : .workspace } + private var terminalCloseConfirmationPresented: Binding { + Binding( + get: { model.pendingTerminalCloseSessionID != nil }, + set: { isPresented in + if !isPresented { + model.cancelTerminalClose() + } + } + ) + } + private var windowTitle: String? { if windowLayout == .standalone { return projectSessions.activeModel.standaloneFileURL?.lastPathComponent ?? "Lithe" @@ -212,6 +237,10 @@ private struct WindowCloseGuard: NSViewRepresentable { context.coordinator.attach(to: view.window, layout: layout, title: title) } } + + static func dismantleNSView(_ view: NSView, coordinator: LitheWindowCoordinator) { + coordinator.detach() + } } enum LitheWindowLayout: Equatable { @@ -272,11 +301,13 @@ enum LitheWindowLayout: Equatable { } @MainActor -protocol ProjectWindowSessionHandling: AnyObject { +protocol ProjectWindowSessionHandling: UnsavedDocumentHandling { var hasActiveProject: Bool { get } var hasActiveStandaloneFile: Bool { get } func closeActiveProject() + func requestCloseActiveWorkbenchItem() -> Bool func requestCloseActiveSession() -> Bool + func resetForProjectWindowClose() async } extension ProjectSessionManager: ProjectWindowSessionHandling { @@ -291,22 +322,43 @@ extension ProjectSessionManager: ProjectWindowSessionHandling { @MainActor final class LitheWindowCoordinator: NSObject, NSWindowDelegate { + private enum NativeWindowCloseIntent { + case commandW + case projectCleanupCompleted + } + var projectSessions: any ProjectWindowSessionHandling weak var window: NSWindow? private var layout: LitheWindowLayout? private var restoredWorkspaceFrame: NSRect? - - init(projectSessions: any ProjectWindowSessionHandling) { + private var closeCommandMonitor: Any? + private var pendingNativeWindowCloseIntent: NativeWindowCloseIntent? + private var nativeWindowCloseTask: Task? + private let confirmUnsavedDocuments: @MainActor (any UnsavedDocumentHandling) -> Bool + private var isDetached = false + + init( + projectSessions: any ProjectWindowSessionHandling, + confirmUnsavedDocuments: @escaping @MainActor (any UnsavedDocumentHandling) -> Bool = { + LitheAppDelegate.confirmUnsavedDocuments( + for: $0, + context: .projectWindowClose + ) + } + ) { self.projectSessions = projectSessions + self.confirmUnsavedDocuments = confirmUnsavedDocuments } func attach(to window: NSWindow?, layout: LitheWindowLayout, title: String? = nil) { - guard let window else { return } + guard !isDetached, let window else { return } if self.window !== window { + stopMonitoringCloseCommand() self.window = window window.delegate = self self.layout = nil restoredWorkspaceFrame = nil + startMonitoringCloseCommand() } apply(layout, title: title, to: window) } @@ -333,12 +385,89 @@ final class LitheWindowCoordinator: NSObject, NSWindowDelegate { } func windowShouldClose(_ sender: NSWindow) -> Bool { + if case .projectCleanupCompleted? = pendingNativeWindowCloseIntent { + pendingNativeWindowCloseIntent = nil + return true + } + guard nativeWindowCloseTask == nil else { return false } + if case .commandW? = pendingNativeWindowCloseIntent { + pendingNativeWindowCloseIntent = nil + guard confirmUnsavedDocuments(projectSessions) else { return false } + closeWindowAfterProjectCleanup(sender) + return false + } if projectSessions.hasActiveProject || projectSessions.hasActiveStandaloneFile { return projectSessions.requestCloseActiveSession() } return true } + func performCloseCommand() { + guard let window else { return } + guard nativeWindowCloseTask == nil else { return } + guard !projectSessions.requestCloseActiveWorkbenchItem() else { return } + pendingNativeWindowCloseIntent = .commandW + defer { pendingNativeWindowCloseIntent = nil } + window.performClose(nil) + } + + private func closeWindowAfterProjectCleanup(_ sender: NSWindow) { + guard nativeWindowCloseTask == nil else { return } + let projectSessions = projectSessions + nativeWindowCloseTask = Task { @MainActor [weak self, weak sender] in + await projectSessions.resetForProjectWindowClose() + guard let self else { return } + defer { self.nativeWindowCloseTask = nil } + guard !self.isDetached, + let sender, + self.window === sender else { return } + self.pendingNativeWindowCloseIntent = .projectCleanupCompleted + defer { self.pendingNativeWindowCloseIntent = nil } + sender.performClose(nil) + } + } + + private func startMonitoringCloseCommand() { + guard closeCommandMonitor == nil else { return } + closeCommandMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { + [weak self] event in + guard let self, Self.isCloseCommand(event, for: self.window) else { return event } + self.performCloseCommand() + return nil + } + } + + func stopMonitoringCloseCommand() { + guard let closeCommandMonitor else { return } + NSEvent.removeMonitor(closeCommandMonitor) + self.closeCommandMonitor = nil + } + + func detach() { + isDetached = true + stopMonitoringCloseCommand() + window = nil + } + + static func isCloseCommand(_ event: NSEvent, for window: NSWindow?) -> Bool { + guard event.type == .keyDown, + !event.isARepeat, + event.window === window, + event.charactersIgnoringModifiers?.lowercased() == "w" else { + return false + } + let closeModifiers = event.modifierFlags.intersection([ + .command, .control, .option, .shift + ]) + return closeModifiers == .command + } + + deinit { + if let closeCommandMonitor { + NSEvent.removeMonitor(closeCommandMonitor) + } + } + private func apply(_ layout: LitheWindowLayout, title: String?, to window: NSWindow) { window.contentMinSize = layout.minimumContentSize if let title { diff --git a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift index 0199eef4..1d7a9373 100644 --- a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift +++ b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift @@ -550,6 +550,7 @@ struct CodeEditorView: NSViewRepresentable { textView.onGoToImplementation = { [weak model] in model?.goToImplementation() } textView.onFindUsages = { [weak model] in model?.findReferences() } textView.onFindRequested = { [weak model] in model?.showFindBar() } + textView.onGoToLineRequested = { [weak model] in model?.showGoToLine() } textView.onFindNextRequested = { [weak model] in model?.navigateFind(offset: 1) } textView.onFindPreviousRequested = { [weak model] in model?.navigateFind(offset: -1) } textView.onRunToCursor = { [weak model] line, column in @@ -717,13 +718,17 @@ struct CodeEditorView: NSViewRepresentable { context.coordinator.updateDiagnostics() context.coordinator.applyNavigationTargetIfNeeded() if let codeTextView = textView as? CodeTextView { + codeTextView.documentID = document.id let findVisible = chrome.isFindBarVisible let findQuery = chrome.findBarQuery + let findOptions = chrome.findOptions if context.coordinator.lastFindVisible != findVisible - || context.coordinator.lastFindQuery != findQuery { + || context.coordinator.lastFindQuery != findQuery + || context.coordinator.lastFindOptions != findOptions { context.coordinator.lastFindVisible = findVisible context.coordinator.lastFindQuery = findQuery - codeTextView.syncFindState(isVisible: findVisible, query: findQuery) + context.coordinator.lastFindOptions = findOptions + codeTextView.syncFindState(isVisible: findVisible, query: findQuery, options: findOptions) } } context.coordinator.applySynchronizedMarkdownScrollIfNeeded(to: container.scrollView) @@ -751,6 +756,7 @@ struct CodeEditorView: NSViewRepresentable { var implementationMarkers: [JavaImplementationMarker] = [] var lastFindVisible = false var lastFindQuery = "" + var lastFindOptions = FindInFileOptions() private var pendingHighlightRange: NSRange? private var pendingReplacedRange: NSRange? private var pendingReplacement: String? @@ -1305,7 +1311,7 @@ struct CodeEditorView: NSViewRepresentable { try? await Task.sleep(for: .milliseconds(80)) guard !Task.isCancelled, let self, let textView = self.textView as? CodeTextView else { return } if let model = self.model, model.isFindBarVisible, !model.findBarQuery.isEmpty { - textView.updateFindMatches(query: model.findBarQuery) + textView.updateFindMatches(query: model.findBarQuery, options: model.findOptions) } else { textView.updateEditorDecorations() } @@ -1647,17 +1653,14 @@ struct CodeEditorView: NSViewRepresentable { 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) - textView.setSelectedRange(NSRange(location: location, length: 0)) - textView.scrollRangeToVisible(NSRange(location: location, length: 0)) + let selection = GoToLineSelection.targetRange( + line: target.line, + utf16Column: target.utf16Column, + selectsWholeLine: target.selectsWholeLine, + in: text + ) + textView.setSelectedRange(selection) + textView.scrollRangeToVisible(selection) textView.window?.makeFirstResponder(textView) scheduleCaretUpdate() } @@ -1818,6 +1821,7 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { var onGoToImplementation: (() -> Void)? var onFindUsages: (() -> Void)? var onFindRequested: (() -> Void)? + var onGoToLineRequested: (() -> Void)? var onFindNextRequested: (() -> Void)? var onFindPreviousRequested: (() -> Void)? var onFindStateChange: ((Int, Int) -> Void)? @@ -1840,6 +1844,9 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { private var findMatchRanges: [NSRange] = [] private var currentFindMatchIndex = 0 private var lastReportedFindState: (index: Int, count: Int)? + private var findMatcher = FindInFileMatcher(query: "", options: .default) + /// 本视图绑定的文档标识;替换通知只在与之匹配时生效,防止分栏误伤。 + var documentID: UUID? private var lastCaretBackgroundRanges: [NSRange] = [] private var completionItemsByID: [String: LanguageServerCompletionItem] = [:] private var languageHoverPopover: NSPopover? @@ -2047,6 +2054,16 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { clearFindHighlights() return } + let matcher = query == findMatcher.query + ? findMatcher + : FindInFileMatcher(query: query, options: findMatcher.options) + findMatcher = matcher + if matcher.options.regularExpression { + // 正则可能产生跨行匹配,编辑行附近的增量窗口覆盖不了, + // 直接整篇重算,避免无关位置编辑后丢失跨行匹配。 + updateFindMatches(query: query, options: matcher.options) + return + } let source = string as NSString let delta = insertedLength - replacedRange.length let replacedEnd = NSMaxRange(replacedRange) @@ -2057,34 +2074,26 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { } return nil } + // 重算窗口 = 编辑所在行向两侧各扩一个字符:行边界处全词匹配的 + // 边界字符可能落在相邻行,只有窗口覆盖该字符才能正确移除并重算。 let safeLocation = min(replacedRange.location, max(0, source.length - 1)) let lineRange = source.length == 0 ? NSRange(location: 0, length: 0) : source.lineRange(for: NSRange(location: safeLocation, length: 0)) - let searchEnd = min(source.length, max(NSMaxRange(lineRange), replacedRange.location + insertedLength)) + let windowLocation = max(0, lineRange.location - 1) + let windowEnd = min(source.length, NSMaxRange(lineRange) + 1) let searchRange = NSRange( - location: lineRange.location, - length: max(0, searchEnd - lineRange.location) + location: windowLocation, + length: max(0, windowEnd - windowLocation) ) findMatchRanges.removeAll { range in NSIntersectionRange(range, searchRange).length > 0 || (range.location >= searchRange.location && range.location < NSMaxRange(searchRange)) } - if searchRange.length > 0, !query.isEmpty { - var cursor = searchRange - while cursor.length > 0 { - let found = source.range( - of: query, - options: [.caseInsensitive, .diacriticInsensitive], - range: cursor - ) - if found.location == NSNotFound { break } - findMatchRanges.append(found) - let nextLocation = NSMaxRange(found) - cursor = NSRange(location: nextLocation, length: NSMaxRange(searchRange) - nextLocation) - } - findMatchRanges.sort { $0.location < $1.location } + for found in matcher.matchRanges(in: source, range: searchRange) { + findMatchRanges.append(found) } + findMatchRanges.sort { $0.location < $1.location } currentFindMatchIndex = min(currentFindMatchIndex, max(0, findMatchRanges.count - 1)) applyFindHighlights() reportFindState( @@ -2229,25 +2238,13 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { // MARK: - Find in file - /// 重新计算匹配范围并刷新高亮,用于 Find Bar 查询变化。 + /// 重新计算匹配范围并刷新高亮,用于 Find Bar 查询或选项变化。 /// 通过 updateEditorDecorations 统一重画,避免旧查询高亮残留。 - func updateFindMatches(query: String) { + func updateFindMatches(query: String, options: FindInFileOptions) { + let matcher = FindInFileMatcher(query: query, options: options) + findMatcher = matcher let source = string as NSString - var newRanges: [NSRange] = [] - if !query.isEmpty { - var searchRange = NSRange(location: 0, length: source.length) - while searchRange.length > 0 { - let found = source.range( - of: query, - options: [.caseInsensitive, .diacriticInsensitive], - range: searchRange - ) - if found.location == NSNotFound { break } - newRanges.append(found) - let nextLocation = NSMaxRange(found) - searchRange = NSRange(location: nextLocation, length: source.length - nextLocation) - } - } + let newRanges = matcher.matchRanges(in: source) let needsRefresh = !findMatchRanges.isEmpty || !newRanges.isEmpty let previousIndex = currentFindMatchIndex let previousRanges = findMatchRanges @@ -2278,6 +2275,80 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { reportFindState(index: currentFindMatchIndex, count: total) } + /// 替换当前匹配并自动跳到下一处:通过 insertText 进入标准输入管线, + /// 撤销、委托回调与装饰刷新同手工编辑一致。 + func replaceNextFindMatch(replacement: String) { + guard isEditable, + !findMatchRanges.isEmpty, + currentFindMatchIndex < findMatchRanges.count else { return } + let matchRange = findMatchRanges[currentFindMatchIndex] + let expanded = findMatcher.replacement( + for: string as NSString, + matchRange: matchRange, + template: replacement + ) + let replacedRange = NSRange(location: matchRange.location, length: (expanded as NSString).length) + insertText(expanded, replacementRange: matchRange) + // 跳过替换文本自身新产生的匹配,避免与替换结果死循环 + selectFindMatch(after: replacedRange) + } + + /// 一次性替换全部匹配:shouldChangeText + NSTextStorage 批量替换 + + /// didChangeText 一步完成,形成单个撤销步骤;之后整篇重算匹配。 + func replaceAllFindMatches(replacement: String) { + guard isEditable, !findMatchRanges.isEmpty else { return } + let source = string as NSString + let fullRange = NSRange(location: 0, length: source.length) + let rebuilt = rebuiltTextByReplacingMatches(with: replacement, in: source) + guard shouldChangeText(in: fullRange, replacementString: rebuilt) else { return } + textStorage?.replaceCharacters(in: fullRange, with: rebuilt) + didChangeText() + let length = (string as NSString).length + setSelectedRange(NSRange(location: min(selectedRange().location, length), length: 0)) + updateFindMatches(query: findMatcher.query, options: findMatcher.options) + } + + private func rebuiltTextByReplacingMatches(with template: String, in source: NSString) -> String { + let rebuilt = NSMutableString() + var cursor = 0 + for range in findMatchRanges { + guard range.location >= cursor else { continue } + rebuilt.append(source.substring(with: NSRange(location: cursor, length: range.location - cursor))) + rebuilt.append(findMatcher.replacement(for: source, matchRange: range, template: template)) + cursor = NSMaxRange(range) + } + if cursor < source.length { + rebuilt.append(source.substring(with: NSRange(location: cursor, length: source.length - cursor))) + } + return rebuilt as String + } + + /// 选中替换区之后的第一个匹配;没有更靠后的匹配时从文档开头回绕, + /// 两种情况都跳过与替换区重叠的匹配。 + private func selectFindMatch(after replacedRange: NSRange) { + if let next = findMatchRanges.firstIndex(where: { $0.location >= NSMaxRange(replacedRange) }) { + selectFindMatch(at: next) + return + } + if let wrapped = findMatchRanges.firstIndex(where: { !Self.overlapsFindRange($0, replacedRange) }) { + selectFindMatch(at: wrapped) + } + } + + private static func overlapsFindRange(_ range: NSRange, _ other: NSRange) -> Bool { + NSIntersectionRange(range, other).length > 0 + || (range.location >= other.location && range.location < NSMaxRange(other)) + } + + private func selectFindMatch(at index: Int) { + currentFindMatchIndex = index + applyFindHighlights() + let range = findMatchRanges[index] + scrollRangeToVisible(range) + setSelectedRange(range) + reportFindState(index: index, count: findMatchRanges.count) + } + /// Publishes only meaningful find-state transitions so SwiftUI updates do /// not create a feedback loop through `updateNSView`. private func reportFindState(index: Int, count: Int) { @@ -2298,9 +2369,9 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { } /// 文档或查询变化时同步 Find Bar 状态;Find Bar 关闭时仅清理已有高亮。 - func syncFindState(isVisible: Bool, query: String) { + func syncFindState(isVisible: Bool, query: String, options: FindInFileOptions) { if isVisible { - updateFindMatches(query: query) + updateFindMatches(query: query, options: options) } else if !findMatchRanges.isEmpty { clearFindHighlights() } @@ -3273,6 +3344,14 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { } let menu = super.menu(for: event) ?? NSMenu() + let goToLineItem = NSMenuItem( + title: NSLocalizedString("Go to Line…", comment: "Context menu item that opens the go-to-line dialog"), + action: #selector(goToLineFromMenu), + keyEquivalent: "" + ) + goToLineItem.target = self + menu.insertItem(goToLineItem, at: 0) + menu.insertItem(.separator(), at: 1) let languageItems = languageContextMenuItems() if onRunToCursor != nil { let runToCursor = NSMenuItem( @@ -3319,6 +3398,10 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { onGoToDefinition?() } + @objc private func goToLineFromMenu() { + onGoToLineRequested?() + } + @objc private func showQuickDocumentationFromMenu() { let position = languageServerPosition(at: selectedRange().location) onQuickDocumentation?(position.line, position.utf16Column) @@ -3514,6 +3597,18 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { name: .litheFindDismiss, object: nil ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleFindReplaceNext(_:)), + name: .litheFindReplaceNext, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleFindReplaceAll(_:)), + name: .litheFindReplaceAll, + object: nil + ) } required init?(coder: NSCoder) { @@ -3529,7 +3624,33 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { @objc private func handleFindQueryChanged(_ notification: Notification) { let query = notification.userInfo?[FindNotificationKeys.query] as? String ?? "" - updateFindMatches(query: query) + let options = FindInFileOptions( + matchCase: notification.userInfo?[FindNotificationKeys.matchCase] as? Bool ?? false, + wholeWords: notification.userInfo?[FindNotificationKeys.wholeWords] as? Bool ?? false, + regularExpression: notification.userInfo?[FindNotificationKeys.regularExpression] as? Bool ?? false + ) + updateFindMatches(query: query, options: options) + } + + @objc private func handleFindReplaceNext(_ notification: Notification) { + guard isReplaceNotificationTarget(notification) else { return } + replaceNextFindMatch( + replacement: notification.userInfo?[FindNotificationKeys.replacement] as? String ?? "" + ) + } + + @objc private func handleFindReplaceAll(_ notification: Notification) { + guard isReplaceNotificationTarget(notification) else { return } + replaceAllFindMatches( + replacement: notification.userInfo?[FindNotificationKeys.replacement] as? String ?? "" + ) + } + + /// 替换通知只在绑定同一文档的编辑器上执行,避免分栏时误伤其他编辑器。 + private func isReplaceNotificationTarget(_ notification: Notification) -> Bool { + guard let targetID = notification.userInfo?[FindNotificationKeys.documentID] as? UUID + else { return false } + return targetID == documentID } @objc private func handleFindNavigate(_ notification: Notification) { diff --git a/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift b/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift index 29d42834..09959848 100644 --- a/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift +++ b/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift @@ -94,6 +94,7 @@ struct EditorAreaView: View { } } .background(model.workbenchBackgroundFeature.hasImage ? Color.clear : LitheTheme.editor) + .background(GoToLineDialogPresenter()) .onChange(of: model.openDocuments.map(\.id)) { ids in if let splitDocumentID, !ids.contains(splitDocumentID) { self.splitDocumentID = nil @@ -407,7 +408,7 @@ struct EditorAreaView: View { .lithePointer() Button { - model.closeTerminalSession(session) + model.requestCloseTerminalSession(session) } label: { Image(systemName: "xmark") .font(.system(size: 9, weight: .semibold)) @@ -497,7 +498,7 @@ struct EditorAreaView: View { Button("Clear", action: session.clear) Divider() Button("Close") { - model.closeTerminalSession(session) + model.requestCloseTerminalSession(session) } } .opacity(isDragged ? 0.92 : 1) diff --git a/macos/Sources/Lithe/Views/Editor/FindBarView.swift b/macos/Sources/Lithe/Views/Editor/FindBarView.swift index 8f03a6ab..05d5aa82 100644 --- a/macos/Sources/Lithe/Views/Editor/FindBarView.swift +++ b/macos/Sources/Lithe/Views/Editor/FindBarView.swift @@ -1,10 +1,13 @@ import SwiftUI -/// 编辑器内的单文件查找栏:实时高亮、上/下一个、Esc 关闭。 +/// 编辑器内的单文件查找栏:实时高亮、上/下一个、Esc 关闭; +/// 可展开替换行(Replace / Replace All)并携带 Match Case、Whole Words、 +/// Regular Expression 选项。 struct FindBarView: View { @EnvironmentObject private var model: AppModel @EnvironmentObject private var chrome: EditorChromeModel - @FocusState private var focused: Bool + @FocusState private var findFocused: Bool + @FocusState private var replaceFocused: Bool private var queryBinding: Binding { Binding( @@ -13,17 +16,53 @@ struct FindBarView: View { ) } + private var replaceBinding: Binding { + Binding( + get: { chrome.findReplaceText }, + set: { model.setFindReplaceText($0) } + ) + } + var body: some View { + VStack(spacing: 6) { + findRow + .frame(height: 34) + if chrome.isReplaceVisible { + replaceRow + .frame(height: 34) + } + } + .padding(.horizontal, 10) + .frame(maxWidth: 520) + .lithePopupChrome(cornerRadius: 7) + .onAppear { findFocused = true } + .onChange(of: chrome.isReplaceVisible) { isVisible in + // 替换行展开后焦点移动到替换输入框,收起时还给查找框 + if isVisible { + replaceFocused = true + } else { + findFocused = true + } + } + .onExitCommand { + model.hideFindBar() + } + } + + private var findRow: some View { HStack(spacing: 7) { + optionsMenu + LitheSystemIcon(systemImage: "magnifyingglass") .font(.system(size: 11.5)) - .foregroundStyle(LitheTheme.secondaryText) + .foregroundStyle(queryIsInvalidRegex ? LitheTheme.error : LitheTheme.secondaryText) + .help(queryIsInvalidRegex ? "Invalid regular expression" : "") TextField("Find in file", text: queryBinding) .textFieldStyle(.plain) .font(.system(size: 12.5)) - .focused($focused) - .macReturnKeyHandler(isEnabled: focused) { isShiftPressed in + .focused($findFocused) + .macReturnKeyHandler(isEnabled: findFocused) { isShiftPressed in if isShiftPressed { model.navigateFind(offset: -1) } else { @@ -57,6 +96,15 @@ struct FindBarView: View { .disabled(chrome.findMatchCount == 0) .help("Next match (Return)") + Button { + model.isReplaceVisible.toggle() + } label: { + Image(systemName: chrome.isReplaceVisible ? "chevron.down" : "chevron.right") + } + .litheIconButton() + .foregroundStyle(chrome.isReplaceVisible ? LitheTheme.accent : LitheTheme.secondaryText) + .help(chrome.isReplaceVisible ? "Hide replace" : "Show replace") + Button { model.hideFindBar() } label: { @@ -66,14 +114,90 @@ struct FindBarView: View { .foregroundStyle(LitheTheme.secondaryText) .help("Close (Esc)") } - .padding(.horizontal, 10) - .frame(height: 34) - .frame(maxWidth: 520) - .lithePopupChrome(cornerRadius: 7) - .onAppear { focused = true } - .onExitCommand { - model.hideFindBar() + } + + private var replaceRow: some View { + HStack(spacing: 7) { + LitheSystemIcon(systemImage: "arrow.left.arrow.right") + .font(.system(size: 11.5)) + .foregroundStyle(LitheTheme.secondaryText) + .padding(.leading, 22) + + TextField("Replace with", text: replaceBinding) + .textFieldStyle(.plain) + .font(.system(size: 12.5)) + .focused($replaceFocused) + .macReturnKeyHandler(isEnabled: replaceFocused) { isShiftPressed in + if isShiftPressed { + model.replaceAllFindMatches() + } else { + model.replaceNextFindMatch() + } + } + + Button { + model.replaceNextFindMatch() + } label: { + Text("Replace") + .font(.system(size: 11.5, weight: .medium)) + } + .buttonStyle(.plain) + .foregroundStyle(chrome.findMatchCount == 0 ? LitheTheme.secondaryText : LitheTheme.accent) + .disabled(chrome.findMatchCount == 0) + .help("Replace current match (Return)") + + Button { + model.replaceAllFindMatches() + } label: { + Text("Replace All") + .font(.system(size: 11.5, weight: .medium)) + } + .buttonStyle(.plain) + .foregroundStyle(chrome.findMatchCount == 0 ? LitheTheme.secondaryText : LitheTheme.accent) + .disabled(chrome.findMatchCount == 0) + .help("Replace all matches (Shift+Return)") + } + } + + private var optionsMenu: some View { + Menu { + Toggle("Match Case", isOn: optionBinding(\.matchCase)) + Toggle("Whole Words", isOn: optionBinding(\.wholeWords)) + Toggle("Regular Expression", isOn: optionBinding(\.regularExpression)) + } label: { + LitheSystemIcon( + systemImage: hasActiveOptions ? "slider.horizontal.3.circle.fill" : "slider.horizontal.3" + ) + .font(.system(size: 11.5)) + .foregroundStyle(hasActiveOptions ? LitheTheme.accent : LitheTheme.secondaryText) + .frame(width: 20, height: 20) + .contentShape(Rectangle()) } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .lithePointer() + .help("Find options") + } + + private var hasActiveOptions: Bool { + chrome.findOptions != .default + } + + private var queryIsInvalidRegex: Bool { + !chrome.findBarQuery.isEmpty + && chrome.findOptions.regularExpression + && !FindInFileMatcher(query: chrome.findBarQuery, options: chrome.findOptions).isValid + } + + private func optionBinding(_ keyPath: WritableKeyPath) -> Binding { + Binding( + get: { chrome.findOptions[keyPath: keyPath] }, + set: { newValue in + var options = chrome.findOptions + options[keyPath: keyPath] = newValue + model.setFindOptions(options) + } + ) } private var matchLabel: String { diff --git a/macos/Sources/Lithe/Views/Editor/GoToLineDialog.swift b/macos/Sources/Lithe/Views/Editor/GoToLineDialog.swift new file mode 100644 index 00000000..8694dc9f --- /dev/null +++ b/macos/Sources/Lithe/Views/Editor/GoToLineDialog.swift @@ -0,0 +1,223 @@ +import AppKit +import SwiftUI + +/// “Go to Line:Column”dialog: a small floating window with a single +/// `[Line] [:column]:` input that accepts a 1-based line or line:column, +/// prefilled with the current caret position and fully selected. Return or +/// OK jumps, Esc or Cancel dismisses without side effects. Presentation is +/// a view-layer capability: visibility state lives in `EditorChromeModel` +/// and the jump itself goes through the existing `AppModel` navigation path. +@MainActor +enum GoToLineDialog { + private static let okResponse = NSApplication.ModalResponse(rawValue: 1) + private static let cancelResponse = NSApplication.ModalResponse(rawValue: 0) + /// Both the workbench and the standalone editor window host a presenter + /// observing the same chrome flag; this keeps only one modal alive. + private static var isPresented = false + + /// Present the dialog modally over the editor window; on OK, parse the + /// input and jump. Invalid input keeps Return from jumping. + static func present(model: AppModel) { + guard !isPresented, model.activeDocument != nil else { return } + isPresented = true + defer { isPresented = false } + model.showGoToLine() + + let coordinator = DialogCoordinator() + coordinator.onConfirm = { NSApp.stopModal(withCode: okResponse) } + coordinator.onCancel = { NSApp.stopModal(withCode: cancelResponse) } + let panel = makePanel( + coordinator: coordinator, + appearance: model.settings.themePreference.windowAppearance + ) + configureContent(panel: panel, coordinator: coordinator, initialValue: initialValue(for: model)) + center(panel: panel) + panel.makeKeyAndOrderFront(nil) + if let field = coordinator.field { + panel.makeFirstResponder(field) + field.currentEditor()?.selectAll(nil) + } + let response = NSApp.runModal(for: panel) + panel.orderOut(nil) + + model.hideGoToLine() + if response == okResponse, let input = coordinator.confirmedText { + model.goToLine(input) + } + } + + /// Prefill mirrors the status bar's 1-based line:column display. + private static func initialValue(for model: AppModel) -> String { + let caret = model.editorChrome.caret + return "\(max(caret?.line ?? 0, 0) + 1):\(max(caret?.utf16Column ?? 0, 0) + 1)" + } + + private static func makePanel(coordinator: DialogCoordinator, appearance: NSAppearance?) -> NSPanel { + let panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 340, height: 96), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + panel.title = String(localized: "Go to Line:Column") + panel.isReleasedWhenClosed = false + panel.level = .floating + panel.delegate = coordinator + // Follow the same theme preference as the workbench windows; without + // this the panel falls back to the system appearance and renders + // light inside a dark-themed editor. + panel.appearance = appearance + return panel + } + + private static func configureContent( + panel: NSPanel, + coordinator: DialogCoordinator, + initialValue: String + ) { + let content = NSView(frame: NSRect(x: 0, y: 0, width: 340, height: 96)) + + let label = NSTextField(labelWithString: String(localized: "[Line] [:column]:")) + label.font = .systemFont(ofSize: 13) + label.sizeToFit() + label.frame.origin = NSPoint(x: 16, y: 50) + content.addSubview(label) + + let field = NSTextField(frame: NSRect( + x: label.frame.maxX + 8, + y: 48, + width: 340 - label.frame.width - 16 - 8 - 16, + height: 24 + )) + field.stringValue = initialValue + field.font = .systemFont(ofSize: 13) + field.delegate = coordinator + field.target = coordinator + field.action = #selector(DialogCoordinator.confirmFromField) + coordinator.field = field + content.addSubview(field) + + let cancelButton = NSButton( + title: String(localized: "Cancel"), + target: coordinator, + action: #selector(DialogCoordinator.cancelFromButton) + ) + cancelButton.bezelStyle = .rounded + cancelButton.keyEquivalent = "\u{1b}" + cancelButton.frame = NSRect(x: 340 - 16 - 78 - 10 - 78, y: 12, width: 78, height: 30) + content.addSubview(cancelButton) + + let okButton = NSButton( + title: String(localized: "OK"), + target: coordinator, + action: #selector(DialogCoordinator.confirmFromButton) + ) + okButton.bezelStyle = .rounded + okButton.keyEquivalent = "\r" + okButton.frame = NSRect(x: 340 - 16 - 78, y: 12, width: 78, height: 30) + okButton.isEnabled = GoToLineInput.parse(initialValue) != nil + coordinator.okButton = okButton + content.addSubview(okButton) + + panel.contentView = content + } + + /// Prefer centering over the editor window so the jump origin stays visible. + private static func center(panel: NSPanel) { + let size = panel.frame.size + if let keyWindow = NSApp.keyWindow, keyWindow !== panel { + panel.setFrameOrigin( + NSPoint( + x: keyWindow.frame.midX - size.width / 2, + y: keyWindow.frame.midY - size.height / 2 + ) + ) + } else { + panel.center() + } + } +} + +@MainActor +private final class DialogCoordinator: NSObject, NSWindowDelegate, NSTextFieldDelegate { + weak var field: NSTextField? + weak var okButton: NSButton? + private(set) var confirmedText: String? + var onConfirm: (() -> Void)? + var onCancel: (() -> Void)? + + func windowShouldClose(_ sender: NSWindow) -> Bool { + cancel() + return true + } + + /// Gray out OK while the input is not a valid line or line:column. + func controlTextDidChange(_ notification: Notification) { + guard let field else { return } + okButton?.isEnabled = GoToLineInput.parse(field.stringValue) != nil + } + + func control( + _ control: NSControl, + textView: NSTextView, + doCommandBy commandSelector: Selector + ) -> Bool { + switch commandSelector { + case #selector(NSResponder.insertNewline(_:)): + confirmFromField() + return true + case #selector(NSResponder.cancelOperation(_:)): + cancel() + return true + default: + return false + } + } + + @objc func confirmFromField() { + confirm() + } + + @objc func confirmFromButton() { + confirm() + } + + @objc func cancelFromButton() { + cancel() + } + + private func confirm() { + guard let field, + GoToLineInput.parse(field.stringValue) != nil else { return } + confirmedText = field.stringValue + onConfirm?() + } + + private func cancel() { + onCancel?() + } +} + +/// Presents the dialog when the chrome flag flips on, so every entry point +/// (menu, context menu, status bar, Cmd+L) funnels through the same state. +/// The presentation is deferred one runloop hop so it never runs inside a +/// SwiftUI view update. +struct GoToLineDialogPresenter: View { + @EnvironmentObject private var model: AppModel + @EnvironmentObject private var chrome: EditorChromeModel + @State private var isPresenting = false + + var body: some View { + Color.clear + .frame(width: 0, height: 0) + .accessibilityHidden(true) + .onChange(of: chrome.isGoToLineVisible) { isVisible in + guard isVisible, !isPresenting else { return } + isPresenting = true + DispatchQueue.main.async { [model] in + defer { isPresenting = false } + GoToLineDialog.present(model: model) + } + } + } +} diff --git a/macos/Sources/Lithe/Views/Editor/StandaloneEditorView.swift b/macos/Sources/Lithe/Views/Editor/StandaloneEditorView.swift index 1b0e8097..505d858a 100644 --- a/macos/Sources/Lithe/Views/Editor/StandaloneEditorView.swift +++ b/macos/Sources/Lithe/Views/Editor/StandaloneEditorView.swift @@ -14,6 +14,7 @@ struct StandaloneEditorView: View { content } .background(LitheTheme.editor) + .background(GoToLineDialogPresenter()) .confirmationDialog( "Save changes before closing?", isPresented: Binding( diff --git a/macos/Sources/Lithe/Views/Git/BranchSwitcherPopover.swift b/macos/Sources/Lithe/Views/Git/BranchSwitcherPopover.swift index 38cc972a..5686d4dd 100644 --- a/macos/Sources/Lithe/Views/Git/BranchSwitcherPopover.swift +++ b/macos/Sources/Lithe/Views/Git/BranchSwitcherPopover.swift @@ -2,6 +2,15 @@ import SwiftUI import LitheGitModule struct BranchSwitcherPopover: View { + enum Metrics { + static let popupWidth: CGFloat = 375 + static let searchBarHeight: CGFloat = 56 + static let actionRowHeight: CGFloat = 30 + static let branchRowHeight: CGFloat = 28 + static let branchGroupHeaderHeight: CGFloat = 24 + static let branchListHeight: CGFloat = 240 + } + @EnvironmentObject private var model: AppModel @Binding var isPresented: Bool let onCommit: () -> Void @@ -11,53 +20,92 @@ struct BranchSwitcherPopover: View { let onManageBranches: () -> Void @State private var searchQuery = "" + @State private var expandedLocalGroups: Set = [] + @State private var expandedRemoteGroups: Set = [] @FocusState private var searchFocused: Bool var body: some View { - VStack(spacing: 0) { + VStack(alignment: .leading, spacing: 0) { searchBar - Rectangle().fill(LitheTheme.divider).frame(height: 1) + popupDivider actions - Rectangle().fill(LitheTheme.divider).frame(height: 1) + popupDivider branchList } - .frame(width: 500, height: 570) - .lithePopupChrome(cornerRadius: LitheTheme.Metrics.popupCornerRadius) + .frame(width: Metrics.popupWidth, alignment: .leading) + .litheRoundedControlBackground( + LitheTheme.popupBackground, + cornerRadius: LitheTheme.Metrics.popupCornerRadius + ) + .overlay { + RoundedRectangle(cornerRadius: LitheTheme.Metrics.popupCornerRadius) + .stroke(LitheTheme.panelBorder, lineWidth: 1) + } .onAppear { searchFocused = true } } private var searchBar: some View { HStack(spacing: 8) { - LitheSystemIcon(systemImage: "magnifyingglass") - .font(.system(size: 13)) - .foregroundStyle(LitheTheme.secondaryText) - TextField("Search for branches and actions", text: $searchQuery) - .textFieldStyle(.plain) - .font(.system(size: 13)) - .focused($searchFocused) - if !searchQuery.isEmpty { - Button { - searchQuery = "" - } label: { - Image(systemName: "xmark.circle.fill") + HStack(spacing: 8) { + LitheSystemIcon(systemImage: "magnifyingglass") + .font(.system(size: 13)) + .foregroundStyle(LitheTheme.secondaryText) + TextField("Search for branches and actions", text: $searchQuery) + .textFieldStyle(.plain) + .font(.system(size: 13)) + .focused($searchFocused) + if !searchQuery.isEmpty { + Button { + searchQuery = "" + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 12)) + .foregroundStyle(LitheTheme.secondaryText) + } + .buttonStyle(.plain) + .lithePointer() + .help("Clear search") } - .litheIconButton() - .help("Clear search") } + .padding(.horizontal, 10) + .frame(height: 30) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(LitheTheme.popupBackground) + .overlay { + RoundedRectangle(cornerRadius: 6) + .stroke(LitheTheme.panelBorder, lineWidth: 1) + } + ) + Button(action: onManageBranches) { - LitheSystemIcon(systemImage: "gearshape") + LitheSystemIcon(systemImage: "arrow.up.left.and.arrow.down.right") } .litheIconButton() + .foregroundStyle(LitheTheme.secondaryText) .help("Open Git branches") + + Button(action: onManageBranches) { + LitheSystemIcon(systemImage: "gearshape") + } + .litheIconButton() + .foregroundStyle(LitheTheme.secondaryText) + .help("Git branch options") + } + .padding(.leading, 13) + .padding(.trailing, 13) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: Metrics.searchBarHeight) + .background { + topRoundedSectionBackground(LitheTheme.toolHeader) } - .padding(.horizontal, 14) - .frame(height: 48) - .background(LitheTheme.toolHeader) } private var actions: some View { VStack(spacing: 1) { - if actionMatches("Fetch") { + // Keep the default command palette focused like IDEA. Fetch remains + // discoverable through the search field without taking a permanent row. + if !normalizedQuery.isEmpty && actionMatches("Fetch") { actionRow("Fetch", icon: "arrow.down.to.line", shortcut: nil) { isPresented = false Task { await model.fetchGit() } @@ -87,7 +135,7 @@ struct BranchSwitcherPopover: View { } if searchQuery.isEmpty || actionMatches("New Branch") || actionMatches("Checkout Tag or Revision") { - Rectangle().fill(LitheTheme.divider).frame(height: 1).padding(.vertical, 5) + popupDivider.padding(.vertical, 5) } if actionMatches("New Branch") { @@ -102,8 +150,9 @@ struct BranchSwitcherPopover: View { actionRow("Checkout Tag or Revision…", icon: "number", shortcut: nil, action: onCheckoutRevision) } } - .padding(.horizontal, 10) - .padding(.vertical, 9) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .frame(maxWidth: .infinity, alignment: .leading) } private var branchList: some View { @@ -120,42 +169,44 @@ struct BranchSwitcherPopover: View { } .foregroundStyle(LitheTheme.primaryText) .padding(.horizontal, 14) - .frame(height: 34) + .frame(height: Metrics.branchGroupHeaderHeight) - if filteredReferences.isEmpty { - Text(model.isLoadingGitHistory ? "Loading branches…" : "No matching branches") - .font(LitheTheme.uiFont) - .foregroundStyle(LitheTheme.secondaryText) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - ScrollView { - LazyVStack(alignment: .leading, spacing: 2) { - ForEach(branchGroups) { group in - if !group.title.isEmpty { - HStack(spacing: 7) { - Image(systemName: "chevron.down") - .font(.system(size: 8, weight: .bold)) - Image(systemName: group.kind == .local ? "folder" : group.kind == .remote ? "network" : "tag") - .font(.system(size: 11.5)) - Text(LocalizedStringKey(group.title)) - .font(.system(size: 12, weight: .medium)) + Group { + if filteredReferences.isEmpty { + Text(model.isLoadingGitHistory ? "Loading branches…" : "No matching branches") + .font(LitheTheme.uiFont) + .foregroundStyle(LitheTheme.secondaryText) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + if searchQuery.isEmpty { + ForEach(recentReferenceRows) { row in + branchRow(row.reference, indented: false, presentation: .recent) + } + + if !recentReferences.isEmpty && !filteredReferences.isEmpty { + popupDivider.padding(.vertical, 6) } - .foregroundStyle(LitheTheme.secondaryText) - .padding(.horizontal, 14) - .frame(height: 28) - } - ForEach(group.references) { reference in - branchRow(reference, indented: !group.title.isEmpty) + groupedBranchRows + } else { + ForEach(searchResultRows) { row in + branchRow(row.reference, indented: false, presentation: .searchResult) + } } } + .padding(.horizontal, 8) + .padding(.bottom, 8) } - .padding(.horizontal, 8) - .padding(.bottom, 8) } } + .frame(height: Metrics.branchListHeight) + } + .frame(maxWidth: .infinity, alignment: .leading) + .background { + bottomRoundedSectionBackground(LitheTheme.sidebar) } - .background(LitheTheme.sidebar) } private func actionRow( @@ -182,37 +233,166 @@ struct BranchSwitcherPopover: View { } .padding(.horizontal, 8) .frame(maxWidth: .infinity, alignment: .leading) - .frame(height: 34) + .frame(height: Metrics.actionRowHeight) + .litheRowHover( + cornerRadius: 6, + hoverBackground: LitheTheme.subtleSelection + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .lithePointer() + } + + @ViewBuilder + private var groupedBranchRows: some View { + if !localReferences.isEmpty { + branchSectionHeader("Local") + + ForEach(localRootRows) { row in + branchRow(row.reference, indented: true, presentation: .grouped) + } + + ForEach(localNamespaceGroups) { group in + localNamespaceRow(group) + if expandedLocalGroups.contains(group.id) { + ForEach(group.rows) { row in + branchRow(row.reference, indented: true, presentation: .namespaceChild) + } + } + } + } + + if !remoteRootGroups.isEmpty { + branchSectionHeader("Remote") + + ForEach(remoteRootGroups) { group in + remoteRootRow(group) + if expandedRemoteGroups.contains(group.id) { + ForEach(remoteRows(in: group)) { row in + branchRow(row.reference, indented: true, presentation: .remoteChild) + } + } + } + } + + if !tagRows.isEmpty { + branchSectionHeader("Tags") + ForEach(tagRows) { row in + branchRow(row.reference, indented: true, presentation: .grouped) + } + } + } + + private func branchSectionHeader(_ title: String) -> some View { + HStack(spacing: 7) { + Image(systemName: "chevron.down") + .font(.system(size: 8, weight: .bold)) + Text(LocalizedStringKey(title)) + .font(.system(size: 12, weight: .medium)) + } + .foregroundStyle(LitheTheme.secondaryText) + .padding(.horizontal, 14) + .frame(height: Metrics.branchGroupHeaderHeight) + } + + private func localNamespaceRow(_ group: BranchPopupGroup) -> some View { + return Button { + if expandedLocalGroups.contains(group.id) { + expandedLocalGroups.remove(group.id) + } else { + expandedLocalGroups.insert(group.id) + } + } label: { + HStack(spacing: 8) { + Image(systemName: expandedLocalGroups.contains(group.id) ? "chevron.down" : "chevron.right") + .font(.system(size: 8, weight: .bold)) + .frame(width: 12) + Image(systemName: "folder") + .font(.system(size: 11.5)) + .foregroundStyle(LitheTheme.secondaryText) + .frame(width: 17) + Text(group.title) + .font(.system(size: 12.5)) + .foregroundStyle(LitheTheme.primaryText) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + } + .padding(.leading, 8) + .padding(.trailing, 9) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: Metrics.branchRowHeight) .contentShape(Rectangle()) + .litheRowHover(cornerRadius: 5, hoverBackground: LitheTheme.subtleSelection) } .buttonStyle(.plain) .lithePointer() } - private func branchRow(_ reference: GitReference, indented: Bool) -> some View { - Button { + private func remoteRootRow(_ group: BranchPopupGroup) -> some View { + return Button { + if expandedRemoteGroups.contains(group.id) { + expandedRemoteGroups.remove(group.id) + } else { + expandedRemoteGroups.insert(group.id) + } + } label: { + HStack(spacing: 8) { + Image(systemName: expandedRemoteGroups.contains(group.id) ? "chevron.down" : "chevron.right") + .font(.system(size: 8, weight: .bold)) + .frame(width: 12) + Image(systemName: "cloud") + .font(.system(size: 11.5)) + .foregroundStyle(LitheTheme.secondaryText) + .frame(width: 17) + Text(group.title) + .font(.system(size: 12.5)) + .foregroundStyle(LitheTheme.primaryText) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + } + .padding(.leading, 8) + .padding(.trailing, 9) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: Metrics.branchRowHeight) + .contentShape(Rectangle()) + .litheRowHover(cornerRadius: 5, hoverBackground: LitheTheme.subtleSelection) + } + .buttonStyle(.plain) + .lithePointer() + } + + private func branchRow( + _ reference: GitReference, + indented: Bool, + presentation: BranchRowPresentation + ) -> some View { + let highlightsCurrent = presentation == .recent && reference.isCurrent + + return Button { + guard !reference.isCurrent else { return } isPresented = false Task { await model.checkoutReference(reference) } } label: { HStack(spacing: 8) { - Image(systemName: referenceIcon(reference)) + Image(systemName: referenceIcon(reference, marksCurrent: presentation == .recent)) .font(.system(size: 11.5)) - .foregroundStyle(reference.isCurrent ? LitheTheme.warning : LitheTheme.secondaryText) + .foregroundStyle(highlightsCurrent ? LitheTheme.warning : LitheTheme.secondaryText) .frame(width: 17) - Text(branchDisplayName(reference, insideGroup: indented)) - .font(.system(size: 12.5, weight: reference.isCurrent ? .semibold : .regular)) + Text(branchDisplayName(reference, presentation: presentation)) + .font(.system(size: 12.5)) .foregroundStyle(LitheTheme.primaryText) .lineLimit(1) + .truncationMode(.middle) Spacer(minLength: 10) if let upstream = reference.upstreamShortName { Text(upstream) .font(.system(size: 11.5)) .foregroundStyle(LitheTheme.secondaryText) .lineLimit(1) - } else { - Text(reference.kind == .remote ? "Remote" : reference.kind == .tag ? "Tag" : "") - .font(.system(size: 10.5)) - .foregroundStyle(LitheTheme.secondaryText) + .truncationMode(.middle) } if !reference.isCurrent { Image(systemName: "chevron.right") @@ -220,17 +400,31 @@ struct BranchSwitcherPopover: View { .foregroundStyle(LitheTheme.secondaryText) } } - .padding(.leading, indented ? 28 : 10) + .padding(.leading, branchRowLeadingPadding(indented: indented, presentation: presentation)) .padding(.trailing, 9) .frame(maxWidth: .infinity, alignment: .leading) - .frame(height: 34) - .background(reference.isCurrent ? LitheTheme.selection : .clear) + .frame(height: Metrics.branchRowHeight) + .background(highlightsCurrent ? LitheTheme.subtleSelection : .clear) .clipShape(RoundedRectangle(cornerRadius: 5)) .contentShape(Rectangle()) } .buttonStyle(.plain) .lithePointer() - .disabled(reference.isCurrent || model.isPerformingBranchOperation) + .disabled(model.isPerformingBranchOperation) + } + + private var recentReferences: [GitReference] { + guard normalizedQuery.isEmpty else { return [] } + return model.recentGitReferences + } + + private var recentReferenceRows: [BranchPopupRow] { + recentReferences.map { reference in + BranchPopupRow( + id: "recent:\(reference.id)", + reference: reference + ) + } } private var filteredReferences: [GitReference] { @@ -242,35 +436,87 @@ struct BranchSwitcherPopover: View { } } - private var branchGroups: [BranchPopupGroup] { - let grouped = Dictionary(grouping: filteredReferences) { reference -> String in - switch reference.kind { - case .remote: return "Remote" - case .tag: return "Tags" - case .local: - let components = reference.shortName.split(separator: "/") - return components.count > 1 ? String(components.dropLast().joined(separator: "/")) : "" + private var searchResultRows: [BranchPopupRow] { + sortedReferences(filteredReferences).map { reference in + BranchPopupRow(id: "search:\(reference.id)", reference: reference) + } + } + + private var localReferences: [GitReference] { + sortedReferences(filteredReferences.filter { $0.kind == .local }) + } + + private var localRootRows: [BranchPopupRow] { + localReferences + .filter { localNamespace(for: $0) == nil } + .map { reference in + BranchPopupRow(id: "local-root:\(reference.id)", reference: reference) } + } + + private var localNamespaceGroups: [BranchPopupGroup] { + let grouped = Dictionary(grouping: localReferences.compactMap { reference -> (String, GitReference)? in + guard let namespace = localNamespace(for: reference) else { return nil } + return (namespace, reference) + }) { $0.0 } + + return grouped.map { namespace, entries in + return BranchPopupGroup( + title: namespace, + kind: .local, + references: sortedReferences(entries.map { $0.1 }) + ) + } + .sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending } + } + + private var remoteRootGroups: [BranchPopupGroup] { + let remoteReferences = filteredReferences.filter { $0.kind == .remote } + let grouped = Dictionary(grouping: remoteReferences) { reference in + reference.shortName.split(separator: "/").first.map(String.init) ?? reference.shortName } - return grouped.map { title, references in - let kind = references.first?.kind ?? .local + return grouped.map { remoteName, references in return BranchPopupGroup( - title: title, - kind: kind, - references: references.sorted { lhs, rhs in - if lhs.isCurrent != rhs.isCurrent { return lhs.isCurrent } - return lhs.shortName.localizedStandardCompare(rhs.shortName) == .orderedAscending - } + title: remoteName, + kind: .remote, + references: sortedReferences(references) ) } - .sorted { lhs, rhs in - if lhs.title.isEmpty != rhs.title.isEmpty { return lhs.title.isEmpty } - if lhs.kind != rhs.kind { return popupKindOrder(lhs.kind) < popupKindOrder(rhs.kind) } - return lhs.title.localizedStandardCompare(rhs.title) == .orderedAscending + .sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending } + } + + private func remoteRows(in group: BranchPopupGroup) -> [BranchPopupRow] { + group.rows.filter { $0.reference.shortName != group.title } + } + + private var tagRows: [BranchPopupRow] { + sortedReferences(filteredReferences.filter { $0.kind == .tag }).map { reference in + BranchPopupRow(id: "tag:\(reference.id)", reference: reference) } } + private func sortedReferences(_ references: [GitReference]) -> [GitReference] { + references.sorted { lhs, rhs in + if lhs.isCurrent != rhs.isCurrent { return lhs.isCurrent } + return lhs.shortName.localizedStandardCompare(rhs.shortName) == .orderedAscending + } + } + + private func localNamespace(for reference: GitReference) -> String? { + let components = reference.shortName.split(separator: "/") + guard components.count > 1 else { return nil } + return components.dropLast().joined(separator: "/") + } + + private func branchRowLeadingPadding( + indented: Bool, + presentation: BranchRowPresentation + ) -> CGFloat { + if presentation == .namespaceChild || presentation == .remoteChild { return 48 } + return indented ? 28 : 10 + } + private var normalizedQuery: String { searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) } @@ -279,8 +525,34 @@ struct BranchSwitcherPopover: View { normalizedQuery.isEmpty || title.localizedCaseInsensitiveContains(normalizedQuery) } - private func referenceIcon(_ reference: GitReference) -> String { - if reference.isCurrent { return "star.fill" } + private func topRoundedSectionBackground(_ color: Color) -> some View { + RoundedRectangle(cornerRadius: LitheTheme.Metrics.popupCornerRadius) + .fill(color) + .overlay(alignment: .bottom) { + Rectangle() + .fill(color) + .frame(height: LitheTheme.Metrics.popupCornerRadius) + } + } + + private var popupDivider: some View { + Rectangle() + .fill(LitheTheme.divider.opacity(0.55)) + .frame(height: 1) + } + + private func bottomRoundedSectionBackground(_ color: Color) -> some View { + RoundedRectangle(cornerRadius: LitheTheme.Metrics.popupCornerRadius) + .fill(color) + .overlay(alignment: .top) { + Rectangle() + .fill(color) + .frame(height: LitheTheme.Metrics.popupCornerRadius) + } + } + + private func referenceIcon(_ reference: GitReference, marksCurrent: Bool) -> String { + if marksCurrent, reference.isCurrent { return "star" } switch reference.kind { case .local: return "point.3.connected.trianglepath.dotted" case .remote: return "cloud" @@ -288,26 +560,51 @@ struct BranchSwitcherPopover: View { } } - private func branchDisplayName(_ reference: GitReference, insideGroup: Bool) -> String { - guard insideGroup, reference.kind == .local else { return reference.shortName } - return reference.shortName.split(separator: "/").last.map(String.init) ?? reference.shortName - } - - private func popupKindOrder(_ kind: GitReferenceKind) -> Int { - switch kind { - case .local: 0 - case .remote: 1 - case .tag: 2 + private func branchDisplayName( + _ reference: GitReference, + presentation: BranchRowPresentation + ) -> String { + switch presentation { + case .namespaceChild: + return reference.shortName.split(separator: "/").last.map(String.init) ?? reference.shortName + case .remoteChild: + let components = reference.shortName.split(separator: "/") + guard components.count > 1 else { return reference.shortName } + return components.dropFirst().joined(separator: "/") + case .recent, .grouped, .searchResult: + return reference.shortName } } } +private enum BranchRowPresentation { + case recent + case grouped + case namespaceChild + case remoteChild + case searchResult +} + private struct BranchPopupGroup: Identifiable { let title: String let kind: GitReferenceKind let references: [GitReference] var id: String { "\(kind.rawValue):\(title)" } + + var rows: [BranchPopupRow] { + references.map { reference in + BranchPopupRow( + id: "group:\(id):\(reference.id)", + reference: reference + ) + } + } +} + +private struct BranchPopupRow: Identifiable { + let id: String + let reference: GitReference } struct TopBarNewBranchDialog: View { diff --git a/macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift b/macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift index 9023ccd3..93edc59c 100644 --- a/macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift +++ b/macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift @@ -15,7 +15,6 @@ struct ChangesSidebarView: View { @State private var selectedShelf: GitShelfEntry? @State private var pendingDropStash: GitStash? @State private var pendingDropShelf: GitShelfEntry? - @State private var shouldConfirmCommitAndPush = false var body: some View { VStack(spacing: 0) { @@ -102,20 +101,6 @@ struct ChangesSidebarView: View { } message: { Text("This removes the saved patch from Lithe and cannot be undone.") } - .confirmationDialog( - "Commit and push changes?", - isPresented: $shouldConfirmCommitAndPush, - titleVisibility: .visible - ) { - Button("Commit and Push") { - Task { await model.commitAndPushStagedChanges() } - } - .lithePointer() - Button("Cancel", role: .cancel) {} - .lithePointer() - } message: { - Text("The staged changes will be committed and the current branch will be pushed to its configured remote.") - } .confirmationDialog( "Replace current commit message?", isPresented: Binding( @@ -773,20 +758,6 @@ struct ChangesSidebarView: View { .lithePointer() .disabled(!canCommit) - Button { - shouldConfirmCommitAndPush = true - } label: { - HStack(spacing: 6) { - if model.isCommitting { - ProgressView().controlSize(.mini) - } - Text("Commit and Push…") - } - } - .buttonStyle(.bordered) - .lithePointer() - .disabled(!canCommit) - Spacer() Button { model.showSettings(category: .ai) diff --git a/macos/Sources/Lithe/Views/Git/GitGraphView.swift b/macos/Sources/Lithe/Views/Git/GitGraphView.swift index 32e78643..55769df1 100644 --- a/macos/Sources/Lithe/Views/Git/GitGraphView.swift +++ b/macos/Sources/Lithe/Views/Git/GitGraphView.swift @@ -27,7 +27,7 @@ struct GitGraphView: View { ForEach(visibleRows) { row in GitGraphRowView( row: row, - graphWidth: graphWidth(for: row), + graphWidth: maximumGraphWidth, rowHeight: rowHeight, isSelected: selectedHash == row.commit.hash, showCommitDecorations: showCommitDecorations, @@ -51,10 +51,6 @@ struct GitGraphView: View { } } - private func graphWidth(for row: GitGraphRow) -> CGFloat { - max(30, CGFloat(max(row.laneCount, 1)) * 13 + 16) - } - private var maximumGraphWidth: CGFloat { max(30, CGFloat(max(layout.laneCount, 1)) * 13 + 16) } @@ -93,7 +89,14 @@ private struct GitGraphRowView: View, Equatable { ) HStack(spacing: 0) { + Text(row.commit.subject) + .font(.system(size: 12.5, weight: .regular)) + .foregroundStyle(LitheTheme.primaryText) + .lineLimit(1) + if showCommitDecorations, !row.labels.isEmpty { + Spacer(minLength: 8) + HStack(spacing: 6) { ForEach(row.labels) { label in GitGraphLabelView(label: label) @@ -101,10 +104,6 @@ private struct GitGraphRowView: View, Equatable { } .padding(.trailing, 4) } - Text(row.commit.subject) - .font(.system(size: 12.5, weight: .regular)) - .foregroundStyle(LitheTheme.primaryText) - .lineLimit(1) } .frame(maxWidth: .infinity, alignment: .leading) diff --git a/macos/Sources/Lithe/Views/Git/GitLogFilterPopover.swift b/macos/Sources/Lithe/Views/Git/GitLogFilterPopover.swift new file mode 100644 index 00000000..043d87a3 --- /dev/null +++ b/macos/Sources/Lithe/Views/Git/GitLogFilterPopover.swift @@ -0,0 +1,946 @@ +import SwiftUI +import LitheGitModule + +// The Git Log filter bar previously used native `Menu` controls for its Branch +// and User filters. With many references or authors NSMenu grows into a +// screen-height list without any size constraint (issue #302). The types in +// this file back bounded, searchable, anchored popover replacements shaped +// like IntelliJ IDEA's log filter menus: a compact first level with starred +// shortcuts and group flyouts for branches, and a flat searchable list for +// authors. + +/// The user-selection state of the Git Log author filter. +enum GitLogAuthorSelection: Hashable { + case currentUser + case author(name: String, email: String) + + var displayName: String { + switch self { + case .currentUser: + return "Me" + case .author(let name, _): + return name + } + } + + var exactAuthor: GitIdentity? { + switch self { + case .currentUser: + return nil + case .author(let name, let email): + return GitIdentity(name: name, email: email) + } + } +} + +/// One distinct commit author offered by the Git Log author filter. +struct GitLogAuthorOption: Identifiable, Hashable { + let id: String + let name: String + let email: String +} + +/// A row a Git Log filter popover knows how to render. +protocol GitLogFilterRow: Identifiable { + var rowTitle: String { get } + /// Localized fixed label for the title, or `nil` when `rowTitle` is a + /// data-derived name that must render verbatim. + var rowTitleKey: LocalizedStringKey? { get } + var rowDetail: String? { get } + var rowSystemImage: String { get } + /// Starred rows use the accent color for their icon, mirroring IDEA's + /// starred branch shortcuts. + var rowIsStarred: Bool { get } +} + +extension GitLogFilterRow { + var rowTitleKey: LocalizedStringKey? { nil } + var rowIsStarred: Bool { false } +} + +/// A region of a flat Git Log filter popover. Pinned regions hold reset and +/// starred shortcut rows and end with a divider before grouped content; a +/// `nil` title marks an untitled region such as ungrouped local branches. +struct GitLogFilterSection: Identifiable { + let id: String + let title: String? + /// Localized fixed label for the title, or `nil` for data-derived + /// namespace titles that render verbatim. + let titleKey: LocalizedStringKey? + let systemImage: String? + let isPinned: Bool + let items: [Row] +} + +/// The fixed label of a pinned filter row. The English key is the single +/// source of truth: it renders through Localizable.strings and joins query +/// matching together with the resolved localized text, so a search hits the +/// row by either wording. +struct GitLogFilterFixedLabel: Hashable { + let key: String + + var titleKey: LocalizedStringKey { LocalizedStringKey(key) } + + /// Display text resolved in the main bundle. Unit tests run without the + /// app strings bundle, where this resolves back to the key itself. + var localizedTitle: String { + NSLocalizedString(key, comment: "Git Log filter fixed row") + } + + /// A query matches the English key or the localized display text. + func matches(_ query: String, localizedTitle override: String? = nil) -> Bool { + if query.isEmpty { return true } + if key.localizedCaseInsensitiveContains(query) { return true } + let localized = override ?? localizedTitle + return localized.localizedCaseInsensitiveContains(query) + } +} + +/// What a branch filter row represents. The kind replaces sentinel values so +/// reset entries, starred shortcuts, and reference rows are distinguishable +/// without magic strings. +struct GitLogBranchFilterItem: GitLogFilterRow, Identifiable, Hashable { + enum Kind: Hashable { + /// The pinned entry that clears the reference filter. + case allBranches + /// A starred shortcut for the current checkout or its upstream. + case starred(reference: GitReference) + /// A concrete reference listed inside a group. + case reference(GitReference) + } + + static let allBranchesLabel = GitLogFilterFixedLabel(key: "All Branches") + + let kind: Kind + let detail: String? + + private init(kind: Kind, detail: String?) { + self.kind = kind + self.detail = detail + } + + var id: String { + switch kind { + case .allBranches: + return "all-branches" + case .starred(let reference): + return "starred:\(reference.fullName)" + case .reference(let reference): + return reference.fullName + } + } + + var reference: GitReference? { + switch kind { + case .allBranches: + return nil + case .starred(let reference), .reference(let reference): + return reference + } + } + + var rowTitle: String { + switch kind { + case .allBranches: + return Self.allBranchesLabel.localizedTitle + case .starred(let reference), .reference(let reference): + return reference.shortName + } + } + + var rowDetail: String? { detail } + + var rowIsStarred: Bool { + if case .starred = kind { return true } + return false + } + + var rowTitleKey: LocalizedStringKey? { + guard case .allBranches = kind else { return nil } + return Self.allBranchesLabel.titleKey + } + + var rowSystemImage: String { + switch kind { + case .allBranches: + return "point.3.connected.trianglepath.dotted" + case .starred: + return "star.fill" + case .reference(let reference): + if reference.isCurrent { return "star.fill" } + switch reference.kind { + case .local: return "point.3.connected.trianglepath.dotted" + case .remote: return "cloud" + case .tag: return "tag" + } + } + } + + /// Whether this row survives the given query: the reset entry matches its + /// label (English key and localized text), data rows match their short + /// name. + func matches(query: String) -> Bool { + switch kind { + case .allBranches: + return Self.allBranchesLabel.matches(query) + case .starred(let reference), .reference(let reference): + return query.isEmpty || reference.shortName.localizedCaseInsensitiveContains(query) + } + } + + /// Whether this row corresponds to the given selected reference; the + /// reset entry matches only when nothing is selected. + func matches(selected: GitReference?) -> Bool { + switch kind { + case .allBranches: + return selected == nil + case .starred(let reference), .reference(let reference): + return selected?.id == reference.id + } + } + + static let allBranches = GitLogBranchFilterItem(kind: .allBranches, detail: nil) + + /// A starred shortcut row; shortcuts render names only, like IDEA. + static func starred(_ reference: GitReference) -> GitLogBranchFilterItem { + GitLogBranchFilterItem(kind: .starred(reference: reference), detail: nil) + } + + /// A reference row. Reference rows keep their full short name in every + /// mode and surface the upstream, when present, as detail. + static func reference(_ reference: GitReference) -> GitLogBranchFilterItem { + GitLogBranchFilterItem( + kind: .reference(reference), + detail: reference.upstreamShortName + ) + } +} + +/// An author filter row: a pinned reset entry, the current user, or a +/// concrete commit author. +struct GitLogAuthorFilterItem: Identifiable, Hashable, GitLogFilterRow { + enum Kind: Hashable { + case allUsers + case currentUser + case author(name: String, email: String) + } + + static let allUsersLabel = GitLogFilterFixedLabel(key: "All Users") + static let currentUserLabel = GitLogFilterFixedLabel(key: "Me") + + let kind: Kind + let detail: String? + + private init(kind: Kind, detail: String?) { + self.kind = kind + self.detail = detail + } + + var id: String { + switch kind { + case .allUsers: return "all-users" + case .currentUser: return "current-user" + case .author(let name, let email): return "\(name.lowercased())|\(email.lowercased())" + } + } + + var rowTitle: String { + switch kind { + case .allUsers: + return Self.allUsersLabel.localizedTitle + case .currentUser: + return Self.currentUserLabel.localizedTitle + case .author(let name, _): + return name + } + } + + var rowDetail: String? { detail } + + var rowTitleKey: LocalizedStringKey? { + switch kind { + case .allUsers: return Self.allUsersLabel.titleKey + case .currentUser: return Self.currentUserLabel.titleKey + case .author: return nil + } + } + + var rowSystemImage: String { + switch kind { + case .allUsers: return "person.2" + case .currentUser: return "person.fill" + case .author: return "person" + } + } + + /// Whether this row survives the given query: pinned entries match their + /// label (English key and localized text), authors match name and email. + func matches(query: String) -> Bool { + switch kind { + case .allUsers: + return Self.allUsersLabel.matches(query) + case .currentUser: + return Self.currentUserLabel.matches(query) + case .author(let name, let email): + return query.isEmpty || + name.localizedCaseInsensitiveContains(query) || + email.localizedCaseInsensitiveContains(query) + } + } + + /// The author-filter selection this row applies, or `nil` to clear it. + var selection: GitLogAuthorSelection? { + switch kind { + case .allUsers: + return nil + case .currentUser: + return .currentUser + case .author(let name, let email): + return .author(name: name, email: email) + } + } + + /// Whether this row corresponds to the given selection; the reset entry + /// matches only when nothing is selected. + func matches(selected: GitLogAuthorSelection?) -> Bool { + switch kind { + case .allUsers: + return selected == nil + case .currentUser: + return selected == .currentUser + case .author(let name, let email): + return selected == .author(name: name, email: email) + } + } + + static let allUsers = GitLogAuthorFilterItem(kind: .allUsers, detail: nil) + static let currentUser = GitLogAuthorFilterItem(kind: .currentUser, detail: nil) + + static func author(name: String, email: String) -> GitLogAuthorFilterItem { + GitLogAuthorFilterItem(kind: .author(name: name, email: email), detail: email) + } +} + +/// A first-level group whose children open in the branch popover's flyout +/// column, mirroring IDEA's `origin/…` and `本地` submenu rows. Remote titles +/// are data-derived (`origin/…`) while `Local` and `Tags` are fixed labels. +struct GitLogBranchGroup: Identifiable { + let id: String + let title: String + let titleKey: LocalizedStringKey? + let systemImage: String + let children: [GitLogBranchFilterItem] +} + +/// The browse-mode content of the branch filter popover: a reset entry, +/// starred shortcuts, and one group per namespace whose children open in the +/// flyout column. +struct GitLogBranchMenu { + let reset: GitLogBranchFilterItem + let starred: [GitLogBranchFilterItem] + let groups: [GitLogBranchGroup] +} + +/// Pure builders for the Git Log filter popovers. Browse mode and search mode +/// follow the same per-row rules — full short names, upstream as detail, +/// starred shortcuts for the current branch and its upstream — so a branch +/// renders identically before and after the user types a query. +enum GitLogFilterList { + /// Builds the browse-mode branch menu: starred shortcuts for the current + /// branch and its upstream, then non-empty groups ordered Local, + /// remotes by name (`origin/…`), and Tags. + static func branchMenu(references: [GitReference]) -> GitLogBranchMenu { + var groups: [GitLogBranchGroup] = [] + + let locals = references.filter { $0.kind == .local } + if !locals.isEmpty { + groups.append(GitLogBranchGroup( + id: "local", + title: "Local", + titleKey: "Local", + systemImage: "arrow.triangle.branch", + children: sortedReferenceItems(locals) + )) + } + + let remoteReferences = references.filter { $0.kind == .remote } + let remotesByName = Dictionary(grouping: remoteReferences) { reference in + reference.shortName.split(separator: "/").first.map(String.init) ?? reference.shortName + } + let remoteNames = remotesByName.keys.sorted { + $0.localizedStandardCompare($1) == .orderedAscending + } + let remoteGroups: [GitLogBranchGroup] = remoteNames.map { remoteName in + GitLogBranchGroup( + id: "remote:\(remoteName)", + title: "\(remoteName)/…", + titleKey: nil, + systemImage: "cloud", + children: sortedReferenceItems(remotesByName[remoteName] ?? []) + ) + } + groups.append(contentsOf: remoteGroups) + + let tags = references.filter { $0.kind == .tag } + if !tags.isEmpty { + groups.append(GitLogBranchGroup( + id: "tags", + title: "Tags", + titleKey: "Tags", + systemImage: "tag", + children: sortedReferenceItems(tags) + )) + } + + return GitLogBranchMenu( + reset: .allBranches, + starred: starredItems(references: references), + groups: groups + ) + } + + /// Builds flat branch sections for the popover's type-to-search mode. The + /// pinned region carries the reset entry plus the starred shortcuts whose + /// titles match the query; the rest groups by local namespace, remote, + /// and tags. An empty query matches everything. + static func branchSections( + references: [GitReference], + query: String + ) -> [GitLogFilterSection] { + var sections: [GitLogFilterSection] = [] + + var pinned: [GitLogBranchFilterItem] = [] + if GitLogBranchFilterItem.allBranches.matches(query: query) { + pinned.append(.allBranches) + } + pinned.append(contentsOf: starredItems(references: references).filter { + $0.matches(query: query) + }) + if !pinned.isEmpty { + sections.append(GitLogFilterSection( + id: "pinned", + title: nil, + titleKey: nil, + systemImage: nil, + isPinned: true, + items: pinned + )) + } + + let matched = references.filter { reference in + query.isEmpty || + reference.shortName.localizedCaseInsensitiveContains(query) || + reference.upstreamShortName?.localizedCaseInsensitiveContains(query) == true + } + + let grouped = Dictionary(grouping: matched) { reference -> String in + switch reference.kind { + case .remote: return "Remote" + case .tag: return "Tags" + case .local: + let components = reference.shortName.split(separator: "/") + return components.count > 1 ? components.dropLast().joined(separator: "/") : "" + } + } + + let orderedGroups = grouped + .map { title, groupReferences -> (title: String, kind: GitReferenceKind, references: [GitReference]) in + (title, groupReferences[0].kind, groupReferences) + } + .sorted { lhs, rhs in + // Mirror BranchSwitcherPopover: ungrouped locals first, then + // by kind (local, remote, tag), then by group title. + if lhs.title.isEmpty != rhs.title.isEmpty { return lhs.title.isEmpty } + if lhs.kind != rhs.kind { return kindOrder(lhs.kind) < kindOrder(rhs.kind) } + return lhs.title.localizedStandardCompare(rhs.title) == .orderedAscending + } + + sections.append(contentsOf: orderedGroups.map { group in + GitLogFilterSection( + id: "\(group.kind.rawValue):\(group.title)", + title: group.title.isEmpty ? nil : group.title, + titleKey: fixedSectionTitleKey(group.title), + systemImage: groupSystemImage(group.kind), + isPinned: false, + items: sortedReferenceItems(group.references) + ) + }) + return sections + } + + /// Builds author sections: pinned "All Users" and "Me" reset entries + /// followed by the authors sorted by name. The query matches names and + /// emails case-insensitively; pinned entries only survive when the query + /// matches their titles. + static func authorSections( + authors: [GitLogAuthorOption], + query: String + ) -> [GitLogFilterSection] { + var pinned: [GitLogAuthorFilterItem] = [] + if GitLogAuthorFilterItem.allUsers.matches(query: query) { + pinned.append(.allUsers) + } + if GitLogAuthorFilterItem.currentUser.matches(query: query) { + pinned.append(.currentUser) + } + + let matched = authors.filter { author in + query.isEmpty || + author.name.localizedCaseInsensitiveContains(query) || + author.email.localizedCaseInsensitiveContains(query) + } + let sorted = matched.sorted { lhs, rhs in + let byName = lhs.name.localizedCaseInsensitiveCompare(rhs.name) + if byName != .orderedSame { return byName == .orderedAscending } + let byEmail = lhs.email.localizedCaseInsensitiveCompare(rhs.email) + if byEmail != .orderedSame { return byEmail == .orderedAscending } + return lhs.id < rhs.id + } + .map { author in + GitLogAuthorFilterItem.author(name: author.name, email: author.email) + } + + var sections: [GitLogFilterSection] = [] + if !pinned.isEmpty { + sections.append(GitLogFilterSection( + id: "pinned", + title: nil, + titleKey: nil, + systemImage: nil, + isPinned: true, + items: pinned + )) + } + if !sorted.isEmpty { + sections.append(GitLogFilterSection( + id: "authors", + title: nil, + titleKey: nil, + systemImage: nil, + isPinned: false, + items: sorted + )) + } + return sections + } + + /// Starred shortcuts for the checked-out branch and, when it exists as a + /// remote reference, its upstream. Shared by both modes so the shortcuts + /// never disappear while searching. + private static func starredItems(references: [GitReference]) -> [GitLogBranchFilterItem] { + guard let current = references.first(where: { $0.isCurrent }) else { return [] } + var items = [GitLogBranchFilterItem.starred(current)] + if let upstreamName = current.upstreamShortName, + let upstream = references.first(where: { $0.kind == .remote && $0.shortName == upstreamName }) { + items.append(.starred(upstream)) + } + return items + } + + /// Reference rows sort the current branch first, then by short name. + private static func sortedReferenceItems( + _ references: [GitReference] + ) -> [GitLogBranchFilterItem] { + references + .sorted { lhs, rhs in + if lhs.isCurrent != rhs.isCurrent { return lhs.isCurrent } + return lhs.shortName.localizedStandardCompare(rhs.shortName) == .orderedAscending + } + .map { GitLogBranchFilterItem.reference($0) } + } + + private static func fixedSectionTitleKey(_ title: String) -> LocalizedStringKey? { + switch title { + case "Remote": return "Remote" + case "Tags": return "Tags" + default: return nil + } + } + + private static func kindOrder(_ kind: GitReferenceKind) -> Int { + switch kind { + case .local: 0 + case .remote: 1 + case .tag: 2 + } + } + + private static func groupSystemImage(_ kind: GitReferenceKind) -> String { + switch kind { + case .local: return "folder" + case .remote: return "network" + case .tag: return "tag" + } + } +} + +/// Shared search field for Git Log filter popovers; focuses itself on appear +/// so typing filters immediately. +struct GitLogFilterSearchBar: View { + @Binding var text: String + let placeholder: LocalizedStringKey + let onSubmit: () -> Void + + @FocusState private var focused: Bool + + var body: some View { + HStack(spacing: 8) { + LitheSystemIcon(systemImage: "magnifyingglass") + .font(.system(size: 13)) + .foregroundStyle(LitheTheme.secondaryText) + TextField(placeholder, text: $text) + .textFieldStyle(.plain) + .font(.system(size: 13)) + .focused($focused) + .onSubmit(onSubmit) + if !text.isEmpty { + Button { + text = "" + } label: { + Image(systemName: "xmark.circle.fill") + } + .litheIconButton() + .help("Clear search") + } + } + .padding(.horizontal, 14) + .frame(height: 34) + .background(LitheTheme.toolHeader) + .onAppear { focused = true } + } +} + +/// Shared rendering for one selectable filter row. +struct GitLogFilterRowView: View { + let item: Row + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 8) { + Image(systemName: item.rowSystemImage) + .font(.system(size: 11.5)) + .foregroundStyle(item.rowIsStarred ? LitheTheme.warning : LitheTheme.secondaryText) + .frame(width: 17) + titleText + .font(.system(size: 12.5)) + .foregroundStyle(LitheTheme.primaryText) + .lineLimit(1) + Spacer(minLength: 10) + if let detail = item.rowDetail { + Text(verbatim: detail) + .font(.system(size: 11.5)) + .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(1) + } + if isSelected { + Image(systemName: "checkmark") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(LitheTheme.accent) + } + } + .padding(.horizontal, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: 28) + .background(isSelected ? LitheTheme.selection : .clear) + .clipShape(RoundedRectangle(cornerRadius: 5)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .lithePointer() + } + + @ViewBuilder + private var titleText: some View { + if let titleKey = item.rowTitleKey { + Text(titleKey) + } else { + Text(verbatim: item.rowTitle) + } + } +} + +/// Shared grouped-list body for flat filter popovers: section headers plus +/// row rendering with an empty-state fallback. A divider follows pinned +/// regions only, which the section flags explicitly. +struct GitLogFilterListView: View { + let sections: [GitLogFilterSection] + let emptyText: LocalizedStringKey + let isItemSelected: (Row) -> Bool + let onSelect: (Row) -> Void + + var body: some View { + if sections.allSatisfy({ $0.items.isEmpty }) { + Text(emptyText) + .font(LitheTheme.uiFont) + .foregroundStyle(LitheTheme.secondaryText) + .frame(maxWidth: .infinity, minHeight: 72) + } else { + ScrollView { + LazyVStack(alignment: .leading, spacing: 2) { + ForEach(Array(sections.enumerated()), id: \.element.id) { index, section in + if let title = sectionHeaderTitle(section) { + sectionHeader(title, systemImage: section.systemImage) + } + ForEach(section.items) { item in + GitLogFilterRowView( + item: item, + isSelected: isItemSelected(item) + ) { + onSelect(item) + } + } + if section.isPinned && index < sections.count - 1 { + Rectangle() + .fill(LitheTheme.divider) + .frame(height: 1) + .padding(.vertical, 2) + } + } + } + .padding(.horizontal, 8) + .padding(.bottom, 6) + } + } + } + + private func sectionHeaderTitle(_ section: GitLogFilterSection) -> Text? { + if let titleKey = section.titleKey { + return Text(titleKey) + } + if let title = section.title { + return Text(verbatim: title) + } + return nil + } + + private func sectionHeader(_ title: Text, systemImage: String?) -> some View { + HStack(spacing: 7) { + Image(systemName: "chevron.down") + .font(.system(size: 8, weight: .bold)) + if let systemImage { + Image(systemName: systemImage) + .font(.system(size: 11.5)) + } + title + .font(.system(size: 12, weight: .medium)) + Spacer() + } + .foregroundStyle(LitheTheme.secondaryText) + .padding(.horizontal, 14) + .frame(height: 24) + } +} + +/// The IDEA-style branch filter popover: a compact first level (reset entry, +/// starred shortcuts, group rows) whose group rows open a bounded flyout +/// column, plus a flat filtered list once the user types a query. The menu is +/// injected as data so body re-evaluations never rebuild it. +struct GitLogBranchFilterPopover: View { + let menu: GitLogBranchMenu + let querySections: (String) -> [GitLogFilterSection] + let isItemSelected: (GitLogBranchFilterItem) -> Bool + let onSelect: (GitLogBranchFilterItem) -> Void + + @State private var searchQuery = "" + @State private var expandedGroupID: String? + + var body: some View { + VStack(spacing: 0) { + GitLogFilterSearchBar( + text: $searchQuery, + placeholder: "Search branches", + onSubmit: selectFirstMatch + ) + Rectangle().fill(LitheTheme.divider).frame(height: 1) + content + } + .frame(width: popoverWidth) + .frame(maxHeight: 460) + .lithePopupChrome(cornerRadius: LitheTheme.Metrics.popupCornerRadius) + } + + // The popover opens at the compact width so the first frame has no dead + // space; each width change is driven by an explicit user action (opening + // a group or typing a query) rather than by layout surprises. + private var popoverWidth: CGFloat { + if !normalizedQuery.isEmpty { return 340 } + return expandedGroup == nil ? 224 : 560 + } + + private var normalizedQuery: String { + searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) + } + + @ViewBuilder + private var content: some View { + if normalizedQuery.isEmpty { + browseColumns + } else { + GitLogFilterListView( + sections: querySections(normalizedQuery), + emptyText: "No matching branches", + isItemSelected: isItemSelected, + onSelect: onSelect + ) + } + } + + private var browseColumns: some View { + HStack(alignment: .top, spacing: 0) { + levelOneColumn + .frame(width: 224) + if let group = expandedGroup { + Rectangle().fill(LitheTheme.divider).frame(width: 1) + flyoutColumn(group) + .frame(width: 335) + } + } + } + + private var expandedGroup: GitLogBranchGroup? { + guard let expandedGroupID else { return nil } + return menu.groups.first { $0.id == expandedGroupID } + } + + private var levelOneColumn: some View { + ScrollView { + VStack(alignment: .leading, spacing: 2) { + GitLogFilterRowView( + item: menu.reset, + isSelected: isItemSelected(menu.reset) + ) { + onSelect(menu.reset) + } + ForEach(menu.starred) { item in + GitLogFilterRowView( + item: item, + isSelected: isItemSelected(item) + ) { + onSelect(item) + } + } + if !menu.starred.isEmpty { + Rectangle() + .fill(LitheTheme.divider) + .frame(height: 1) + .padding(.vertical, 2) + } + ForEach(menu.groups) { group in + groupRow(group) + } + if menu.starred.isEmpty && menu.groups.isEmpty { + Text("No matching branches") + .font(LitheTheme.uiFont) + .foregroundStyle(LitheTheme.secondaryText) + .frame(maxWidth: .infinity, minHeight: 72) + } + } + .padding(6) + } + } + + private func groupRow(_ group: GitLogBranchGroup) -> some View { + let isExpanded = expandedGroupID == group.id + return Button { + expandedGroupID = isExpanded ? nil : group.id + } label: { + HStack(spacing: 8) { + Image(systemName: group.systemImage) + .font(.system(size: 11.5)) + .foregroundStyle(LitheTheme.secondaryText) + .frame(width: 17) + groupTitleText(group) + .font(.system(size: 12.5)) + .foregroundStyle(LitheTheme.primaryText) + .lineLimit(1) + Spacer(minLength: 10) + Image(systemName: "chevron.right") + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(LitheTheme.secondaryText) + } + .padding(.horizontal, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: 28) + .background(isExpanded ? LitheTheme.selection : .clear) + .clipShape(RoundedRectangle(cornerRadius: 5)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .lithePointer() + } + + @ViewBuilder + private func groupTitleText(_ group: GitLogBranchGroup) -> some View { + if let titleKey = group.titleKey { + Text(titleKey) + } else { + Text(verbatim: group.title) + } + } + + private func flyoutColumn(_ group: GitLogBranchGroup) -> some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 2) { + ForEach(group.children) { child in + GitLogFilterRowView( + item: child, + isSelected: isItemSelected(child) + ) { + onSelect(child) + } + } + } + .padding(6) + } + } + + private func selectFirstMatch() { + guard !normalizedQuery.isEmpty else { return } + guard let item = querySections(normalizedQuery).flatMap(\.items).first else { return } + onSelect(item) + } +} + +/// A bounded, searchable, grouped dropdown for flat filters such as the Git +/// Log author filter. The popover keeps a fixed width and caps its height so +/// large lists scroll instead of covering the workbench. +struct GitLogFilterPopover: View { + let sectionsForQuery: (String) -> [GitLogFilterSection] + let searchPlaceholder: LocalizedStringKey + let emptyText: LocalizedStringKey + let isItemSelected: (Row) -> Bool + let onSelect: (Row) -> Void + + @State private var searchQuery = "" + + var body: some View { + VStack(spacing: 0) { + GitLogFilterSearchBar( + text: $searchQuery, + placeholder: searchPlaceholder, + onSubmit: selectFirstMatch + ) + Rectangle().fill(LitheTheme.divider).frame(height: 1) + GitLogFilterListView( + sections: filteredSections, + emptyText: emptyText, + isItemSelected: isItemSelected, + onSelect: onSelect + ) + } + .frame(width: 340) + .frame(maxHeight: 460) + .lithePopupChrome(cornerRadius: LitheTheme.Metrics.popupCornerRadius) + } + + private var filteredSections: [GitLogFilterSection] { + sectionsForQuery(searchQuery.trimmingCharacters(in: .whitespacesAndNewlines)) + } + + private func selectFirstMatch() { + guard let item = filteredSections.flatMap(\.items).first else { return } + onSelect(item) + } +} diff --git a/macos/Sources/Lithe/Views/Git/GitLogView.swift b/macos/Sources/Lithe/Views/Git/GitLogView.swift index 22721d07..99ee19b7 100644 --- a/macos/Sources/Lithe/Views/Git/GitLogView.swift +++ b/macos/Sources/Lithe/Views/Git/GitLogView.swift @@ -22,7 +22,7 @@ struct GitLogView: View { @State private var pendingCommitOperation: GitCommitOperationRequest? @State private var pendingBranchOperation: GitBranchOperationRequest? @State private var comparisonSourceReference: GitReference? - @State private var showCommitDecorations = true + @State private var showCommitDecorations = false @State private var selectedGitToolTab = GitToolTab.log @State private var gitConsoleAutoScrolls = true @State private var gitConsoleWrapsLines = false @@ -31,12 +31,16 @@ struct GitLogView: View { @State private var gitLogPathFilter = "" @State private var gitLogPathDraft = "" @State private var showsGitLogPathPopover = false + @State private var gitCommitFileLoadTask: Task? + @State private var showsGitLogBranchFilterPopover = false + @State private var showsGitLogAuthorFilterPopover = false @State private var graphLayout = GitGraphLayout( rows: [], laneCount: 0, hasMissingParents: false ) @FocusState private var gitLogSearchFocused: Bool + @FocusState private var gitLogCommitListFocused: Bool /// IntelliJ's Git tool window uses the macOS system UI font throughout; /// only hashes and timestamps use a monospaced face. Keeping these values @@ -52,6 +56,7 @@ struct GitLogView: View { static let rowHeight: CGFloat = 38 static let treeRowHeight: CGFloat = 28 static let toolbarHeight: CGFloat = 38 + static let commitFileLoadDelay = Duration.milliseconds(120) static let darkConsoleText = Color(red: 0.76, green: 0.77, blue: 0.79) static let darkConsoleMetadata = Color(red: 0.69, green: 0.70, blue: 0.72) } @@ -178,6 +183,14 @@ struct GitLogView: View { guard model.gitConsoleEntries.last?.succeeded == false else { return } selectedGitToolTab = .console } + .onAppear { + if let commit = model.selectedGitCommit { + scheduleGitCommitFileLoad(for: commit) + } + } + .onDisappear { + gitCommitFileLoadTask?.cancel() + } .sheet(item: $branchDialogRequest) { request in GitBranchNameDialog(request: request) { name, checkout in Task { @@ -268,6 +281,12 @@ struct GitLogView: View { await model.mergeBranch(operation.reference) case .rebase: await model.rebaseCurrentBranch(onto: operation.reference) + case .checkoutAndRebase: + await model.checkoutAndRebase(operation.reference) + case .pullRebase: + await model.pullRemoteReference(operation.reference, strategy: .rebase) + case .pullMerge: + await model.pullRemoteReference(operation.reference, strategy: .merge) } } } @@ -341,9 +360,17 @@ struct GitLogView: View { .help("Git tool window actions") Spacer(minLength: 12) + + Button { + model.isGitLogVisible = false + } label: { + Image(systemName: "minus") + } + .litheIconButton() + .help("Hide Git tool window") } .padding(.leading, 12) - .padding(.trailing, 42) + .padding(.trailing, 7) .frame(height: 32) .background(model.workbenchBackgroundFeature.hasImage ? Color.clear : LitheTheme.toolHeader) .overlay(alignment: .bottom) { @@ -805,6 +832,12 @@ struct GitLogView: View { Task { await model.showComparisonWithWorkingTree(for: reference) } } + if let currentReference, currentReference.id != reference.id { + Button("Compare with Current Branch") { + Task { await model.showComparison(from: reference, to: currentReference) } + } + } + if let source = comparisonSourceReference, source.id != reference.id { Button("Compare '\(source.shortName)' with '\(reference.shortName)'") { comparisonSourceReference = nil @@ -816,39 +849,73 @@ struct GitLogView: View { } } - if reference.kind == .local { + if !reference.isCurrent { Divider() - if !reference.isCurrent { - Button("Checkout") { - Task { await model.checkoutReference(reference) } - } - .disabled(model.isPerformingBranchOperation) - } - - Button("Update") { - Task { await model.updateCurrentBranch(reference) } - } - .disabled(!reference.isCurrent || model.isPerformingBranchOperation) - - Button("Push…") { - pendingPushReference = reference + Button("Checkout") { + Task { await model.checkoutReference(reference) } } .disabled(model.isPerformingBranchOperation) - if !reference.isCurrent { + if reference.kind != .tag { + Button("Checkout and Rebase onto Current Branch") { + pendingBranchOperation = GitBranchOperationRequest( + kind: .checkoutAndRebase, + reference: reference + ) + } + .disabled(model.isPerformingBranchOperation) + Button("Merge into Current Branch") { pendingBranchOperation = GitBranchOperationRequest( kind: .merge, reference: reference ) } + .disabled(model.isPerformingBranchOperation) Button("Rebase Current Branch onto…") { pendingBranchOperation = GitBranchOperationRequest( kind: .rebase, reference: reference ) } + .disabled(model.isPerformingBranchOperation) + } + } + + if reference.kind == .remote { + Divider() + + Button("Pull with Rebase") { + pendingBranchOperation = GitBranchOperationRequest( + kind: .pullRebase, + reference: reference + ) + } + .disabled(model.isPerformingBranchOperation) + Button("Pull with Merge") { + pendingBranchOperation = GitBranchOperationRequest( + kind: .pullMerge, + reference: reference + ) + } + .disabled(model.isPerformingBranchOperation) + } + + if reference.kind == .local { + Divider() + + Button("Update") { + Task { await model.updateCurrentBranch(reference) } + } + .disabled(!reference.isCurrent || model.isPerformingBranchOperation) + + Button("Push…") { + pendingPushReference = reference + } + .disabled(model.isPerformingBranchOperation) + + if !reference.isCurrent { Button("Delete Branch", role: .destructive) { pendingBranchOperation = GitBranchOperationRequest( kind: .delete, @@ -972,11 +1039,22 @@ struct GitLogView: View { } } .litheScrollViewChrome(hideHorizontal: true) + .focusable() + .focused($gitLogCommitListFocused) + .gitLogFocusEffectHidden() + .onMoveCommand { direction in + switch direction { + case .up: + moveGitLogCommitSelection(by: -1) + case .down: + moveGitLogCommitSelection(by: 1) + default: + break + } + } .onChange(of: model.selectedGitCommit?.hash) { _ in guard let hash = model.selectedGitCommit?.hash else { return } - withAnimation(.easeOut(duration: 0.16)) { - proxy.scrollTo(hash, anchor: .center) - } + proxy.scrollTo(hash) } } } @@ -1047,12 +1125,38 @@ struct GitLogView: View { Rectangle().fill(LitheTheme.divider).frame(height: 1) - if model.selectedGitCommitFiles.isEmpty { - Text(model.selectedGitCommit == nil ? "Select a commit" : "No changed files") + switch model.selectedGitCommitFilesLoadState { + case .idle: + Text("Select a commit") .font(LitheTheme.uiFont) .foregroundStyle(LitheTheme.secondaryText) .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { + case .loading: + VStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Loading changed files…") + } + .font(LitheTheme.uiFont) + .foregroundStyle(LitheTheme.secondaryText) + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .failed: + VStack(spacing: 8) { + Text("Could not load changed files") + if let commit = model.selectedGitCommit { + Button("Retry") { + scheduleGitCommitFileLoad(for: commit) + } + } + } + .font(LitheTheme.uiFont) + .foregroundStyle(LitheTheme.secondaryText) + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .ready where model.selectedGitCommitFiles.isEmpty: + Text("No changed files") + .font(LitheTheme.uiFont) + .foregroundStyle(LitheTheme.secondaryText) + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .ready: GeometryReader { geometry in ScrollView(.vertical) { LazyVStack(alignment: .leading, spacing: 0) { @@ -1097,6 +1201,7 @@ struct GitLogView: View { Spacer(minLength: 0) } .padding(11) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .textSelection(.enabled) } else { Text("Commit details") @@ -1109,10 +1214,32 @@ struct GitLogView: View { } private var filteredCommits: [GitCommit] { - guard let hashes = model.gitLogMatchedCommitHashes else { return model.gitCommits } + guard let hashes = visibleCommitHashes else { return model.gitCommits } return model.gitCommits.filter { hashes.contains($0.hash) } } + private func moveGitLogCommitSelection(by offset: Int) { + guard let commit = GitLogCommitSelection.adjacentCommit( + in: filteredCommits, + selectedHash: model.selectedGitCommit?.hash, + offset: offset + ) else { return } + model.previewGitCommitSelection(commit) + scheduleGitCommitFileLoad(for: commit) + } + + private func scheduleGitCommitFileLoad(for commit: GitCommit) { + gitCommitFileLoadTask?.cancel() + gitCommitFileLoadTask = Task { [model] in + do { + try await Task.sleep(for: GitVisual.commitFileLoadDelay) + } catch { + return + } + await model.loadGitCommitFiles(for: commit) + } + } + private var checkoutReference: GitReference? { guard let reference = model.selectedGitReference, reference.kind == .local, @@ -1143,7 +1270,9 @@ struct GitLogView: View { let pendingOperation = $pendingCommitOperation return GitGraphRowActions( onSelect: { [model] commit in - Task { await model.selectGitCommit(commit) } + gitLogCommitListFocused = true + model.previewGitCommitSelection(commit) + scheduleGitCommitFileLoad(for: commit) }, onCherryPick: { commit in pendingOperation.wrappedValue = GitCommitOperationRequest(kind: .cherryPick, commit: commit) @@ -1200,33 +1329,34 @@ struct GitLogView: View { private var gitLogFilterBar: some View { HStack(spacing: 8) { HStack(spacing: 2) { - Menu { - Button { - Task { await model.selectGitReference(nil) } - } label: { - gitLogMenuItem("All Branches", selected: model.selectedGitReference == nil) - } - Divider() - ForEach(model.gitReferences) { reference in - Button { - Task { await model.selectGitReference(reference) } - } label: { - gitLogMenuItem( - reference.shortName, - selected: model.selectedGitReference?.id == reference.id, - systemImage: referenceIcon(reference) - ) - } - } + Button { + showsGitLogBranchFilterPopover = true } label: { gitLogFilterLabel( title: "Branch", selection: model.selectedGitReference?.shortName ) } - .menuStyle(.borderlessButton) - .fixedSize() + .buttonStyle(.plain) .lithePointer() + .popover(isPresented: $showsGitLogBranchFilterPopover, arrowEdge: .bottom) { + GitLogBranchFilterPopover( + menu: GitLogFilterList.branchMenu(references: model.gitReferences), + querySections: { query in + GitLogFilterList.branchSections( + references: model.gitReferences, + query: query + ) + }, + isItemSelected: { item in + item.matches(selected: model.selectedGitReference) + }, + onSelect: { item in + showsGitLogBranchFilterPopover = false + Task { await model.selectGitReference(item.reference) } + } + ) + } if model.selectedGitReference != nil { gitLogFilterClearButton(help: "Clear branch filter") { @@ -1236,34 +1366,32 @@ struct GitLogView: View { } HStack(spacing: 2) { - Menu { - Button { - selectedGitLogAuthor = nil - } label: { - gitLogMenuItem("All Users", selected: selectedGitLogAuthor == nil) - } - Button { - selectedGitLogAuthor = .currentUser - } label: { - gitLogMenuItem("Me", selected: selectedGitLogAuthor == .currentUser) - } - if !gitLogAuthorOptions.isEmpty { Divider() } - ForEach(gitLogAuthorOptions) { author in - Button { - selectedGitLogAuthor = .author(name: author.name, email: author.email) - } label: { - gitLogMenuItem( - author.name, - selected: selectedGitLogAuthor == .author(name: author.name, email: author.email) - ) - } - } + Button { + showsGitLogAuthorFilterPopover = true } label: { gitLogFilterLabel(title: "User", selection: selectedGitLogAuthor?.displayName) } - .menuStyle(.borderlessButton) - .fixedSize() + .buttonStyle(.plain) .lithePointer() + .popover(isPresented: $showsGitLogAuthorFilterPopover, arrowEdge: .bottom) { + GitLogFilterPopover( + sectionsForQuery: { query in + GitLogFilterList.authorSections( + authors: gitLogAuthorOptions, + query: query + ) + }, + searchPlaceholder: "Search users", + emptyText: "No matching users", + isItemSelected: { item in + item.matches(selected: selectedGitLogAuthor) + }, + onSelect: { item in + showsGitLogAuthorFilterPopover = false + selectedGitLogAuthor = item.selection + } + ) + } if selectedGitLogAuthor != nil { gitLogFilterClearButton(help: "Clear user filter") { @@ -1572,25 +1700,30 @@ struct GitLogView: View { } } -private enum GitLogAuthorSelection: Hashable { - case currentUser - case author(name: String, email: String) - - var displayName: String { - switch self { - case .currentUser: - return "Me" - case .author(let name, _): - return name +enum GitLogCommitSelection { + static func adjacentCommit( + in commits: [GitCommit], + selectedHash: String?, + offset: Int + ) -> GitCommit? { + guard !commits.isEmpty, offset == -1 || offset == 1 else { return nil } + guard let selectedHash, + let selectedIndex = commits.firstIndex(where: { $0.hash == selectedHash }) else { + return offset < 0 ? commits.last : commits.first } + let targetIndex = selectedIndex + offset + guard commits.indices.contains(targetIndex) else { return nil } + return commits[targetIndex] } +} - var exactAuthor: GitIdentity? { - switch self { - case .currentUser: - return nil - case .author(let name, let email): - return GitIdentity(name: name, email: email) +private extension View { + @ViewBuilder + func gitLogFocusEffectHidden() -> some View { + if #available(macOS 14.0, *) { + focusEffectDisabled() + } else { + self } } } @@ -1603,12 +1736,6 @@ private struct GitLogFilterTaskIdentity: Hashable { let commitHashes: [String] } -private struct GitLogAuthorOption: Identifiable { - let id: String - let name: String - let email: String -} - enum GitLogDatePreset: String, CaseIterable, Identifiable, Hashable { case anyTime case today @@ -1790,12 +1917,18 @@ private enum GitBranchOperationKind { case delete case merge case rebase + case checkoutAndRebase + case pullRebase + case pullMerge var title: String { switch self { case .delete: "Delete branch?" case .merge: "Merge branch?" case .rebase: "Rebase branch?" + case .checkoutAndRebase: "Checkout and rebase branch?" + case .pullRebase: "Pull remote branch with rebase?" + case .pullMerge: "Pull remote branch with merge?" } } @@ -1804,6 +1937,9 @@ private enum GitBranchOperationKind { case .delete: "Delete" case .merge: "Merge" case .rebase: "Rebase" + case .checkoutAndRebase: "Checkout and Rebase" + case .pullRebase: "Pull with Rebase" + case .pullMerge: "Pull with Merge" } } @@ -1815,6 +1951,12 @@ private enum GitBranchOperationKind { return "Merge \(reference.shortName) into the current branch. Conflicts may require terminal resolution." case .rebase: return "Replay the current branch onto \(reference.shortName). Conflicts may require terminal resolution." + case .checkoutAndRebase: + return "Checkout \(reference.shortName), then replay it onto the branch that is current now." + case .pullRebase: + return "Pull \(reference.shortName) into the current branch and replay local commits." + case .pullMerge: + return "Pull \(reference.shortName) into the current branch with a merge." } } } @@ -2068,72 +2210,219 @@ struct GitPullStrategyDialog: View { let request: GitPullStrategyRequest let onResolve: (GitPullStrategy) -> Void - var body: some View { - VStack(alignment: .leading, spacing: 16) { - VStack(alignment: .leading, spacing: 5) { - Text("Branches have diverged") - .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(LitheTheme.primaryText) - Text("Your branch and '\(request.upstream)' each have commits the other does not, so the changes cannot be fast-forwarded.") - .font(.system(size: 11.5)) - .foregroundStyle(LitheTheme.secondaryText) - .fixedSize(horizontal: false, vertical: true) - } + @State private var selectedStrategy: GitPullStrategy = .merge - HStack(spacing: 18) { - counter(value: request.ahead, caption: "local commit(s)") - counter(value: request.behind, caption: "upstream commit(s)") - Spacer(minLength: 0) - } + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Text("Update Project") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(LitheTheme.primaryText) + .padding(.bottom, 24) - Text("Merge joins both histories with a merge commit. Rebase replays your commits on top of the upstream, keeping history linear but rewriting your commit hashes.") + Text("Updating \(request.upstream) (\(request.behind) incoming, \(request.ahead) local)") .font(.system(size: 11.5)) .foregroundStyle(LitheTheme.secondaryText) - .fixedSize(horizontal: false, vertical: true) + .lineLimit(1) + .truncationMode(.middle) + .padding(.bottom, 14) + + VStack(alignment: .leading, spacing: 16) { + strategyRow( + .merge, + title: "Integrate incoming changes into current branch (M)" + ) + strategyRow( + .rebase, + title: "Rebase current branch onto incoming changes (R)" + ) + } if request.hasLocalChanges { Label( - "You have uncommitted changes. Rebase will refuse to start until they are committed or stashed.", + "Rebase requires a clean working tree. Commit or stash local changes before choosing Rebase.", systemImage: "exclamationmark.triangle.fill" ) .font(.system(size: 11)) .foregroundStyle(LitheTheme.warning) .fixedSize(horizontal: false, vertical: true) + .padding(.top, 14) } - HStack { - Spacer() - Button("Cancel") { dismiss() } - .keyboardShortcut(.cancelAction) - .lithePointer() - Button("Rebase") { resolve(.rebase) } - .lithePointer() - Button("Merge") { resolve(.merge) } - .buttonStyle(.borderedProminent) - .lithePointer() - .tint(LitheTheme.accent) - .keyboardShortcut(.defaultAction) + Spacer(minLength: 22) + + HStack(spacing: 10) { + Spacer(minLength: 16) + + Button("Cancel") { + dismiss() + } + .keyboardShortcut(.cancelAction) + .lithePointer() + + Button("OK") { + onResolve(selectedStrategy) + dismiss() + } + .buttonStyle(.borderedProminent) + .tint(LitheTheme.accent) + .keyboardShortcut(.defaultAction) + .lithePointer() } } .padding(20) - .frame(width: 460) + .frame(width: 560) + .frame(minHeight: 248) .background(LitheTheme.raised) } - private func counter(value: Int, caption: LocalizedStringKey) -> some View { - VStack(alignment: .leading, spacing: 1) { - Text("\(value)") - .font(.system(size: 17, weight: .semibold, design: .rounded)) - .foregroundStyle(LitheTheme.primaryText) - Text(caption) - .font(.system(size: 10.5)) - .foregroundStyle(LitheTheme.secondaryText) + private func strategyRow(_ strategy: GitPullStrategy, title: LocalizedStringKey) -> some View { + Button { + selectedStrategy = strategy + } label: { + HStack(spacing: 12) { + Image(systemName: selectedStrategy == strategy ? "largecircle.fill.circle" : "circle") + .font(.system(size: 22)) + .foregroundStyle(selectedStrategy == strategy ? LitheTheme.accent : LitheTheme.secondaryText) + Text(title) + .font(.system(size: 15)) + .foregroundStyle(LitheTheme.primaryText) + Spacer(minLength: 0) + } + .contentShape(Rectangle()) } + .buttonStyle(.plain) + .lithePointer() } +} - private func resolve(_ strategy: GitPullStrategy) { - onResolve(strategy) - dismiss() +/// A compact IDEA-style push review. The branch row is deliberately separate +/// from the action so the user can verify the destination before pushing. +struct GitPushDialog: View { + @Environment(\.dismiss) private var dismiss + let projectName: String + let reference: GitReference + let onPush: () -> Void + + var body: some View { + let presentation = GitPushDialogPresentation(reference: reference) + + VStack(spacing: 0) { + HStack { + Text("Push to \(projectName)") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(LitheTheme.primaryText) + Spacer() + } + .padding(.horizontal, 20) + .padding(.vertical, 14) + + Rectangle() + .fill(LitheTheme.divider) + .frame(height: 1) + + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 8) { + Image(systemName: "arrow.right") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(LitheTheme.primaryText) + Text(reference.shortName) + .font(.system(size: 13)) + .foregroundStyle(LitheTheme.primaryText) + Image(systemName: "arrow.right") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(LitheTheme.secondaryText) + Text(presentation.destination) + .font(.system(size: 13)) + .foregroundStyle(reference.upstreamShortName == nil ? LitheTheme.secondaryText : LitheTheme.accent) + .lineLimit(1) + .truncationMode(.middle) + } + .padding(.horizontal, 20) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: 38) + .background(LitheTheme.selection.opacity(0.72)) + + Spacer(minLength: 0) + } + .frame(width: 360) + .frame(maxHeight: .infinity, alignment: .topLeading) + .background(LitheTheme.sidebar) + + Rectangle() + .fill(LitheTheme.divider) + .frame(width: 1) + + VStack(spacing: 0) { + HStack(spacing: 12) { + Image(systemName: "arrow.left.arrow.right") + Image(systemName: "eye") + Image(systemName: "pencil") + Rectangle() + .fill(LitheTheme.divider) + .frame(width: 1, height: 20) + Image(systemName: "doc.text") + Spacer() + } + .font(.system(size: 13)) + .foregroundStyle(LitheTheme.secondaryText) + .padding(.horizontal, 18) + .frame(height: 48) + + Rectangle() + .fill(LitheTheme.divider) + .frame(height: 1) + + Spacer(minLength: 0) + Text("No commit selected") + .font(.system(size: 13)) + .foregroundStyle(LitheTheme.secondaryText) + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + Rectangle() + .fill(LitheTheme.divider) + .frame(height: 1) + + HStack(spacing: 12) { + Spacer(minLength: 16) + + Button("Cancel") { + dismiss() + } + .keyboardShortcut(.cancelAction) + .lithePointer() + + Button(presentation.actionTitle) { + onPush() + dismiss() + } + .buttonStyle(.borderedProminent) + .tint(LitheTheme.accent) + .keyboardShortcut(.defaultAction) + .lithePointer() + } + .padding(16) + } + .frame(width: 720, height: 430) + .background(LitheTheme.raised) + } +} + +struct GitPushDialogPresentation { + let destination: String + let actionTitle: String + + init(reference: GitReference) { + if let upstream = reference.upstreamShortName { + destination = "Tracking \(upstream)" + actionTitle = "Push" + } else { + destination = "Publish \(reference.shortName) (Core selects default remote)" + actionTitle = "Publish Branch" + } } } diff --git a/macos/Sources/Lithe/Views/Run/JavaRunConfigurationEditorView.swift b/macos/Sources/Lithe/Views/Run/JavaRunConfigurationEditorView.swift index c1b083d2..5e0b4c1b 100644 --- a/macos/Sources/Lithe/Views/Run/JavaRunConfigurationEditorView.swift +++ b/macos/Sources/Lithe/Views/Run/JavaRunConfigurationEditorView.swift @@ -40,8 +40,9 @@ struct RunConfigurationEditorView: View { if effectiveCapabilities.contains(.environment) { environmentSection } - if effectiveCapabilities.contains(.mavenProfiles) && !feature.mavenProfiles.isEmpty { - profilesSection + if effectiveCapabilities.contains(.mavenSkipTests) + || (effectiveCapabilities.contains(.mavenProfiles) && !feature.mavenProfiles.isEmpty) { + mavenOptionsSection } } .padding(18) @@ -270,23 +271,36 @@ struct RunConfigurationEditorView: View { } } - private var profilesSection: some View { - section(title: "Active Maven Profiles") { - ForEach(feature.mavenProfiles) { profile in - Toggle(isOn: profileBinding(for: profile)) { - HStack(spacing: 0) { - Text(profile.id) - .lineLimit(1) - Spacer(minLength: 0) + private var mavenOptionsSection: some View { + section(title: "Maven") { + if effectiveCapabilities.contains(.mavenSkipTests) { + Picker("Tests", selection: Binding( + get: { options.mavenSkipTests }, + set: { options.mavenSkipTests = $0 } + )) { + Text("Project default").tag(Bool?.none) + Text("Run tests").tag(Bool?.some(false)) + Text("Skip tests").tag(Bool?.some(true)) + } + .pickerStyle(.segmented) + } + if effectiveCapabilities.contains(.mavenProfiles) { + ForEach(feature.mavenProfiles) { profile in + Toggle(isOn: profileBinding(for: profile)) { + HStack(spacing: 0) { + Text(profile.id) + .lineLimit(1) + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) } + .toggleStyle(.checkbox) + .lithePointer() + .font(.system(size: 12)) + .foregroundStyle(LitheTheme.primaryText) .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) } - .toggleStyle(.checkbox) - .lithePointer() - .font(.system(size: 12)) - .foregroundStyle(LitheTheme.primaryText) - .frame(maxWidth: .infinity, alignment: .leading) } } } diff --git a/macos/Sources/Lithe/Views/Run/MavenView.swift b/macos/Sources/Lithe/Views/Run/MavenView.swift index d10770c1..a8d67376 100644 --- a/macos/Sources/Lithe/Views/Run/MavenView.swift +++ b/macos/Sources/Lithe/Views/Run/MavenView.swift @@ -4,17 +4,34 @@ struct MavenView: View { @EnvironmentObject private var model: AppModel @ObservedObject var feature: MavenFeatureModel @State private var selectedModuleID: String? - @State private var enabledProfiles: Set = [] + @State private var selectedPhase: MavenLifecyclePhase? @State private var expandedNodeIDs: Set = [] + @State private var isGoalSheetPresented = false + @State private var isSettingsSheetPresented = false + @State private var isAddProfilePresented = false + @State private var customGoal = "" + @State private var customProfile = "" + @State private var settingsPath = "" + @State private var mavenExecutablePath = "" + @State private var javaHomePath = "" var body: some View { VStack(spacing: 0) { toolWindowHeader + if let error = feature.configurationSaveError { + configurationErrorBanner(error) + } + if feature.isReloadRequired { + reloadBanner + } + if feature.isLoadingProject { ProgressView("Scanning Maven project...") .frame(maxWidth: .infinity, maxHeight: .infinity) .foregroundStyle(LitheTheme.secondaryText) + } else if case .failed(let message) = feature.projectState { + failedState(message) } else if let project = feature.project { HStack(spacing: 0) { projectPane(project) @@ -35,6 +52,12 @@ struct MavenView: View { .onChange(of: feature.project?.id) { _ in resetTreeState() } + .sheet(isPresented: $isGoalSheetPresented) { + goalSheet + } + .sheet(isPresented: $isSettingsSheetPresented) { + settingsSheet + } } private var toolWindowHeader: some View { @@ -52,6 +75,10 @@ struct MavenView: View { .font(.system(size: 11.5, weight: .medium)) .foregroundStyle(LitheTheme.secondaryText) .lineLimit(1) + } else if feature.taskState == .cancelled { + Label("Cancelled", systemImage: "stop.circle.fill") + .font(.system(size: 11.5, weight: .medium)) + .foregroundStyle(LitheTheme.warning) } else if let exitCode = feature.lastExitCode { Label( exitCode == 0 ? "Succeeded" : "Failed", @@ -61,6 +88,23 @@ struct MavenView: View { .foregroundStyle(exitCode == 0 ? LitheTheme.success : LitheTheme.error) } + Button(action: runSelected) { + LitheSystemIcon(systemImage: "play.fill") + } + .litheIconButton() + .disabled(selectedPhase == nil || feature.isRunning) + .help("Run selected Maven lifecycle phase") + + Button { + customGoal = "" + isGoalSheetPresented = true + } label: { + LitheSystemIcon(systemImage: "terminal") + } + .litheIconButton() + .disabled(feature.isRunning) + .help("Execute Maven goal") + Button(action: refreshProject) { LitheSystemIcon(systemImage: "arrow.clockwise") } @@ -76,6 +120,29 @@ struct MavenView: View { .help("Stop Maven task") } + Button { + feature.setSkipTests(!feature.skipTests) + } label: { + LitheSystemIcon(systemImage: feature.skipTests ? "checkmark.square.fill" : "square") + } + .litheIconButton() + .foregroundStyle(feature.skipTests ? LitheTheme.accent : LitheTheme.secondaryText) + .help("Skip tests") + + Button { + expandedNodeIDs.removeAll() + } label: { + LitheSystemIcon(systemImage: "rectangle.compress.vertical") + } + .litheIconButton() + .help("Collapse all") + + Button(action: presentSettings) { + LitheSystemIcon(systemImage: "slider.horizontal.3") + } + .litheIconButton() + .help("Maven settings") + Button(action: feature.clearOutput) { Image(systemName: "trash") } @@ -90,17 +157,58 @@ struct MavenView: View { Task { await feature.loadProject(at: workspaceURL, files: model.projectFiles) } } + private var reloadBanner: some View { + HStack(spacing: 8) { + Image(systemName: "arrow.triangle.2.circlepath") + .foregroundStyle(LitheTheme.warning) + Text("Maven configuration changed") + .font(.system(size: 11.5, weight: .medium)) + .foregroundStyle(LitheTheme.primaryText) + Spacer(minLength: 8) + Button("Reload JDT LS") { + model.restartLanguageServers() + feature.acknowledgeReload() + } + .buttonStyle(.borderless) + } + .padding(.horizontal, 10) + .frame(height: 32) + .background(LitheTheme.warning.opacity(0.1)) + .overlay(alignment: .bottom) { + Rectangle().fill(LitheTheme.divider).frame(height: 1) + } + } + + private func configurationErrorBanner(_ message: String) -> some View { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(LitheTheme.error) + Text(message) + .font(.system(size: 11.5)) + .foregroundStyle(LitheTheme.primaryText) + .lineLimit(2) + Spacer(minLength: 0) + } + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background(LitheTheme.error.opacity(0.08)) + .overlay(alignment: .bottom) { + Rectangle().fill(LitheTheme.divider).frame(height: 1) + } + } + private func projectPane(_ project: MavenProject) -> some View { ScrollView(.vertical) { VStack(alignment: .leading, spacing: 1) { - if !project.profiles.isEmpty { + if !feature.availableProfiles.isEmpty { treeNode( id: profilesNodeID, title: "Profiles", systemImage: "folder", onLabelAction: { toggleNode(profilesNodeID) } ) { - ForEach(project.profiles) { profile in + profileActions + ForEach(feature.availableProfiles) { profile in profileRow(profile) } } @@ -179,13 +287,52 @@ struct MavenView: View { .frame(height: 24) } + private var profileActions: some View { + HStack(spacing: 4) { + Button { + customProfile = "" + isAddProfilePresented = true + } label: { + Image(systemName: "plus") + .frame(width: 18, height: 20) + } + .buttonStyle(.plain) + .help("Add profile") + .popover(isPresented: $isAddProfilePresented, arrowEdge: .trailing) { + VStack(alignment: .leading, spacing: 10) { + Text("Add Maven Profile") + .font(.system(size: 13, weight: .semibold)) + TextField("Profile ID", text: $customProfile) + .textFieldStyle(.roundedBorder) + .frame(width: 220) + .onSubmit(addCustomProfile) + HStack { + Spacer() + Button("Cancel") { isAddProfilePresented = false } + Button("Add", action: addCustomProfile) + .keyboardShortcut(.defaultAction) + .disabled(customProfile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + .padding(14) + } + + Button(action: feature.restoreDefaultProfiles) { + Image(systemName: "arrow.uturn.backward") + .frame(width: 18, height: 20) + } + .buttonStyle(.plain) + .help("Restore default profiles") + Spacer(minLength: 0) + } + .foregroundStyle(LitheTheme.secondaryText) + .padding(.leading, 2) + } + private func lifecycleRow(_ phase: MavenLifecyclePhase, module: MavenModule?) -> some View { Button { - model.runMaven( - phase: phase, - module: module, - profiles: enabledProfiles - ) + selectedModuleID = module?.id + selectedPhase = phase } label: { HStack(spacing: 6) { Image(systemName: phase.systemImage) @@ -195,20 +342,31 @@ struct MavenView: View { Text(LocalizedStringKey(phase.title)) .lineLimit(1) Spacer(minLength: 0) - LitheSystemIcon(systemImage: "play.fill") - .font(.system(size: 8)) - .foregroundStyle(LitheTheme.accent) + if selectedModuleID == module?.id, selectedPhase == phase { + LitheSystemIcon(systemImage: "play.fill") + .font(.system(size: 8)) + .foregroundStyle(LitheTheme.accent) + } } .font(.system(size: 12)) .foregroundStyle(LitheTheme.primaryText) .padding(.horizontal, 2) .frame(maxWidth: .infinity, alignment: .leading) .frame(height: 24) + .background( + selectedModuleID == module?.id && selectedPhase == phase + ? LitheTheme.subtleSelection + : .clear + ) + .clipShape(RoundedRectangle(cornerRadius: 4)) .contentShape(Rectangle()) } .buttonStyle(.plain) .lithePointer() - .disabled(feature.isRunning) + .simultaneousGesture(TapGesture(count: 2).onEnded { + guard !feature.isRunning else { return } + feature.run(phase: phase, module: module) + }) } private func treeNode( @@ -367,19 +525,176 @@ struct MavenView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } + private func failedState(_ message: String) -> some View { + VStack(spacing: 12) { + Image(systemName: "xmark.octagon") + .font(.system(size: 28, weight: .light)) + .foregroundStyle(LitheTheme.error) + Text("Unable to load Maven project") + .font(.system(size: 14, weight: .semibold)) + Text(message) + .font(LitheTheme.uiFont) + .foregroundStyle(LitheTheme.secondaryText) + .multilineTextAlignment(.center) + .frame(maxWidth: 440) + Button("Retry", action: refreshProject) + } + .padding(20) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var goalSheet: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Execute Maven Goal") + .font(.system(size: 16, weight: .semibold)) + TextField("Goal", text: $customGoal, prompt: Text("spring-boot:run")) + .textFieldStyle(.roundedBorder) + .onSubmit(executeCustomGoal) + HStack { + Spacer() + Button("Cancel") { isGoalSheetPresented = false } + .keyboardShortcut(.cancelAction) + Button("Run", action: executeCustomGoal) + .keyboardShortcut(.defaultAction) + .disabled(customGoal.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + .padding(20) + .frame(width: 420) + } + + private var settingsSheet: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Maven Settings") + .font(.system(size: 16, weight: .semibold)) + + settingsPathRow( + title: "settings.xml", + value: $settingsPath, + choose: { + model.platformUI.chooseFile(title: "Choose Maven settings.xml", prompt: "Choose") + } + ) + settingsPathRow( + title: "Maven Home or Executable", + value: $mavenExecutablePath, + choose: { + model.platformUI.chooseDirectory(title: "Choose Maven Home", prompt: "Choose") + } + ) + settingsPathRow( + title: "Maven JDK", + value: $javaHomePath, + choose: { + model.platformUI.chooseDirectory(title: "Choose Maven JDK", prompt: "Choose") + } + ) + + if let error = feature.configurationSaveError { + Label(error, systemImage: "exclamationmark.triangle.fill") + .font(.system(size: 11.5)) + .foregroundStyle(LitheTheme.error) + } + + HStack { + Spacer() + Button("Cancel") { isSettingsSheetPresented = false } + .keyboardShortcut(.cancelAction) + Button("Save", action: saveSettings) + .keyboardShortcut(.defaultAction) + } + } + .padding(20) + .frame(width: 560) + } + + private func settingsPathRow( + title: String, + value: Binding, + choose: @escaping () -> URL? + ) -> some View { + VStack(alignment: .leading, spacing: 5) { + Text(title) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(LitheTheme.primaryText) + HStack(spacing: 6) { + TextField("Automatic", text: value) + .textFieldStyle(.roundedBorder) + Button { + value.wrappedValue = "" + } label: { + Image(systemName: "xmark") + } + .litheIconButton() + .help("Use automatic value") + Button { + if let url = choose() { + value.wrappedValue = url.standardizedFileURL.path + } + } label: { + Image(systemName: "folder") + } + .litheIconButton() + .help("Choose path") + } + } + } + private func profileBinding(for profile: MavenProfile) -> Binding { Binding( - get: { enabledProfiles.contains(profile.id) }, + get: { feature.selectedProfiles.contains(profile.id) }, set: { enabled in + var profiles = feature.selectedProfiles if enabled { - enabledProfiles.insert(profile.id) + profiles.insert(profile.id) } else { - enabledProfiles.remove(profile.id) + profiles.remove(profile.id) } + feature.setSelectedProfiles(profiles) } ) } + private func runSelected() { + guard let phase = selectedPhase, !feature.isRunning else { return } + feature.run(phase: phase, module: selectedModule) + } + + private func executeCustomGoal() { + let goal = customGoal.trimmingCharacters(in: .whitespacesAndNewlines) + guard !goal.isEmpty else { return } + isGoalSheetPresented = false + feature.runCustomGoal(goal, module: selectedModule) + } + + private func addCustomProfile() { + if feature.addCustomProfile(customProfile) { + customProfile = "" + isAddProfilePresented = false + } + } + + private func presentSettings() { + settingsPath = feature.settingsPath ?? "" + mavenExecutablePath = feature.mavenExecutablePath ?? "" + javaHomePath = feature.javaHomePath ?? "" + isSettingsSheetPresented = true + } + + private func saveSettings() { + feature.updateLocalConfiguration( + settingsPath: settingsPath, + mavenExecutablePath: mavenExecutablePath, + javaHomePath: javaHomePath + ) + isSettingsSheetPresented = false + } + + private var selectedModule: MavenModule? { + guard let selectedModuleID else { return nil } + return feature.project?.allModules.first(where: { $0.id == selectedModuleID }) + } + private var profilesNodeID: String { "profiles" } private func projectNodeID(_ project: MavenProject) -> String { @@ -408,10 +723,10 @@ struct MavenView: View { private func resetTreeState() { selectedModuleID = nil - enabledProfiles = Set(feature.project?.profiles.filter(\.isActiveByDefault).map(\.id) ?? []) + selectedPhase = .compile expandedNodeIDs = feature.project.map { project in var ids: Set = [projectNodeID(project)] - if !project.profiles.isEmpty { + if !feature.availableProfiles.isEmpty { ids.insert(profilesNodeID) } return ids diff --git a/macos/Sources/Lithe/Views/Run/RunView.swift b/macos/Sources/Lithe/Views/Run/RunView.swift index 87e1085c..953466c7 100644 --- a/macos/Sources/Lithe/Views/Run/RunView.swift +++ b/macos/Sources/Lithe/Views/Run/RunView.swift @@ -201,6 +201,12 @@ struct RunView: View { return (String(localized: "Project identification failed"), message, "xmark.octagon.fill") case .idle: return nil + case .projectNotReady: + return ( + String(localized: "Project is still loading"), + String(localized: "Wait for the workspace scan to finish, then identify the project again."), + "hourglass" + ) } } diff --git a/macos/Sources/Lithe/Views/Terminal/TerminalView.swift b/macos/Sources/Lithe/Views/Terminal/TerminalView.swift index 2f764a05..be8a5200 100644 --- a/macos/Sources/Lithe/Views/Terminal/TerminalView.swift +++ b/macos/Sources/Lithe/Views/Terminal/TerminalView.swift @@ -91,7 +91,7 @@ struct TerminalView: View { model.moveTerminalToEditor(session.id) } Button("Close Terminal") { - model.closeTerminalSession(session) + model.requestCloseTerminalSession(session) } } else { Button("No Terminal Sessions") {} @@ -164,7 +164,7 @@ struct TerminalView: View { } Button { - model.closeTerminalSession(session) + model.requestCloseTerminalSession(session) } label: { Image(systemName: "xmark") .font(.system(size: 9, weight: .semibold)) @@ -206,7 +206,7 @@ struct TerminalView: View { } Divider() Button("Close") { - model.closeTerminalSession(session) + model.requestCloseTerminalSession(session) } } .lithePointer() diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchStatusViews.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchStatusViews.swift index 1dc9a766..7c35c853 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchStatusViews.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchStatusViews.swift @@ -1,11 +1,22 @@ import SwiftUI +/// Status bar line:column indicator. Visually unchanged from the plain text +/// label; clicking opens the Go to Line dialog (a no-op without an active +/// document). struct EditorCaretPositionLabel: View { @ObservedObject var chrome: EditorChromeModel + let onShowGoToLine: () -> Void var body: some View { - Text(chrome.caret.map { "\($0.line + 1):\($0.utf16Column + 1)" } ?? "1:1") - .monospacedDigit() + Button { + onShowGoToLine() + } label: { + Text(chrome.caret.map { "\($0.line + 1):\($0.utf16Column + 1)" } ?? "1:1") + .monospacedDigit() + } + .buttonStyle(.plain) + .lithePointer() + .help("Go to Line…") } } diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift index 9ab26a87..9e3365bb 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift @@ -1214,7 +1214,7 @@ struct WorkbenchView: View { private var detailedStatusItems: some View { HStack(spacing: 14) { - EditorCaretPositionLabel(chrome: model.editorChrome) + EditorCaretPositionLabel(chrome: model.editorChrome) { model.showGoToLine() } Text("UTF-8") Text("\(settings.tabWidth) spaces") Button { @@ -1235,7 +1235,7 @@ struct WorkbenchView: View { private var compactStatusItems: some View { HStack(spacing: 10) { - EditorCaretPositionLabel(chrome: model.editorChrome) + EditorCaretPositionLabel(chrome: model.editorChrome) { model.showGoToLine() } MemoryUsageStatusView() FrameRateStatusView() gitStatus diff --git a/macos/Sources/Lithe/Views/Workspace/ProjectSwitcherPopover.swift b/macos/Sources/Lithe/Views/Workspace/ProjectSwitcherPopover.swift index 70209be1..7b57f63a 100644 --- a/macos/Sources/Lithe/Views/Workspace/ProjectSwitcherPopover.swift +++ b/macos/Sources/Lithe/Views/Workspace/ProjectSwitcherPopover.swift @@ -1,5 +1,10 @@ import SwiftUI +enum ProjectSwitcherLayoutMetrics { + static let width: CGFloat = 390 + static let maximumHeight: CGFloat = 520 +} + struct ProjectSwitcherPopover: View { @EnvironmentObject private var model: AppModel @EnvironmentObject private var projectSessions: ProjectSessionManager @@ -54,15 +59,15 @@ struct ProjectSwitcherPopover: View { } .padding(8) } - .frame(width: 390, height: 520) - .background(LitheTheme.popupBackground) + .frame(width: ProjectSwitcherLayoutMetrics.width) + .frame(maxHeight: ProjectSwitcherLayoutMetrics.maximumHeight) } private var divider: some View { Rectangle() .fill(LitheTheme.divider) .frame(height: 1) - .padding(.vertical, 10) + .padding(.vertical, 8) } private func sectionTitle(_ title: String) -> some View { @@ -86,7 +91,7 @@ struct ProjectSwitcherPopover: View { .foregroundStyle(LitheTheme.primaryText) .padding(.horizontal, 10) .frame(maxWidth: .infinity, alignment: .leading) - .frame(height: 32) + .frame(height: 30) .contentShape(Rectangle()) .litheRowHover(cornerRadius: 5) } @@ -173,7 +178,7 @@ struct ProjectSwitcherPopover: View { } } .padding(.horizontal, 10) - .frame(maxWidth: .infinity, minHeight: 48, alignment: .leading) + .frame(maxWidth: .infinity, minHeight: 46, alignment: .leading) .contentShape(Rectangle()) } diff --git a/macos/Sources/LitheCoreContracts/Execution/ExecutionContracts.swift b/macos/Sources/LitheCoreContracts/Execution/ExecutionContracts.swift index 1e6ff2db..2946504c 100644 --- a/macos/Sources/LitheCoreContracts/Execution/ExecutionContracts.swift +++ b/macos/Sources/LitheCoreContracts/Execution/ExecutionContracts.swift @@ -7,9 +7,11 @@ package struct RunOptions: Codable, Hashable, Sendable { package var mavenJavaHomePath = "" package var vmArguments = "" package var activeMavenProfiles: Set = [] + package var skipTests: Bool? private enum CodingKeys: String, CodingKey { case homePath, mavenExecutablePath, mavenJavaHomePath, vmArguments, activeMavenProfiles + case skipTests } package init( @@ -17,13 +19,15 @@ package struct RunOptions: Codable, Hashable, Sendable { mavenExecutablePath: String = "", mavenJavaHomePath: String = "", vmArguments: String = "", - activeMavenProfiles: Set = [] + activeMavenProfiles: Set = [], + skipTests: Bool? = nil ) { self.homePath = homePath self.mavenExecutablePath = mavenExecutablePath self.mavenJavaHomePath = mavenJavaHomePath self.vmArguments = vmArguments self.activeMavenProfiles = activeMavenProfiles + self.skipTests = skipTests } package init(from decoder: Decoder) throws { @@ -33,6 +37,7 @@ package struct RunOptions: Codable, Hashable, Sendable { mavenJavaHomePath = try container.decodeIfPresent(String.self, forKey: .mavenJavaHomePath) ?? "" vmArguments = try container.decodeIfPresent(String.self, forKey: .vmArguments) ?? "" activeMavenProfiles = try container.decodeIfPresent(Set.self, forKey: .activeMavenProfiles) ?? [] + skipTests = try container.decodeIfPresent(Bool.self, forKey: .skipTests) } } @@ -47,6 +52,7 @@ package struct RunOptions: Codable, Hashable, Sendable { vmArguments: String = "", programArguments: String = "", activeProfiles: Set = [], + mavenSkipTests: Bool? = nil, mavenExecutablePath: String = "", mavenJavaHomePath: String = "", environment: [String: String] = [:] @@ -59,7 +65,8 @@ package struct RunOptions: Codable, Hashable, Sendable { mavenExecutablePath: mavenExecutablePath, mavenJavaHomePath: mavenJavaHomePath, vmArguments: vmArguments, - activeMavenProfiles: activeProfiles + activeMavenProfiles: activeProfiles, + skipTests: mavenSkipTests ) } @@ -87,6 +94,10 @@ package struct RunOptions: Codable, Hashable, Sendable { get { java.activeMavenProfiles } set { java.activeMavenProfiles = newValue } } + package var mavenSkipTests: Bool? { + get { java.skipTests } + set { java.skipTests = newValue } + } private enum CodingKeys: String, CodingKey { case workingDirectoryPath, arguments, environment, java diff --git a/macos/Sources/LitheCoreContracts/Execution/MavenContracts.swift b/macos/Sources/LitheCoreContracts/Execution/MavenContracts.swift index b5f0e293..6072231b 100644 --- a/macos/Sources/LitheCoreContracts/Execution/MavenContracts.swift +++ b/macos/Sources/LitheCoreContracts/Execution/MavenContracts.swift @@ -139,13 +139,194 @@ package struct MavenBuildIssue: Identifiable, Hashable, Sendable { } } +package enum MavenProjectLoadState: Equatable, Sendable { + case idle + case loading + case ready + case failed(String) +} + +package enum MavenTaskState: Equatable, Sendable { + case idle + case running + case stopping + case cancelled + case failed(String) +} + +package struct MavenPortableConfiguration: Codable, Equatable, Sendable { + package static let currentVersion = 1 + + package var version: Int + package var selectedProfiles: [String] + package var customProfiles: [String] + package var skipTests: Bool + + package init( + version: Int = currentVersion, + selectedProfiles: [String] = [], + customProfiles: [String] = [], + skipTests: Bool = false + ) { + self.version = version + self.selectedProfiles = selectedProfiles + self.customProfiles = customProfiles + self.skipTests = skipTests + } +} + +package struct MavenLocalConfiguration: Codable, Equatable, Sendable { + package static let currentVersion = 1 + + package var version: Int + package var settingsPath: String? + package var mavenExecutablePath: String? + package var javaHomePath: String? + + package init( + version: Int = currentVersion, + settingsPath: String? = nil, + mavenExecutablePath: String? = nil, + javaHomePath: String? = nil + ) { + self.version = version + self.settingsPath = settingsPath + self.mavenExecutablePath = mavenExecutablePath + self.javaHomePath = javaHomePath + } +} + +package struct MavenStoredConfiguration: Equatable, Sendable { + package let portable: MavenPortableConfiguration? + package let local: MavenLocalConfiguration? + + package init( + portable: MavenPortableConfiguration?, + local: MavenLocalConfiguration? + ) { + self.portable = portable + self.local = local + } +} + +package struct MavenLaunchContext: Codable, Equatable, Sendable { + package static let currentVersion = 1 + + package let version: Int + package let reactorPath: String + package let profiles: [String] + package let settingsPath: String? + package let skipTests: Bool + package let mavenExecutablePath: String? + package let javaHomePath: String? + + package init( + version: Int = currentVersion, + reactorPath: String, + profiles: [String], + settingsPath: String?, + skipTests: Bool, + mavenExecutablePath: String?, + javaHomePath: String? + ) { + self.version = version + self.reactorPath = reactorPath + self.profiles = profiles + self.settingsPath = settingsPath + self.skipTests = skipTests + self.mavenExecutablePath = mavenExecutablePath + self.javaHomePath = javaHomePath + } +} + +package struct MavenLaunchPlan: Equatable, Sendable { + package let version: Int + package let toolchain: String + package let arguments: [String] + package let workingDirectory: String + package let configurationFingerprint: String + + package init( + version: Int, + toolchain: String, + arguments: [String], + workingDirectory: String, + configurationFingerprint: String + ) { + self.version = version + self.toolchain = toolchain + self.arguments = arguments + self.workingDirectory = workingDirectory + self.configurationFingerprint = configurationFingerprint + } +} + +package func redactedMavenArgumentsForDisplay(_ arguments: [String]) -> [String] { + var result: [String] = [] + var index = 0 + while index < arguments.count { + let argument = arguments[index] + if argument == "-s" || argument == "--settings" { + result.append(argument) + if arguments.indices.contains(index + 1) { + result.append("") + index += 2 + } else { + index += 1 + } + } else if argument.hasPrefix("--settings=") || argument.hasPrefix("-s=") { + result.append(String(argument.prefix { $0 != "=" }) + "=") + index += 1 + } else { + result.append(argument) + index += 1 + } + } + return result +} + +package struct MavenOperationError: LocalizedError, Equatable, Sendable { + package let code: String + package let message: String + package let details: String? + + package init(code: String, message: String, details: String? = nil) { + self.code = code + self.message = message + self.details = details + } + + package var errorDescription: String? { + guard let details, !details.isEmpty else { return message } + return message + ": " + details + } +} + package protocol MavenProjectOperations: Sendable { - func scanMavenProject(at rootURL: URL, files: [URL]) -> MavenProject? + func scanMavenProject(at rootURL: URL, files: [URL]) throws -> MavenProject? + func mavenLaunchPlan( + at rootURL: URL, + context: MavenLaunchContext, + module: String?, + goals: [String] + ) throws -> MavenLaunchPlan func mavenDiagnostics(output: String, projectRoot: URL) -> [MavenBuildIssue] } +package protocol MavenConfigurationStoring: Sendable { + func loadMavenConfiguration( + workspaceURL: URL, + reactorPath: String + ) throws -> MavenStoredConfiguration + func saveMavenConfiguration( + _ configuration: MavenStoredConfiguration, + workspaceURL: URL, + reactorPath: String + ) throws +} + @MainActor package protocol MavenRuntimePort: AnyObject { - func mavenExecutable(for project: MavenProject) -> URL? - func mavenProcessEnvironment() -> [String: String] + func mavenExecutable(for project: MavenProject, overridePath: String?) -> URL? + func mavenProcessEnvironment(javaHomePath: String?) -> [String: String] } diff --git a/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift b/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift index 06a6e030..34b20fa4 100644 --- a/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift +++ b/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift @@ -47,8 +47,28 @@ package struct ProjectRunConfigurationInspection: Equatable, Sendable { } } +package enum ProjectLoadState: Equatable, Sendable { + case idle + case loading(workspace: URL) + case bound(workspace: URL) + case ready(workspace: URL, snapshotID: UUID) + case failed(workspace: URL, message: String) + + package func isReady(for workspace: URL, snapshotID: UUID? = nil) -> Bool { + guard case .ready(let boundWorkspace, let boundSnapshotID) = self, + boundWorkspace == workspace.standardizedFileURL else { return false } + return snapshotID.map { $0 == boundSnapshotID } ?? true + } + + package func hasReadyInventory(for workspace: URL) -> Bool { + guard case .ready(let boundWorkspace, _) = self else { return false } + return boundWorkspace == workspace.standardizedFileURL + } +} + package enum RunConfigurationGenerationState: Equatable, Sendable { case idle + case projectNotReady case succeeded(entryCount: Int) case noEntries case failed(String) @@ -221,6 +241,14 @@ package protocol RunConfigurationOperations: Sendable { classPath: String?, debugPort: Int? ) throws -> SharedLaunchPlan + func launchPlan( + at projectURL: URL, + configurationID: String, + currentFile: String?, + classPath: String?, + debugPort: Int?, + mavenContext: MavenLaunchContext? + ) throws -> SharedLaunchPlan func saveEditorChanges( _ options: RunOptions, toolchain: ProjectToolchainSelection, @@ -233,6 +261,23 @@ package protocol RunConfigurationOperations: Sendable { } package extension RunConfigurationOperations { + func launchPlan( + at projectURL: URL, + configurationID: String, + currentFile: String?, + classPath: String?, + debugPort: Int?, + mavenContext _: MavenLaunchContext? + ) throws -> SharedLaunchPlan { + try launchPlan( + at: projectURL, + configurationID: configurationID, + currentFile: currentFile, + classPath: classPath, + debugPort: debugPort + ) + } + func saveEditorChanges( _: RunOptions, toolchain _: ProjectToolchainSelection, diff --git a/macos/Sources/LitheCoreContracts/Execution/RunModels.swift b/macos/Sources/LitheCoreContracts/Execution/RunModels.swift index b502b072..63f38c90 100644 --- a/macos/Sources/LitheCoreContracts/Execution/RunModels.swift +++ b/macos/Sources/LitheCoreContracts/Execution/RunModels.swift @@ -52,6 +52,7 @@ package struct RunConfigurationCapabilities: OptionSet, Hashable, Sendable { package static let javaVMArguments = Self(rawValue: 1 << 4) package static let mavenProfiles = Self(rawValue: 1 << 5) package static let jdwpDebug = Self(rawValue: 1 << 6) + package static let mavenSkipTests = Self(rawValue: 1 << 7) package static let process: Self = [.workingDirectory, .arguments, .environment] } @@ -148,7 +149,10 @@ package enum RunConfigurationKind: Hashable, Identifiable, Sendable { case .currentFile, .javaMain: return [.workingDirectory, .arguments, .environment, .javaRuntime, .javaVMArguments, .jdwpDebug] case .mavenModule, .mavenFramework: - return [.workingDirectory, .arguments, .environment, .javaRuntime, .javaVMArguments, .mavenProfiles, .jdwpDebug] + return [ + .workingDirectory, .arguments, .environment, .javaRuntime, + .javaVMArguments, .mavenProfiles, .mavenSkipTests, .jdwpDebug + ] case .process: return .process } diff --git a/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift b/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift index 2e3e7b2b..065983e1 100644 --- a/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift +++ b/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift @@ -213,6 +213,23 @@ package protocol LanguageServerRuntimeCore: Sendable { requestTimeout: TimeInterval, shutdownTimeout: TimeInterval ) -> Result + func startLanguageServer( + providerID: String, + executableURL: URL, + arguments: [String], + environment: [String: String], + rootURL: URL, + workingDirectoryURL: URL, + initializationOptions: ToolingJSONValue?, + runtimeExecutableURL: URL?, + jdtlsLaunchResources: JDTLSLaunchResources?, + cacheDirectoryURL: URL?, + workspaceFingerprint: String?, + mavenContext: MavenLaunchContext?, + initializeTimeout: TimeInterval, + requestTimeout: TimeInterval, + shutdownTimeout: TimeInterval + ) -> Result func stopLanguageServer(sessionID: String) func syncLanguageServerDocument( @@ -255,6 +272,43 @@ package protocol LanguageServerRuntimeCore: Sendable { func destroyLanguageServer(sessionID: String) } +package extension LanguageServerRuntimeCore { + func startLanguageServer( + providerID: String, + executableURL: URL, + arguments: [String], + environment: [String: String], + rootURL: URL, + workingDirectoryURL: URL, + initializationOptions: ToolingJSONValue?, + runtimeExecutableURL: URL?, + jdtlsLaunchResources: JDTLSLaunchResources?, + cacheDirectoryURL: URL?, + workspaceFingerprint: String?, + mavenContext _: MavenLaunchContext?, + initializeTimeout: TimeInterval, + requestTimeout: TimeInterval, + shutdownTimeout: TimeInterval + ) -> Result { + startLanguageServer( + providerID: providerID, + executableURL: executableURL, + arguments: arguments, + environment: environment, + rootURL: rootURL, + workingDirectoryURL: workingDirectoryURL, + initializationOptions: initializationOptions, + runtimeExecutableURL: runtimeExecutableURL, + jdtlsLaunchResources: jdtlsLaunchResources, + cacheDirectoryURL: cacheDirectoryURL, + workspaceFingerprint: workspaceFingerprint, + initializeTimeout: initializeTimeout, + requestTimeout: requestTimeout, + shutdownTimeout: shutdownTimeout + ) + } +} + package extension LanguageServerRuntimeCore { func notifyLanguageServerWorkspaceFilesChanged( sessionID _: String, diff --git a/macos/Sources/LitheCoreContracts/Language/LanguageToolingContracts.swift b/macos/Sources/LitheCoreContracts/Language/LanguageToolingContracts.swift index 2f9c8b4e..beb15769 100644 --- a/macos/Sources/LitheCoreContracts/Language/LanguageToolingContracts.swift +++ b/macos/Sources/LitheCoreContracts/Language/LanguageToolingContracts.swift @@ -541,6 +541,11 @@ package protocol LanguageServerSession: AnyObject { /// state directory so structural changes (add/remove module, edit root /// pom.xml) never reuse a stale project model. func start(rootURL: URL, workspaceFingerprint: String?) throws + func start( + rootURL: URL, + workspaceFingerprint: String?, + mavenContext: MavenLaunchContext? + ) throws func synchronize(fileURL: URL, text: String, languageID: String) throws func notifyWorkspaceFilesChanged(_ changes: [LanguageServerWorkspaceFileChange]) throws func closeDocument(_ fileURL: URL) @@ -613,7 +618,16 @@ package protocol LanguageServerSession: AnyObject { } package extension LanguageServerSession { - var javaTestRunnerURL: URL? { nil } + func start( + rootURL: URL, + workspaceFingerprint: String?, + mavenContext _: MavenLaunchContext? + ) throws { + try start(rootURL: rootURL, workspaceFingerprint: workspaceFingerprint) + } +} + +package extension LanguageServerSession { var features: LanguageServerFeatureSet { [] } var onFeaturesChange: ((LanguageServerFeatureSet) -> Void)? { get { nil } diff --git a/macos/Sources/LitheCoreContracts/Workspace/WorkspaceModels.swift b/macos/Sources/LitheCoreContracts/Workspace/WorkspaceModels.swift index 542bf7d4..913af2b2 100644 --- a/macos/Sources/LitheCoreContracts/Workspace/WorkspaceModels.swift +++ b/macos/Sources/LitheCoreContracts/Workspace/WorkspaceModels.swift @@ -36,9 +36,11 @@ package struct FileNode: Identifiable, Hashable, Sendable { } package struct WorkspaceSnapshot: Sendable { + package let id: UUID package let root: FileNode package let files: [URL] package init(root: FileNode, files: [URL]) { + self.id = UUID() self.root = root self.files = files } diff --git a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift index 88be0168..d76d7966 100644 --- a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift +++ b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift @@ -2385,7 +2385,9 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu _ values: [DebugVariable], parentPath: String, depth: Int, - to rows: inout [GenericDebugVariableRow] + to rows: inout [GenericDebugVariableRow], + ancestorVariableIDs: Set = [], + ancestorVariableReferences: Set = [] ) { for (index, variable) in values.enumerated() { let path = "\(parentPath)/\(index):\(variable.id)" @@ -2394,12 +2396,27 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu content: .variable(variable), depth: depth )) - if expandedVariableIDs.contains(variable.id) { + + // DAP variable containers can legally expose a parent object again + // (for example through `this`), so stop walking when an ancestor + // reference or identity repeats instead of recursing forever. + let repeatsAncestor = ancestorVariableIDs.contains(variable.id) + || (variable.variablesReference != 0 + && ancestorVariableReferences.contains(variable.variablesReference)) + if expandedVariableIDs.contains(variable.id), !repeatsAncestor { + var nextAncestorIDs = ancestorVariableIDs + nextAncestorIDs.insert(variable.id) + var nextAncestorReferences = ancestorVariableReferences + if variable.variablesReference != 0 { + nextAncestorReferences.insert(variable.variablesReference) + } appendVisibleVariables( variableChildren[variable.id] ?? [], parentPath: path, depth: depth + 1, - to: &rows + to: &rows, + ancestorVariableIDs: nextAncestorIDs, + ancestorVariableReferences: nextAncestorReferences ) appendVariableLoadMoreRow( parentVariableID: variable.id, diff --git a/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift b/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift index ab203a04..548431c6 100644 --- a/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift +++ b/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift @@ -18,19 +18,67 @@ package final class MavenFeatureModel: ObservableObject { } package var project: MavenProject? { service.project } + package var projectState: MavenProjectLoadState { service.projectState } + package var taskState: MavenTaskState { service.taskState } package var isLoadingProject: Bool { service.isLoadingProject } package var isRunning: Bool { service.isRunning } package var runningTitle: String? { service.runningTitle } package var output: String { service.output } package var issues: [MavenBuildIssue] { service.issues } package var lastExitCode: Int32? { service.lastExitCode } + package var availableProfiles: [MavenProfile] { service.availableProfiles } + package var selectedProfiles: Set { service.selectedProfiles } + package var skipTests: Bool { service.skipTests } + package var settingsPath: String? { service.settingsPath } + package var mavenExecutablePath: String? { service.mavenExecutablePath } + package var javaHomePath: String? { service.javaHomePath } + package var configurationSaveError: String? { service.configurationSaveError } + package var isReloadRequired: Bool { service.isReloadRequired } + package var launchContext: MavenLaunchContext? { service.launchContext } - package func loadProject(at workspaceURL: URL, files: [URL]) async { + package func loadProject(at workspaceURL: URL, files: [URL], snapshotID: UUID? = nil) async { await service.loadProject(at: workspaceURL, files: files) } - package func run(phase: MavenLifecyclePhase, module: MavenModule?, profiles: Set) { - service.run(phase: phase, module: module, profiles: profiles) + package func run(phase: MavenLifecyclePhase, module: MavenModule?) { + service.run(phase: phase, module: module) + } + + package func runCustomGoal(_ value: String, module: MavenModule?) { + service.runCustomGoal(value, module: module) + } + + package func setSelectedProfiles(_ profiles: Set) { + service.setSelectedProfiles(profiles) + } + + @discardableResult + package func addCustomProfile(_ value: String) -> Bool { + service.addCustomProfile(value) + } + + package func restoreDefaultProfiles() { + service.restoreDefaultProfiles() + } + + package func setSkipTests(_ enabled: Bool) { + service.setSkipTests(enabled) + } + + package func updateLocalConfiguration( + settingsPath: String?, + mavenExecutablePath: String?, + javaHomePath: String? + ) { + service.updateLocalConfiguration( + settingsPath: settingsPath, + mavenExecutablePath: mavenExecutablePath, + javaHomePath: javaHomePath + ) + } + + package func acknowledgeReload() { + service.acknowledgeReload() } package func reset() { service.reset() } @@ -86,6 +134,7 @@ package final class RunFeatureModel: ObservableObject { package var configurationStatus: ProjectRunConfigurationStatus { service.configurationStatus } package var configurationDiagnostics: [RunConfigurationDiagnostic] { service.configurationDiagnostics } package var generationState: RunConfigurationGenerationState { service.generationState } + package func reportGenerationProjectNotReady() { service.reportGenerationProjectNotReady() } package var recoveryAction: RunConfigurationRecoveryAction { service.recoveryAction } package var recoveryPath: String? { service.recoveryPath } package var configurationSaveError: String? { service.configurationSaveError } @@ -94,6 +143,8 @@ package final class RunFeatureModel: ObservableObject { service.blockingToolchainDiagnostic(for: service.selectedConfiguration) } package var sourceSearchRoots: [URL] { service.sourceSearchRoots } + package func isProjectReady(for workspace: URL, snapshotID: UUID?) -> Bool { service.isProjectReady(for: workspace, snapshotID: snapshotID) } + package func hasReadyInventory(for workspace: URL) -> Bool { service.hasReadyInventory(for: workspace) } package func options(for configuration: RunConfiguration) -> RunOptions { service.options(for: configuration) @@ -172,9 +223,10 @@ package final class RunFeatureModel: ObservableObject { package func loadProject( at workspaceURL: URL, files: [URL], - mavenProject: MavenProject? + mavenProject: MavenProject?, + snapshotID: UUID? = nil ) async { - await service.loadProject(at: workspaceURL, files: files, mavenProject: mavenProject) + await service.loadProject(at: workspaceURL, files: files, mavenProject: mavenProject, snapshotID: snapshotID) } package func generateRunConfigurations() async { @@ -218,7 +270,7 @@ package final class ProjectDevelopmentFeatureModel { self.runFeature = runFeature } - package func loadProject(at workspaceURL: URL, files: [URL]) async { + package func loadProject(at workspaceURL: URL, files: [URL], snapshotID: UUID? = nil) async { // Maven is one build-system Provider, not a workspace prerequisite. // Avoid scanning every project as Maven; non-Maven ecosystems should // reach the generic run pipeline without paying for Java discovery. @@ -233,7 +285,8 @@ package final class ProjectDevelopmentFeatureModel { await runFeature.loadProject( at: workspaceURL, files: files, - mavenProject: mavenFeature.project + mavenProject: mavenFeature.project, + snapshotID: snapshotID ) } } diff --git a/macos/Sources/LitheExecutionModule/Module/ExecutionFeatureGraph.swift b/macos/Sources/LitheExecutionModule/Module/ExecutionFeatureGraph.swift index 82bb9e1c..8b8ccc90 100644 --- a/macos/Sources/LitheExecutionModule/Module/ExecutionFeatureGraph.swift +++ b/macos/Sources/LitheExecutionModule/Module/ExecutionFeatureGraph.swift @@ -21,6 +21,9 @@ package final class ExecutionFeatureGraph: NSObject, ExecutionServiceGraph { mavenFeature = MavenFeatureModel(service: maven) runFeature = RunFeatureModel(service: run) projectDevelopment = ProjectDevelopmentFeatureModel(mavenFeature: mavenFeature, runFeature: runFeature) + run.configureMavenContextProvider { [weak maven] in + maven?.launchContext + } } package var isActive: Bool { maven.isRunning || run.isRunning || tests.isRunning } @@ -33,7 +36,12 @@ package final class ExecutionFeatureGraph: NSObject, ExecutionServiceGraph { } package func configureModuleLeases(acquire: @escaping @MainActor (String) -> ModuleLease) { - maven.$isRunning.removeDuplicates().sink { [weak self] active in + maven.$taskState.map { state in + switch state { + case .running, .stopping: true + case .idle, .cancelled, .failed: false + } + }.removeDuplicates().sink { [weak self] active in guard let self else { return } if active, mavenLease == nil { mavenLease = acquire("Maven build is running") } if !active { mavenLease?.release(); mavenLease = nil } diff --git a/macos/Sources/LitheExecutionModule/Services/MavenService.swift b/macos/Sources/LitheExecutionModule/Services/MavenService.swift index 136d8151..66a1ab54 100644 --- a/macos/Sources/LitheExecutionModule/Services/MavenService.swift +++ b/macos/Sources/LitheExecutionModule/Services/MavenService.swift @@ -5,35 +5,88 @@ import LitheCoreContracts @MainActor package final class MavenService: ObservableObject { @Published package private(set) var project: MavenProject? - @Published package private(set) var isLoadingProject = false - @Published package private(set) var isRunning = false + @Published package private(set) var projectState: MavenProjectLoadState = .idle + @Published package private(set) var taskState: MavenTaskState = .idle @Published package private(set) var runningTitle: String? @Published package private(set) var output = "" @Published package private(set) var issues: [MavenBuildIssue] = [] @Published package private(set) var lastExitCode: Int32? + @Published package private(set) var selectedProfiles: Set = [] + @Published package private(set) var customProfiles: [String] = [] + @Published package private(set) var skipTests = false + @Published package private(set) var settingsPath: String? + @Published package private(set) var mavenExecutablePath: String? + @Published package private(set) var javaHomePath: String? + @Published package private(set) var configurationSaveError: String? + @Published package private(set) var isReloadRequired = false + + package var isLoadingProject: Bool { + if case .loading = projectState { return true } + return false + } + + package var isRunning: Bool { + switch taskState { + case .running, .stopping: true + case .idle, .cancelled, .failed: false + } + } + + package var availableProfiles: [MavenProfile] { + var seen = Set() + let discovered = project?.profiles.filter { seen.insert($0.id).inserted } ?? [] + let custom = customProfiles.compactMap { id -> MavenProfile? in + guard seen.insert(id).inserted else { return nil } + return MavenProfile(id: id, isActiveByDefault: false) + } + return discovered + custom + } + + package var launchContext: MavenLaunchContext? { + guard let reactorPath else { return nil } + return MavenLaunchContext( + reactorPath: reactorPath, + profiles: selectedProfiles.sorted(), + settingsPath: settingsPath, + skipTests: skipTests, + mavenExecutablePath: mavenExecutablePath, + javaHomePath: javaHomePath + ) + } private let process: any StreamingProcess private let mavenOperations: any MavenProjectOperations - private var projectLoadID = UUID() - private let maximumOutputCharacters = 500_000 private let runtimeService: any MavenRuntimePort + private let configurationWriter: MavenConfigurationWriter + private var workspaceURL: URL? + private var reactorPath: String? + private var projectLoadID = UUID() + private var launchPlanID = UUID() private var activeOperationID: String? + private var configurationRevision = 0 + private var configurationFingerprint: String? + private var fingerprintRevision = 0 + private let maximumOutputCharacters = 500_000 package init( runtimeService: any MavenRuntimePort, process: any StreamingProcess, - mavenOperations: any MavenProjectOperations + mavenOperations: any MavenProjectOperations, + configurationStore: (any MavenConfigurationStoring)? = nil ) { self.runtimeService = runtimeService self.process = process self.mavenOperations = mavenOperations + configurationWriter = MavenConfigurationWriter(store: configurationStore) process.onOutput = { [weak self] chunk in Task { @MainActor [weak self] in + guard self?.activeOperationID != nil else { return } self?.append(chunk) } } process.onTermination = { [weak self] exitCode in Task { @MainActor [weak self] in + guard self?.activeOperationID != nil else { return } self?.finishProcess(exitCode: exitCode) } } @@ -47,112 +100,284 @@ package final class MavenService: ObservableObject { package func loadProject(at workspaceURL: URL, files: [URL]) async { let loadID = UUID() projectLoadID = loadID - isLoadingProject = true + projectState = .loading let rootURL = workspaceURL.standardizedFileURL let mavenOperations = mavenOperations - let scannedProject = await Task.detached(priority: .utility) { - mavenOperations.scanMavenProject(at: rootURL, files: files) + let configurationWriter = configurationWriter + let result = await Task.detached(priority: .utility) { + do { + let project = try mavenOperations.scanMavenProject(at: rootURL, files: files) + let reactorPath = project.map { Self.relativePath(from: rootURL, to: $0.rootURL) } + let stored: MavenStoredConfiguration? + if let reactorPath { + stored = try await configurationWriter.load( + workspaceURL: rootURL, + reactorPath: reactorPath + ) + } else { + stored = nil + } + return MavenProjectLoadResult( + project: project, + reactorPath: reactorPath, + stored: stored, + errorMessage: nil + ) + } catch { + return MavenProjectLoadResult( + project: nil, + reactorPath: nil, + stored: nil, + errorMessage: error.localizedDescription + ) + } }.value guard !Task.isCancelled, projectLoadID == loadID else { return } - project = scannedProject - isLoadingProject = false + + guard let errorMessage = result.errorMessage else { + self.workspaceURL = rootURL + reactorPath = result.reactorPath + project = result.project + applyStoredConfiguration(result.stored, project: result.project) + if let context = launchContext { + let fingerprint = await Task.detached(priority: .utility) { + try? mavenOperations.mavenLaunchPlan( + at: rootURL, + context: context, + module: nil, + goals: [MavenLifecyclePhase.validate.rawValue] + ).configurationFingerprint + }.value + guard !Task.isCancelled, projectLoadID == loadID else { return } + configurationFingerprint = fingerprint + } else { + configurationFingerprint = nil + } + projectState = .ready + configurationSaveError = nil + isReloadRequired = false + return + } + project = nil + self.workspaceURL = nil + reactorPath = nil + projectState = .failed(errorMessage) } - package func run( - phase: MavenLifecyclePhase, - module: MavenModule?, - profiles: Set - ) { - guard project != nil else { return } - stop() - resetOutput() - var arguments = baseArguments(profiles: profiles) - if let module { - arguments += ["-pl", module.relativePath, "-am"] + package func run(phase: MavenLifecyclePhase, module: MavenModule?) { + run(goals: [phase.rawValue], module: module, title: taskTitle(name: phase.title, module: module)) + } + + package func runCustomGoal(_ value: String, module: MavenModule?) { + let goals = value.split(whereSeparator: \.isWhitespace).map(String.init) + run(goals: goals, module: module, title: taskTitle(name: value, module: module)) + } + + package func setSelectedProfiles(_ profiles: Set) { + let knownProfiles = Set(availableProfiles.map(\.id)) + let normalized = Set(profiles.filter { knownProfiles.contains($0) }) + guard normalized != selectedProfiles else { return } + selectedProfiles = normalized + configurationDidChange() + } + + @discardableResult + package func addCustomProfile(_ value: String) -> Bool { + let profile = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard isValidProfile(profile) else { return false } + if !customProfiles.contains(profile), project?.profiles.contains(where: { $0.id == profile }) != true { + customProfiles.append(profile) + customProfiles.sort() } - arguments.append(phase.rawValue) - startProcess(arguments: arguments, title: taskTitle(phase: phase, module: module)) + selectedProfiles.insert(profile) + configurationDidChange() + return true + } + + package func restoreDefaultProfiles() { + let defaults = Set(project?.profiles.filter(\.isActiveByDefault).map(\.id) ?? []) + guard selectedProfiles != defaults else { return } + selectedProfiles = defaults + configurationDidChange() + } + + package func setSkipTests(_ enabled: Bool) { + guard skipTests != enabled else { return } + skipTests = enabled + configurationDidChange() + } + + package func updateLocalConfiguration( + settingsPath: String?, + mavenExecutablePath: String?, + javaHomePath: String? + ) { + let settings = normalizedLocalPath(settingsPath) + let executable = normalizedLocalPath(mavenExecutablePath) + let javaHome = normalizedLocalPath(javaHomePath) + guard settings != self.settingsPath + || executable != self.mavenExecutablePath + || javaHome != self.javaHomePath else { return } + self.settingsPath = settings + self.mavenExecutablePath = executable + self.javaHomePath = javaHome + configurationDidChange() + } + + package func acknowledgeReload() { + isReloadRequired = false + refreshConfigurationFingerprint(establishBaseline: true) } package func stop() { - process.stop() - isRunning = false - runningTitle = nil + launchPlanID = UUID() + guard isRunning else { return } + taskState = .stopping + if activeOperationID != nil { + process.stop() + } activeOperationID = nil + runningTitle = nil + lastExitCode = nil + taskState = .cancelled + if !output.isEmpty, !output.hasSuffix("\n") { + append("\n") + } + append("Maven task cancelled.\n") } package func reset() { stop() projectLoadID = UUID() + launchPlanID = UUID() project = nil - isLoadingProject = false + workspaceURL = nil + reactorPath = nil + projectState = .idle + taskState = .idle + runningTitle = nil output = "" issues = [] lastExitCode = nil + selectedProfiles = [] + customProfiles = [] + skipTests = false + settingsPath = nil + mavenExecutablePath = nil + javaHomePath = nil + configurationFingerprint = nil + fingerprintRevision += 1 + configurationSaveError = nil + isReloadRequired = false } package func clearOutput() { output = "" issues = [] lastExitCode = nil - } - - // MARK: - 进程执行 - - private func baseArguments(profiles: Set) -> [String] { - var arguments = ["-B", "-ntp"] - if !profiles.isEmpty { - arguments += ["-P", profiles.sorted().joined(separator: ",")] + if taskState == .cancelled { + taskState = .idle } - return arguments } - private func resetOutput() { - output = "" - issues = [] - lastExitCode = nil + private func run(goals: [String], module: MavenModule?, title: String) { + guard let project, let workspaceURL, let reactorPath else { return } + stop() + resetOutput() + let planID = UUID() + launchPlanID = planID + runningTitle = title + taskState = .running + let context = MavenLaunchContext( + reactorPath: reactorPath, + profiles: selectedProfiles.sorted(), + settingsPath: settingsPath, + skipTests: skipTests, + mavenExecutablePath: mavenExecutablePath, + javaHomePath: javaHomePath + ) + let operations = mavenOperations + Task { [weak self] in + let result = await Task.detached(priority: .userInitiated) { + do { + return MavenPlanResult( + plan: try operations.mavenLaunchPlan( + at: workspaceURL, + context: context, + module: module?.relativePath, + goals: goals + ), + errorMessage: nil + ) + } catch { + return MavenPlanResult( + plan: nil, + errorMessage: error.localizedDescription + ) + } + }.value + guard let self, self.launchPlanID == planID else { return } + if let plan = result.plan { + self.startProcess(plan: plan, project: project, context: context, title: title) + } else { + self.failTask(message: result.errorMessage ?? "Unable to create the Maven launch plan.") + } + } } - private func startProcess(arguments: [String], title: String) { - guard let project else { return } - guard let executable = runtimeService.mavenExecutable(for: project) else { - output = "No Maven executable was found. Edit the Maven service configuration.\n" - lastExitCode = 1 + private func startProcess( + plan: MavenLaunchPlan, + project: MavenProject, + context: MavenLaunchContext, + title: String + ) { + guard let workspaceURL else { return } + guard let executable = runtimeService.mavenExecutable( + for: project, + overridePath: context.mavenExecutablePath + ) else { + failTask(message: "No Maven executable was found. Choose Maven Home or an executable in Maven Settings.") return } - isRunning = true runningTitle = title - append("$ " + executable.lastPathComponent + " " + arguments.joined(separator: " ") + "\n\n") + taskState = .running + recordConfigurationFingerprint(plan.configurationFingerprint) + append( + "$ " + executable.lastPathComponent + " " + + redactedMavenArgumentsForDisplay(plan.arguments).joined(separator: " ") + "\n\n" + ) let operationID = UUID().uuidString activeOperationID = operationID + let workingDirectory = plan.workingDirectory == "." + ? workspaceURL + : workspaceURL.appendingPathComponent(plan.workingDirectory, isDirectory: true) do { try process.start(ProcessRequest( operationID: operationID, executablePath: executable.path, - arguments: arguments, - workingDirectory: project.rootURL.path, - environment: runtimeService.mavenProcessEnvironment() + arguments: plan.arguments, + workingDirectory: workingDirectory.standardizedFileURL.path, + environment: runtimeService.mavenProcessEnvironment(javaHomePath: context.javaHomePath) )) } catch { - append("Unable to start Maven: " + error.localizedDescription + "\n") - isRunning = false - runningTitle = nil - lastExitCode = 1 - issues = [MavenBuildIssue( - id: "start-error", - fileURL: nil, - line: nil, - column: nil, - severity: .error, - message: error.localizedDescription - )] + guard activeOperationID == operationID else { return } + activeOperationID = nil + failTask(message: "Unable to start Maven: " + error.localizedDescription) } } private func finishProcess(exitCode: Int32) { guard let project else { return } - isRunning = false + if taskState == .stopping { + activeOperationID = nil + runningTitle = nil + lastExitCode = nil + taskState = .cancelled + append("Maven task cancelled.\n") + return + } + taskState = exitCode == 0 ? .idle : .failed("Maven exited with code \(exitCode).") runningTitle = nil lastExitCode = exitCode issues = mavenOperations.mavenDiagnostics(output: output, projectRoot: project.rootURL) @@ -163,20 +388,123 @@ package final class MavenService: ObservableObject { guard event.operationID == activeOperationID else { return } switch event.state { case .starting, .running: - isRunning = true + taskState = .running case .stopping: - isRunning = false + taskState = .stopping case .failed: - isRunning = false - runningTitle = nil - if let message = event.message, !message.isEmpty { - append("Unable to run Maven: " + message + "\n") - } + activeOperationID = nil + failTask(message: event.message ?? "Unable to run Maven.") case .finished: - break + if let exitCode = event.exitCode { + finishProcess(exitCode: exitCode) + } + } + } + + private func failTask(message: String) { + append(message + (message.hasSuffix("\n") ? "" : "\n")) + taskState = .failed(message) + runningTitle = nil + lastExitCode = 1 + issues = [MavenBuildIssue( + id: "maven-error-" + UUID().uuidString, + fileURL: nil, + line: nil, + column: nil, + severity: .error, + message: message + )] + } + + private func applyStoredConfiguration( + _ stored: MavenStoredConfiguration?, + project: MavenProject? + ) { + let defaults = project?.profiles.filter(\.isActiveByDefault).map(\.id) ?? [] + let portable = stored?.portable + selectedProfiles = Set(portable?.selectedProfiles ?? defaults) + customProfiles = normalizedProfiles(portable?.customProfiles ?? []) + skipTests = portable?.skipTests ?? false + settingsPath = normalizedLocalPath(stored?.local?.settingsPath) + mavenExecutablePath = normalizedLocalPath(stored?.local?.mavenExecutablePath) + javaHomePath = normalizedLocalPath(stored?.local?.javaHomePath) + } + + private func configurationDidChange() { + isReloadRequired = configurationFingerprint != nil + configurationSaveError = nil + persistConfiguration() + refreshConfigurationFingerprint() + } + + private func refreshConfigurationFingerprint(establishBaseline: Bool = false) { + guard let workspaceURL, let context = launchContext else { return } + fingerprintRevision += 1 + let revision = fingerprintRevision + let operations = mavenOperations + Task { [weak self] in + let fingerprint = await Task.detached(priority: .utility) { + try? operations.mavenLaunchPlan( + at: workspaceURL, + context: context, + module: nil, + goals: [MavenLifecyclePhase.validate.rawValue] + ).configurationFingerprint + }.value + guard let self, self.fingerprintRevision == revision, let fingerprint else { return } + if establishBaseline || self.configurationFingerprint == nil { + self.configurationFingerprint = fingerprint + self.isReloadRequired = false + } else { + self.isReloadRequired = self.configurationFingerprint != fingerprint + } } } + private func recordConfigurationFingerprint(_ fingerprint: String) { + if let configurationFingerprint { + isReloadRequired = configurationFingerprint != fingerprint + } else { + configurationFingerprint = fingerprint + isReloadRequired = false + } + } + + private func persistConfiguration() { + guard let workspaceURL, let reactorPath else { return } + configurationRevision += 1 + let revision = configurationRevision + let stored = MavenStoredConfiguration( + portable: MavenPortableConfiguration( + selectedProfiles: selectedProfiles.sorted(), + customProfiles: normalizedProfiles(customProfiles), + skipTests: skipTests + ), + local: MavenLocalConfiguration( + settingsPath: settingsPath, + mavenExecutablePath: mavenExecutablePath, + javaHomePath: javaHomePath + ) + ) + let writer = configurationWriter + Task { [weak self] in + let errorMessage = await writer.save( + revision: revision, + configuration: stored, + workspaceURL: workspaceURL, + reactorPath: reactorPath + ) + guard let self, self.configurationRevision == revision else { return } + self.configurationSaveError = errorMessage + } + } + + private func resetOutput() { + output = "" + issues = [] + lastExitCode = nil + } + private func append(_ value: String) { output.append(value.replacingOccurrences(of: "\r", with: "")) if output.count > maximumOutputCharacters { @@ -184,9 +512,86 @@ package final class MavenService: ObservableObject { } } - private func taskTitle(phase: MavenLifecyclePhase, module: MavenModule?) -> String { + private func taskTitle(name: String, module: MavenModule?) -> String { let target = module?.displayName ?? project?.displayName ?? "Project" - return phase.title + " · " + target + return name + " · " + target + } + + private func isValidProfile(_ value: String) -> Bool { + !value.isEmpty + && !value.contains(",") + && !value.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) } + private func normalizedProfiles(_ values: [String]) -> [String] { + Array(Set(values.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter(isValidProfile))) + .sorted() + } + + private func normalizedLocalPath(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : (trimmed as NSString).expandingTildeInPath + } + + nonisolated private static func relativePath(from rootURL: URL, to childURL: URL) -> String { + let root = rootURL.standardizedFileURL.path + let child = childURL.standardizedFileURL.path + if root == child { return "." } + let prefix = root.hasSuffix("/") ? root : root + "/" + guard child.hasPrefix(prefix) else { return "." } + return String(child.dropFirst(prefix.count)) + } +} + +private struct MavenProjectLoadResult: Sendable { + let project: MavenProject? + let reactorPath: String? + let stored: MavenStoredConfiguration? + let errorMessage: String? +} + +private struct MavenPlanResult: Sendable { + let plan: MavenLaunchPlan? + let errorMessage: String? +} + +private actor MavenConfigurationWriter { + private let store: (any MavenConfigurationStoring)? + private var latestRevision = 0 + + init(store: (any MavenConfigurationStoring)?) { + self.store = store + } + + func load( + workspaceURL: URL, + reactorPath: String + ) throws -> MavenStoredConfiguration { + try store?.loadMavenConfiguration( + workspaceURL: workspaceURL, + reactorPath: reactorPath + ) ?? MavenStoredConfiguration(portable: nil, local: nil) + } + + func save( + revision: Int, + configuration: MavenStoredConfiguration, + workspaceURL: URL, + reactorPath: String + ) -> String? { + guard revision > latestRevision else { return nil } + latestRevision = revision + do { + try store?.saveMavenConfiguration( + configuration, + workspaceURL: workspaceURL, + reactorPath: reactorPath + ) + return nil + } catch { + return error.localizedDescription + } + } } diff --git a/macos/Sources/LitheExecutionModule/Services/RunService.swift b/macos/Sources/LitheExecutionModule/Services/RunService.swift index 4c9c1c42..879100d6 100644 --- a/macos/Sources/LitheExecutionModule/Services/RunService.swift +++ b/macos/Sources/LitheExecutionModule/Services/RunService.swift @@ -14,6 +14,7 @@ package final class RunService: ObservableObject { } } @Published package private(set) var isLoadingProject = false + @Published package private(set) var projectLoadState: ProjectLoadState = .idle @Published package private(set) var isRunning = false @Published package private(set) var runningTitle: String? @Published package private(set) var output = "" @@ -56,6 +57,7 @@ package final class RunService: ObservableObject { private let maximumOutputCharacters = 500_000 private let runtime: any RunRuntimePort private let executableResolver: any RunExecutableResolving + private var mavenContextProvider: @MainActor () -> MavenLaunchContext? = { nil } package init( runtime: any RunRuntimePort, @@ -105,6 +107,36 @@ package final class RunService: ObservableObject { package var lastRunFileURL: URL? { lastCurrentFileURL } package var lastConfiguration: RunConfiguration? { lastRunConfiguration } + /// Whether the file inventory for `workspace` came from its snapshot, and is + /// therefore complete enough to generate a configuration from. Entry points + /// that activate the execution module on demand use this to decide whether + /// the project still has to be loaded. + package func isProjectReady(for workspace: URL, snapshotID: UUID?) -> Bool { + projectLoadState.isReady(for: workspace, snapshotID: snapshotID) + } + + /// Whether a complete inventory for `workspace` is already loaded, even if a + /// newer snapshot has since been published. Entry points use this to tell a + /// superseded inventory apart from one that was never loaded. + package func hasReadyInventory(for workspace: URL) -> Bool { + projectLoadState.hasReadyInventory(for: workspace) + } + + /// Surfaces the "project still loading" generation notice without scanning. + /// + /// AppModel uses this when readiness cannot be established for the current + /// snapshot: the service may still hold an older `.ready` inventory, and + /// calling `generateRunConfigurations` would scan that stale list. + package func reportGenerationProjectNotReady() { + generationState = .projectNotReady + } + + package func configureMavenContextProvider( + _ provider: @escaping @MainActor () -> MavenLaunchContext? + ) { + mavenContextProvider = provider + } + @discardableResult package func registerLanguageRunExtension( _ provider: any LanguageRunExtensionProviding, @@ -132,14 +164,22 @@ package final class RunService: ObservableObject { return roots } + /// Loads run state for a workspace. + /// + /// `snapshotID` identifies the workspace snapshot `files` came from. Passing + /// `nil` means no snapshot has been applied yet, which binds the service so + /// existing configuration can be read while generation stays blocked. package func loadProject( at projectURL: URL, files: [URL], - mavenProject: MavenProject? + mavenProject: MavenProject?, + snapshotID: UUID? = nil ) async { let loadID = UUID() projectLoadID = loadID + let workspace = projectURL.standardizedFileURL isLoadingProject = true + projectLoadState = .loading(workspace: workspace) defer { if projectLoadID == loadID { isLoadingProject = false @@ -155,7 +195,14 @@ package final class RunService: ObservableObject { if let currentProject = self.projectURL { selectedConfigurationIDsByProject[currentProject.path] = selectedConfigurationID } - self.projectURL = projectURL.standardizedFileURL + self.projectURL = workspace + // Whether the existing configuration parses is `configurationStatus`, not + // this state. Keeping them apart is what lets a broken generated.json be + // regenerated: folding a parse failure in here would block generation, + // which is the only way to repair it. + projectLoadState = snapshotID + .map { .ready(workspace: workspace, snapshotID: $0) } + ?? .bound(workspace: workspace) self.mavenProject = mavenProject mavenProfiles = mavenProject?.profiles ?? [] self.projectFiles = files @@ -199,7 +246,14 @@ package final class RunService: ObservableObject { } package func generateRunConfigurations() async { - guard let projectURL else { return } + // Generation scans the file inventory this service holds, so a + // provisional inventory would write a configuration that omits entry + // points the workspace contains. Dropping the request silently is also + // indistinguishable from a broken button, so report the pending state. + guard let projectURL, case .ready = projectLoadState else { + generationState = .projectNotReady + return + } let loadID = projectLoadID isLoadingProject = true defer { @@ -413,7 +467,8 @@ package final class RunService: ObservableObject { lastExitCode = nil lastRunConfiguration = configuration lastCurrentFileURL = currentFileURL - let options = self.options(for: configuration) + let mavenContext = configuration.kind.isMavenBacked ? mavenContextProvider() : nil + let options = effectiveOptions(for: configuration, mavenContext: mavenContext) let usesGenericCurrentFile = configuration.kind == .currentFile && isGenericCurrentFile(currentFileURL) if !usesGenericCurrentFile { @@ -476,7 +531,8 @@ package final class RunService: ObservableObject { configurationID: configuration.id, currentFile: currentFile, classPath: planClassPath, - debugPort: nil + debugPort: nil, + mavenContext: mavenContext ) extensionSession = languageRunExtension( providerID: configuration.kind.providerID @@ -498,7 +554,13 @@ package final class RunService: ObservableObject { runningTitle = configuration.name isRunning = true - append("$ " + resolved.executableURL.lastPathComponent + " " + arguments.joined(separator: " ") + "\n\n") + let displayedArguments = configuration.kind.isMavenBacked + ? redactedMavenArgumentsForDisplay(arguments) + : arguments + append( + "$ " + resolved.executableURL.lastPathComponent + " " + + displayedArguments.joined(separator: " ") + "\n\n" + ) let operationID = UUID().uuidString activeOperationID = operationID @@ -590,6 +652,7 @@ package final class RunService: ObservableObject { stopAllServices() projectLoadID = UUID() projectURL = nil + projectLoadState = .idle selectedConfigurationIDsByProject = [:] projectFiles = [] mavenProject = nil @@ -910,7 +973,8 @@ package final class RunService: ObservableObject { )) return } - let options = self.options(for: configuration) + let mavenContext = configuration.kind.isMavenBacked ? mavenContextProvider() : nil + let options = effectiveOptions(for: configuration, mavenContext: mavenContext) let configuredJavaHome = (options.mavenJavaHomePath.isEmpty ? options.javaHomePath : options.mavenJavaHomePath).trimmingCharacters(in: .whitespacesAndNewlines) @@ -933,7 +997,8 @@ package final class RunService: ObservableObject { configurationID: configuration.id, currentFile: nil, classPath: nil, - debugPort: nil + debugPort: nil, + mavenContext: mavenContext ) } catch { moduleSessions.append(RunSession( @@ -970,7 +1035,10 @@ package final class RunService: ObservableObject { id: configuration.id, configurationID: configuration.id, title: configuration.name, - output: "$ " + resolved.executableURL.lastPathComponent + " " + arguments.joined(separator: " ") + "\n\n", + output: "$ " + resolved.executableURL.lastPathComponent + " " + + (configuration.kind.isMavenBacked + ? redactedMavenArgumentsForDisplay(arguments) + : arguments).joined(separator: " ") + "\n\n", isRunning: true, exitCode: nil ) @@ -1225,6 +1293,21 @@ package final class RunService: ObservableObject { return filePath.hasPrefix(directoryPath + "/") } + private func effectiveOptions( + for configuration: RunConfiguration, + mavenContext: MavenLaunchContext? + ) -> RunOptions { + var options = self.options(for: configuration) + guard let mavenContext else { return options } + if options.mavenExecutablePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + options.mavenExecutablePath = mavenContext.mavenExecutablePath ?? "" + } + if options.mavenJavaHomePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + options.mavenJavaHomePath = mavenContext.javaHomePath ?? "" + } + return options + } + private func resolvedWorkingDirectory(_ path: String, fallback: URL) -> URL { let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return fallback } diff --git a/macos/Sources/LitheGitModule/Application/GitCommitFilesLoader.swift b/macos/Sources/LitheGitModule/Application/GitCommitFilesLoader.swift new file mode 100644 index 00000000..3d03138f --- /dev/null +++ b/macos/Sources/LitheGitModule/Application/GitCommitFilesLoader.swift @@ -0,0 +1,450 @@ +import Foundation + +/// Describes the selected commit's changed-file loading lifecycle. +package enum GitCommitFilesLoadState: Equatable, Sendable { + case idle + case loading + case ready + case failed +} + +enum GitCommitFilesLoadOutcome: Equatable, Sendable { + case ready([GitCommitFile]) + case failed + case superseded +} + +/// Coordinates commit-file reads with bounded concurrency and prioritizes the +/// latest visible selection over filtering and speculative prefetch work. +@MainActor +final class GitCommitFilesLoader { + private enum RequestPurpose: Equatable { + case selected + case query + case prefetch + } + + private struct CacheKey: Hashable { + let repositoryRoot: URL + let commitHash: String + } + + private struct Waiter { + let id: UUID + let purpose: RequestPurpose + let continuation: AsyncStream.Continuation + } + + private struct Request { + let id: UUID + let key: CacheKey + let commit: GitCommit + let repositoryRoot: URL + let generation: UInt64 + var purpose: RequestPurpose + var waiters: [Waiter] + } + + private let service: GitService + private let cacheCapacity: Int + private let physicalLoadLimit: Int + private var cache: [CacheKey: [GitCommitFile]] = [:] + private var cacheRecency: [CacheKey] = [] + private var generation: UInt64 = 0 + private var activeRequests: [UUID: Request] = [:] + private var pendingSelectedRequest: Request? + private var pendingQueryRequests: [Request] = [] + private var pendingPrefetchRequests: [Request] = [] + + init( + service: GitService, + cacheCapacity: Int = 128, + physicalLoadLimit: Int = 2 + ) { + self.service = service + self.cacheCapacity = max(1, cacheCapacity) + self.physicalLoadLimit = max(1, physicalLoadLimit) + } + + var hasActiveWork: Bool { + !activeRequests.isEmpty + || pendingSelectedRequest != nil + || !pendingQueryRequests.isEmpty + || !pendingPrefetchRequests.isEmpty + } + + func cachedFiles(for commit: GitCommit, at repositoryRoot: URL) -> [GitCommitFile]? { + let key = makeKey(for: commit, at: repositoryRoot) + guard let files = cache[key] else { return nil } + touch(key) + return files + } + + func requestSelectedFiles( + for commit: GitCommit, + at repositoryRoot: URL + ) -> Task { + let key = makeKey(for: commit, at: repositoryRoot) + + // A superseded physical read may be synchronous and therefore unable to + // stop promptly. Its selected waiter is completed immediately, while the + // read may still populate only its cache entry. The newest selection can + // then use the second physical slot without waiting for stale work. + supersedeSelectedWaiters(unlessMatching: key) + pendingPrefetchRequests = [] + + if let files = cachedFiles(for: commit, at: repositoryRoot) { + return Task { .ready(files) } + } + return makeDemandTask( + purpose: .selected, + commit: commit, + repositoryRoot: repositoryRoot + ) + } + + func loadSelectedFiles( + for commit: GitCommit, + at repositoryRoot: URL + ) async -> GitCommitFilesLoadOutcome { + let task = requestSelectedFiles(for: commit, at: repositoryRoot) + return await withTaskCancellationHandler { + await task.value + } onCancel: { + task.cancel() + } + } + + func loadQueryFiles( + for commit: GitCommit, + at repositoryRoot: URL + ) async -> GitCommitFilesLoadOutcome { + if let files = cachedFiles(for: commit, at: repositoryRoot) { + return .ready(files) + } + let task = makeDemandTask( + purpose: .query, + commit: commit, + repositoryRoot: repositoryRoot + ) + return await withTaskCancellationHandler { + await task.value + } onCancel: { + task.cancel() + } + } + + func replacePrefetchCandidates( + _ commits: [GitCommit], + at repositoryRoot: URL + ) { + pendingPrefetchRequests = [] + var includedKeys: Set = [] + for commit in commits { + let key = makeKey(for: commit, at: repositoryRoot) + guard includedKeys.insert(key).inserted, + cache[key] == nil, + !containsPendingOrActiveRequest(for: key) else { + continue + } + pendingPrefetchRequests.append(Request( + id: UUID(), + key: key, + commit: commit, + repositoryRoot: repositoryRoot, + generation: generation, + purpose: .prefetch, + waiters: [] + )) + } + startPendingRequestsIfPossible() + } + + /// Invalidates queued work and cache entries. Reads already executing may + /// finish, but their generation prevents them from repopulating the cache. + func reset() { + generation &+= 1 + cache = [:] + cacheRecency = [] + resumeWaiters(in: pendingSelectedRequest, with: .superseded) + pendingSelectedRequest = nil + for request in pendingQueryRequests { + resumeWaiters(in: request, with: .superseded) + } + pendingQueryRequests = [] + pendingPrefetchRequests = [] + for requestID in Array(activeRequests.keys) { + guard var request = activeRequests[requestID] else { continue } + resumeWaiters(in: request, with: .superseded) + request.waiters = [] + request.purpose = .prefetch + activeRequests[requestID] = request + } + } + + private func makeDemandTask( + purpose: RequestPurpose, + commit: GitCommit, + repositoryRoot: URL + ) -> Task { + let waiterID = UUID() + var streamContinuation: AsyncStream.Continuation? + let stream = AsyncStream { continuation in + streamContinuation = continuation + } + guard let streamContinuation else { + return Task { .failed } + } + streamContinuation.onTermination = { @Sendable [weak self] _ in + Task { @MainActor [weak self] in + self?.cancelWaiter(waiterID) + } + } + let waiter = Waiter( + id: waiterID, + purpose: purpose, + continuation: streamContinuation + ) + enqueueDemand( + purpose: purpose, + commit: commit, + repositoryRoot: repositoryRoot, + waiter: waiter + ) + return Task { + var iterator = stream.makeAsyncIterator() + return await iterator.next() ?? .superseded + } + } + + private func enqueueDemand( + purpose: RequestPurpose, + commit: GitCommit, + repositoryRoot: URL, + waiter: Waiter + ) { + let key = makeKey(for: commit, at: repositoryRoot) + if let requestID = activeRequests.first(where: { + $0.value.key == key && $0.value.generation == generation + })?.key, var request = activeRequests[requestID] { + request.waiters.append(waiter) + if purpose == .selected { + request.purpose = .selected + } else if request.purpose == .prefetch { + request.purpose = .query + } + activeRequests[requestID] = request + return + } + if pendingSelectedRequest?.key == key { + pendingSelectedRequest?.waiters.append(waiter) + return + } + if let queryIndex = pendingQueryRequests.firstIndex(where: { $0.key == key }) { + var request = pendingQueryRequests.remove(at: queryIndex) + request.waiters.append(waiter) + if purpose == .selected { + supersedeSelectedWaiters(unlessMatching: key) + request.purpose = .selected + pendingSelectedRequest = request + } else { + pendingQueryRequests.insert(request, at: queryIndex) + } + startPendingRequestsIfPossible() + return + } + + if let prefetchIndex = pendingPrefetchRequests.firstIndex(where: { $0.key == key }) { + var request = pendingPrefetchRequests.remove(at: prefetchIndex) + request.purpose = purpose + request.waiters = [waiter] + if purpose == .selected { + supersedeSelectedWaiters(unlessMatching: key) + pendingSelectedRequest = request + } else { + pendingQueryRequests.append(request) + } + startPendingRequestsIfPossible() + return + } + + let request = Request( + id: UUID(), + key: key, + commit: commit, + repositoryRoot: repositoryRoot, + generation: generation, + purpose: purpose, + waiters: [waiter] + ) + if purpose == .selected { + supersedeSelectedWaiters(unlessMatching: key) + pendingSelectedRequest = request + } else { + pendingQueryRequests.append(request) + } + startPendingRequestsIfPossible() + } + + private func supersedeSelectedWaiters(unlessMatching key: CacheKey) { + if var request = pendingSelectedRequest, request.key != key { + pendingSelectedRequest = nil + let selectedWaiters = removeSelectedWaiters(from: &request) + resumeWaiters(selectedWaiters, with: .superseded) + if !request.waiters.isEmpty { + request.purpose = .query + enqueuePendingQueryRequest(request) + } + } + + for requestID in Array(activeRequests.keys) { + guard var request = activeRequests[requestID], request.key != key else { continue } + let selectedWaiters = removeSelectedWaiters(from: &request) + guard !selectedWaiters.isEmpty else { continue } + request.purpose = remainingPurpose(for: request) + activeRequests[requestID] = request + resumeWaiters(selectedWaiters, with: .superseded) + } + } + + private func removeSelectedWaiters(from request: inout Request) -> [Waiter] { + let selectedWaiters = request.waiters.filter { $0.purpose == .selected } + request.waiters.removeAll { $0.purpose == .selected } + return selectedWaiters + } + + private func remainingPurpose(for request: Request) -> RequestPurpose { + if request.waiters.contains(where: { $0.purpose == .selected }) { + return .selected + } + return request.waiters.contains(where: { $0.purpose == .query }) ? .query : .prefetch + } + + private func startPendingRequestsIfPossible() { + while activeRequests.count < physicalLoadLimit, let request = takeNextRequest() { + activeRequests[request.id] = request + let service = self.service + Task { @MainActor [weak self] in + let files = await service.files( + in: request.commit, + at: request.repositoryRoot + ) + self?.finishActiveRequest(requestID: request.id, files: files) + } + } + } + + private func takeNextRequest() -> Request? { + if let request = pendingSelectedRequest { + pendingSelectedRequest = nil + return request + } + if !pendingQueryRequests.isEmpty { + return pendingQueryRequests.removeFirst() + } + // One speculative read is enough to warm the cache while leaving room + // for a newly selected commit to start without waiting for prefetch. + if !pendingPrefetchRequests.isEmpty, + !activeRequests.values.contains(where: { $0.purpose == .prefetch }) { + return pendingPrefetchRequests.removeFirst() + } + return nil + } + + private func finishActiveRequest(requestID: UUID, files: [GitCommitFile]?) { + guard let request = activeRequests.removeValue(forKey: requestID) else { return } + if request.generation != generation { + resumeWaiters(in: request, with: .superseded) + } else if let files { + cache(files, for: request.key) + resumeWaiters(in: request, with: .ready(files)) + } else { + // Failures are deliberately not cached, so a retry can issue a new + // physical read instead of treating the failure as an empty commit. + resumeWaiters(in: request, with: .failed) + } + startPendingRequestsIfPossible() + } + + private func cancelWaiter(_ waiterID: UUID) { + for requestID in Array(activeRequests.keys) { + guard var request = activeRequests[requestID] else { continue } + request.waiters.removeAll { $0.id == waiterID } + request.purpose = remainingPurpose(for: request) + activeRequests[requestID] = request + } + if var request = pendingSelectedRequest { + request.waiters.removeAll { $0.id == waiterID } + pendingSelectedRequest = nil + if request.waiters.contains(where: { $0.purpose == .selected }) { + pendingSelectedRequest = request + } else if !request.waiters.isEmpty { + request.purpose = .query + enqueuePendingQueryRequest(request) + } + } + for index in pendingQueryRequests.indices.reversed() { + pendingQueryRequests[index].waiters.removeAll { $0.id == waiterID } + if pendingQueryRequests[index].waiters.isEmpty { + pendingQueryRequests.remove(at: index) + } + } + } + + private func containsPendingOrActiveRequest(for key: CacheKey) -> Bool { + activeRequests.values.contains(where: { + $0.key == key && $0.generation == generation + }) + || pendingSelectedRequest?.key == key + || pendingQueryRequests.contains(where: { $0.key == key }) + || pendingPrefetchRequests.contains(where: { $0.key == key }) + } + + private func resumeWaiters( + in request: Request?, + with outcome: GitCommitFilesLoadOutcome + ) { + guard let request else { return } + resumeWaiters(request.waiters, with: outcome) + } + + private func resumeWaiters( + _ waiters: [Waiter], + with outcome: GitCommitFilesLoadOutcome + ) { + for waiter in waiters { + waiter.continuation.yield(outcome) + waiter.continuation.finish() + } + } + + private func enqueuePendingQueryRequest(_ request: Request) { + if let index = pendingQueryRequests.firstIndex(where: { $0.key == request.key }) { + pendingQueryRequests[index].waiters.append(contentsOf: request.waiters) + } else { + pendingQueryRequests.append(request) + } + startPendingRequestsIfPossible() + } + + private func makeKey(for commit: GitCommit, at repositoryRoot: URL) -> CacheKey { + CacheKey( + repositoryRoot: repositoryRoot.standardizedFileURL, + commitHash: commit.hash + ) + } + + private func cache(_ files: [GitCommitFile], for key: CacheKey) { + cache[key] = files + touch(key) + while cacheRecency.count > cacheCapacity { + let evictedKey = cacheRecency.removeFirst() + cache.removeValue(forKey: evictedKey) + } + } + + private func touch(_ key: CacheKey) { + cacheRecency.removeAll { $0 == key } + cacheRecency.append(key) + } +} diff --git a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift index ebbf13f3..bc4bd629 100644 --- a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift +++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -42,12 +42,15 @@ package final class GitFeatureModel: ObservableObject { @Published package private(set) var gitBlameLines: [URL: [GitBlameLine]] = [:] @Published package private(set) var gitLineChangeMarkers: [URL: [GitLineChangeMarker]] = [:] @Published package private(set) var gitReferences: [GitReference] = [] + @Published package private(set) var recentGitReferences: [GitReference] = [] @Published package private(set) var gitCommits: [GitCommit] = [] @Published package private(set) var gitLogMatchedCommitHashes: Set? @Published package private(set) var isFilteringGitLog = false @Published package var selectedGitReference: GitReference? @Published package var selectedGitCommit: GitCommit? @Published package private(set) var selectedGitCommitFiles: [GitCommitFile] = [] + @Published package private(set) var selectedGitCommitFilesLoadState = + GitCommitFilesLoadState.idle @Published package var selectedGitCommitFile: GitCommitFile? @Published package var selectedGitCommitDiffContext: GitCommitDiffContext? @Published package private(set) var isLoadingGitHistory = false @@ -62,8 +65,10 @@ package final class GitFeatureModel: ObservableObject { @Published package private(set) var isCloningRepository = false private let service: GitService + private let commitFilesLoader: GitCommitFilesLoader private var gitIdentity: GitIdentity? private var commitPathsByHash: [String: Set] = [:] + private var selectedGitCommitFilesGeneration: UInt64 = 0 private var gitLogFilterGeneration = UUID() private let shelveService: ShelveService? private let snapshotProvider: @Sendable (URL) async -> GitSnapshot? @@ -90,6 +95,7 @@ package final class GitFeatureModel: ObservableObject { private var loadingLineChangeURLs: Set = [] private var lineChangeHunks: [URL: [String: DiffHunk]] = [:] + private static let commitFilesPrefetchRadius = 4 package init( service: GitService, @@ -100,6 +106,7 @@ package final class GitFeatureModel: ObservableObject { diffDocumentProvider: (@Sendable (GitChange, GitDiffWhitespaceMode) async -> DiffDocument)? = nil ) { self.service = service + commitFilesLoader = GitCommitFilesLoader(service: service) self.shelveService = shelveService self.snapshotProvider = snapshotProvider ?? { await service.snapshot(for: $0) } self.stashesProvider = stashesProvider ?? { await service.stashes(at: $0) } @@ -149,6 +156,7 @@ package final class GitFeatureModel: ObservableObject { || isPerformingBranchOperation || isCloningRepository || isResolvingGitOperation + || commitFilesLoader.hasActiveWork } package func reset() { @@ -184,11 +192,13 @@ package final class GitFeatureModel: ObservableObject { loadingLineChangeURLs = [] lineChangeHunks = [:] gitReferences = [] + recentGitReferences = [] gitCommits = [] gitIdentity = nil gitLogMatchedCommitHashes = nil isFilteringGitLog = false commitPathsByHash = [:] + clearGitCommitFilesCache() gitLogFilterGeneration = UUID() gitHistoryLimit = 300 isLoadingGitHistory = false @@ -201,6 +211,7 @@ package final class GitFeatureModel: ObservableObject { selectedGitReference = nil selectedGitCommit = nil selectedGitCommitFiles = [] + selectedGitCommitFilesLoadState = .idle selectedGitCommitFile = nil selectedGitCommitDiffContext = nil branchComparison = nil @@ -289,6 +300,7 @@ package final class GitFeatureModel: ObservableObject { guard !Task.isCancelled else { return } let changesChanged = gitChanges != snapshot.changes if gitRepositoryRoot != snapshot.repositoryRoot { + clearGitCommitFilesCache() gitRepositoryRoot = snapshot.repositoryRoot gitConsoleRepositoryGeneration &+= 1 isLoadingInitialGitConsoleEntry = false @@ -360,7 +372,11 @@ package final class GitFeatureModel: ObservableObject { didChange = true } } else { - if gitRepositoryRoot != nil { gitRepositoryRoot = nil; didChange = true } + if gitRepositoryRoot != nil { + clearGitCommitFilesCache() + gitRepositoryRoot = nil + didChange = true + } if currentBranch != "No Git" { currentBranch = "No Git"; didChange = true } if !gitChanges.isEmpty { gitChanges = []; didChange = true } if !gitStashes.isEmpty { gitStashes = []; didChange = true } @@ -1236,6 +1252,7 @@ package final class GitFeatureModel: ObservableObject { limit: gitHistoryLimit ) gitReferences = snapshot.references + recentGitReferences = snapshot.recentReferences gitCommits = snapshot.commits gitIdentity = snapshot.identity canLoadMoreGitHistory = snapshot.hasMore @@ -1246,12 +1263,15 @@ package final class GitFeatureModel: ObservableObject { if let nextCommit { if previousCommitHash == nextCommit.hash { selectedGitCommit = nextCommit + await loadGitCommitFiles(for: nextCommit) } else { await selectGitCommit(nextCommit) } } else { + selectedGitCommitFilesGeneration &+= 1 selectedGitCommit = nil selectedGitCommitFiles = [] + selectedGitCommitFilesLoadState = .idle selectedGitCommitFile = nil selectedGitCommitDiffContext = nil } @@ -1302,8 +1322,16 @@ package final class GitFeatureModel: ObservableObject { if let cached = commitPathsByHash[commit.hash] { paths = cached } else { - paths = Set(await service.files(in: commit, at: repositoryRoot).map(\.path)) + let outcome = await commitFilesLoader.loadQueryFiles( + for: commit, + at: repositoryRoot + ) guard gitLogFilterGeneration == generation else { return } + guard case .ready(let files) = outcome else { + isFilteringGitLog = false + return + } + paths = Set(files.map(\.path)) commitPathsByHash[commit.hash] = paths } if query.matchesPaths(paths) { pathMatched.append(commit) } @@ -1325,14 +1353,87 @@ package final class GitFeatureModel: ObservableObject { } package func selectGitCommit(_ commit: GitCommit) async { - guard let gitRepositoryRoot else { return } + previewGitCommitSelection(commit) + await loadGitCommitFiles(for: commit) + } + + package func previewGitCommitSelection(_ commit: GitCommit) { + selectedGitCommitFilesGeneration &+= 1 selectedGitCommit = commit + guard let gitRepositoryRoot else { + selectedGitCommitFiles = [] + selectedGitCommitFilesLoadState = .failed + selectedGitCommitFile = nil + selectedGitCommitDiffContext = nil + return + } + if let cachedFiles = commitFilesLoader.cachedFiles(for: commit, at: gitRepositoryRoot) { + selectedGitCommitFiles = cachedFiles + selectedGitCommitFilesLoadState = .ready + } else { + selectedGitCommitFiles = [] + selectedGitCommitFilesLoadState = .loading + } selectedGitCommitFile = nil selectedGitCommitDiffContext = nil - let files = await service.files(in: commit, at: gitRepositoryRoot) + } + + package func loadGitCommitFiles(for commit: GitCommit) async { guard selectedGitCommit?.hash == commit.hash else { return } - selectedGitCommitFiles = files - selectedGitCommitFile = files.first + guard let gitRepositoryRoot else { + selectedGitCommitFiles = [] + selectedGitCommitFilesLoadState = .failed + return + } + let generation = selectedGitCommitFilesGeneration + if let cachedFiles = commitFilesLoader.cachedFiles(for: commit, at: gitRepositoryRoot) { + selectedGitCommitFiles = cachedFiles + selectedGitCommitFilesLoadState = .ready + scheduleGitCommitFilesPrefetch(around: commit, at: gitRepositoryRoot) + return + } + selectedGitCommitFilesLoadState = .loading + let outcome = await commitFilesLoader.loadSelectedFiles( + for: commit, + at: gitRepositoryRoot + ) + guard selectedGitCommitFilesGeneration == generation, + selectedGitCommit?.hash == commit.hash, + self.gitRepositoryRoot?.standardizedFileURL + == gitRepositoryRoot.standardizedFileURL else { + return + } + switch outcome { + case .ready(let files): + selectedGitCommitFiles = files + selectedGitCommitFilesLoadState = .ready + scheduleGitCommitFilesPrefetch(around: commit, at: gitRepositoryRoot) + case .failed: + selectedGitCommitFiles = [] + selectedGitCommitFilesLoadState = .failed + case .superseded: + break + } + } + + private func scheduleGitCommitFilesPrefetch( + around commit: GitCommit, + at repositoryRoot: URL + ) { + let candidates = GitCommitFilesPrefetchPlan.candidates( + in: gitCommits, + centeredAt: commit.hash, + radius: Self.commitFilesPrefetchRadius + ) + commitFilesLoader.replacePrefetchCandidates(candidates, at: repositoryRoot) + } + + private func clearGitCommitFilesCache() { + commitPathsByHash = [:] + selectedGitCommitFilesGeneration &+= 1 + commitFilesLoader.reset() + selectedGitCommitFiles = [] + selectedGitCommitFilesLoadState = selectedGitCommit == nil ? .idle : .loading } package func showGitCommitDiff(for file: GitCommitFile) async { @@ -1629,6 +1730,92 @@ package final class GitFeatureModel: ObservableObject { await startIntegration(.reference(reference), operation: .rebase) } + package func checkoutAndRebase(_ reference: GitReference) async { + guard let gitRepositoryRoot, reference.kind != .tag, !reference.isCurrent else { return } + isPerformingBranchOperation = true + let result = await withGitOperation { + await service.checkoutAndRebase(reference, at: gitRepositoryRoot) + } + isPerformingBranchOperation = false + await reportBranchOperation( + result, + success: "Checked out \(reference.shortName) and rebased it onto the previous branch" + ) + } + + package func pullRemoteReference( + _ reference: GitReference, + strategy: GitPullStrategy + ) async { + guard let gitRepositoryRoot, reference.kind == .remote else { return } + isPerformingBranchOperation = true + let preflight = await service.integrationPreflight( + for: .reference(reference), + operation: strategy == .rebase ? .rebase : .merge, + at: gitRepositoryRoot + ) + if let preflight, !preflight.isClear { + switch selectedSaveChangesPolicy { + case .stash: + let message = "Lithe auto-stash before pull" + let stashed = await recordingGitCommand { + await service.stash(message: message, includeUntracked: true, at: gitRepositoryRoot) + } + guard stashed.succeeded else { + isPerformingBranchOperation = false + notify?(trimmedMessage(stashed)) + return + } + let result = await withGitOperation { + await service.pullRemoteReference(reference, strategy: strategy, at: gitRepositoryRoot) + } + isPerformingBranchOperation = false + await refreshGit() + if gitOperationState?.hasConflicts == true { + if let stash = gitStashes.first(where: { $0.message.contains(message) }) { + deferredSavedChanges = GitDeferredSavedChanges(stashReference: stash.reference, operationTitle: "pull") + } + notify?("拉取产生冲突,改动已保留在暂存中") + return + } + if let stash = gitStashes.first(where: { $0.message.contains(message) }) { + let restored = await service.popStash(stash, at: gitRepositoryRoot) + if !restored.succeeded { notify?("恢复本地改动失败:\(trimmedMessage(restored))") } + } + await reportBranchOperation(result, success: strategy == .rebase ? "从远程分支变基拉取完成" : "从远程分支合并拉取完成") + return + case .shelve: + let capture = await captureAndCleanShelf(message: "Lithe shelf before pull", at: gitRepositoryRoot) + guard case .saved(let shelf) = capture else { + isPerformingBranchOperation = false + if case .failed(let message) = capture { notify?(message) } + return + } + let result = await withGitOperation { + await service.pullRemoteReference(reference, strategy: strategy, at: gitRepositoryRoot) + } + isPerformingBranchOperation = false + await refreshGit() + if gitOperationState?.hasConflicts == true { + deferredSavedChanges = GitDeferredSavedChanges(shelfID: shelf.id, operationTitle: "pull") + notify?("拉取产生冲突,改动已保留在搁置中") + return + } + if !(await restoreShelf(shelf, at: gitRepositoryRoot)) { + notify?("恢复搁置改动失败") + } + await reportBranchOperation(result, success: strategy == .rebase ? "从远程分支变基拉取完成" : "从远程分支合并拉取完成") + return + } + } + let result = await withGitOperation { + await service.pullRemoteReference(reference, strategy: strategy, at: gitRepositoryRoot) + } + isPerformingBranchOperation = false + let verb = strategy == .rebase ? "Rebased from" : "Merged from" + await reportBranchOperation(result, success: "\(verb) \(reference.shortName)") + } + /// Checks whether uncommitted changes would stop the operation before running /// it, so the user gets a choice instead of Git's localized refusal. private func startIntegration( @@ -2118,3 +2305,29 @@ package final class GitFeatureModel: ObservableObject { return message.isEmpty ? "Git operation failed" : message } } + +package enum GitCommitFilesPrefetchPlan { + package static func candidates( + in commits: [GitCommit], + centeredAt commitHash: String, + radius: Int + ) -> [GitCommit] { + guard radius > 0, + let centerIndex = commits.firstIndex(where: { $0.hash == commitHash }) else { + return [] + } + + var candidates: [GitCommit] = [] + for distance in 1...radius { + let olderIndex = centerIndex + distance + if commits.indices.contains(olderIndex) { + candidates.append(commits[olderIndex]) + } + let newerIndex = centerIndex - distance + if commits.indices.contains(newerIndex) { + candidates.append(commits[newerIndex]) + } + } + return candidates + } +} diff --git a/macos/Sources/LitheGitModule/Models/GitModels.swift b/macos/Sources/LitheGitModule/Models/GitModels.swift index a418f4c4..236b2a4a 100644 --- a/macos/Sources/LitheGitModule/Models/GitModels.swift +++ b/macos/Sources/LitheGitModule/Models/GitModels.swift @@ -216,16 +216,19 @@ package struct GitBranchComparison: Identifiable, Sendable { package struct GitHistorySnapshot: Sendable { package let references: [GitReference] + package let recentReferences: [GitReference] package let commits: [GitCommit] package let hasMore: Bool package let identity: GitIdentity? package init( references: [GitReference], + recentReferences: [GitReference] = [], commits: [GitCommit], hasMore: Bool, identity: GitIdentity? = nil ) { self.references = references + self.recentReferences = recentReferences self.commits = commits self.hasMore = hasMore self.identity = identity diff --git a/macos/Sources/LitheGitModule/Services/GitService.swift b/macos/Sources/LitheGitModule/Services/GitService.swift index eb1ff3d7..2c5d23bc 100644 --- a/macos/Sources/LitheGitModule/Services/GitService.swift +++ b/macos/Sources/LitheGitModule/Services/GitService.swift @@ -73,7 +73,13 @@ package protocol GitOperations: Sendable { func deleteBranch(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? func mergeBranch(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? func rebaseCurrentBranch(onto reference: GitReference, at rootURL: URL) -> GitProcessResult? + func checkoutAndRebase(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? func updateCurrentBranch(at rootURL: URL, strategy: GitPullStrategy) -> GitProcessResult? + func pullRemoteReference( + _ reference: GitReference, + strategy: GitPullStrategy, + at rootURL: URL + ) -> GitProcessResult? func pullPreflight(at rootURL: URL) -> GitPullPreflightState? func conflictMarkerPaths(at rootURL: URL) -> [String] func integrationPreflight( @@ -335,8 +341,8 @@ package struct GitService: Sendable { } ?? GitHistorySnapshot(references: [], commits: [], hasMore: false) } - func files(in commit: GitCommit, at repositoryRoot: URL) async -> [GitCommitFile] { - await read(priority: .utility) { $0.files(in: commit, at: repositoryRoot) } ?? [] + func files(in commit: GitCommit, at repositoryRoot: URL) async -> [GitCommitFile]? { + await read(priority: .utility) { $0.files(in: commit, at: repositoryRoot) } } func diffDocument( @@ -507,6 +513,10 @@ package struct GitService: Sendable { await command(at: repositoryRoot) { $0.rebaseCurrentBranch(onto: reference, at: repositoryRoot) } } + func checkoutAndRebase(_ reference: GitReference, at repositoryRoot: URL) async -> CommandResult { + await command(at: repositoryRoot) { $0.checkoutAndRebase(reference, at: repositoryRoot) } + } + func updateCurrentBranch( at repositoryRoot: URL, strategy: GitPullStrategy = .ffOnly @@ -514,6 +524,16 @@ package struct GitService: Sendable { await command(at: repositoryRoot) { $0.updateCurrentBranch(at: repositoryRoot, strategy: strategy) } } + func pullRemoteReference( + _ reference: GitReference, + strategy: GitPullStrategy, + at repositoryRoot: URL + ) async -> CommandResult { + await command(at: repositoryRoot) { + $0.pullRemoteReference(reference, strategy: strategy, at: repositoryRoot) + } + } + func pullPreflight(at repositoryRoot: URL) async -> GitPullPreflightState? { await read { $0.pullPreflight(at: repositoryRoot) } } diff --git a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift index ad1445e5..43952491 100644 --- a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift +++ b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift @@ -92,6 +92,18 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { } package func start(rootURL: URL, workspaceFingerprint: String?) throws { + try start( + rootURL: rootURL, + workspaceFingerprint: workspaceFingerprint, + mavenContext: nil + ) + } + + package func start( + rootURL: URL, + workspaceFingerprint: String?, + mavenContext: MavenLaunchContext? + ) throws { guard sessionID == nil else { return } let normalizedRoot = rootURL.standardizedFileURL transition(to: .startingProcess) @@ -113,6 +125,7 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { jdtlsLaunchResources: jdtlsLaunchResources, cacheDirectoryURL: cacheDirectoryURL, workspaceFingerprint: workspaceFingerprint, + mavenContext: mavenContext, initializeTimeout: initializeTimeout, requestTimeout: requestTimeout, shutdownTimeout: shutdownTimeout diff --git a/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift b/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift index 261861c6..52890cca 100644 --- a/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift +++ b/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift @@ -60,6 +60,7 @@ package final class LanguageToolingSessionManager: ObservableObject, private let workspaceFingerprintProvider: (LanguageProviderDescriptor, URL) throws -> String? private let workspaceStateResetter: ((LanguageProviderDescriptor, URL, String?) throws -> Void)? private let workspaceStateCleaner: ((LanguageProviderDescriptor, URL, String?) throws -> Int)? + private var mavenContextProvider: (LanguageProviderDescriptor, URL) -> MavenLaunchContext? = { _, _ in nil } package init( catalog: LanguageProviderCatalog = .compatibilityFallback, @@ -91,6 +92,13 @@ package final class LanguageToolingSessionManager: ObservableObject, return providerID }) } + + package func configureMavenContextProvider( + _ provider: @escaping (LanguageProviderDescriptor, URL) -> MavenLaunchContext? + ) { + mavenContextProvider = provider + } + package func updateCatalog(_ catalog: LanguageProviderCatalog) { let previousDescriptors = Dictionary( uniqueKeysWithValues: self.catalog.descriptors.map { ($0.id, $0) } @@ -1310,7 +1318,8 @@ package final class LanguageToolingSessionManager: ObservableObject, do { try created.start( rootURL: rootURL, - workspaceFingerprint: workspaceFingerprint + workspaceFingerprint: workspaceFingerprint, + mavenContext: mavenContextProvider(descriptor, rootURL) ) } catch { if languageServerSessionIdentities[descriptor.id] == sessionIdentity { diff --git a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift index 95783ec0..62c9f064 100644 --- a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift +++ b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift @@ -11,6 +11,8 @@ package enum WorkspaceRebuildResult: Sendable { /// Owns the workspace snapshot and delegates scanning and text reads to Core. @MainActor package final class WorkspaceFeatureModel: ObservableObject { + package private(set) var workspaceGeneration = 0 + package private(set) var appliedSnapshot: WorkspaceSnapshot? @Published package private(set) var rootNode: FileNode? @Published package private(set) var projectFiles: [URL] = [] @Published package private(set) var isLoadingWorkspace = false @@ -161,6 +163,7 @@ package final class WorkspaceFeatureModel: ObservableObject { } package func reset() { + workspaceGeneration &+= 1 if let workspaceURL { scheduleSearchIndexInvalidation(at: workspaceURL, rules: visibilityRules) } @@ -184,6 +187,7 @@ package final class WorkspaceFeatureModel: ObservableObject { hasRestoredWorkspaceSession = false rootNode = nil projectFiles = [] + appliedSnapshot = nil isLoadingWorkspace = false isRefreshingWorkspace = false loadErrorMessage = nil @@ -202,6 +206,7 @@ package final class WorkspaceFeatureModel: ObservableObject { } package func beginWorkspace(at url: URL, visibilityRules: FileVisibilityRules) { + workspaceGeneration &+= 1 workspaceURL = url.standardizedFileURL self.visibilityRules = visibilityRules hasRestoredWorkspaceSession = false @@ -294,6 +299,7 @@ package final class WorkspaceFeatureModel: ObservableObject { loadErrorMessage = nil rootNode = snapshot.root projectFiles = snapshot.files + appliedSnapshot = snapshot scheduleSearchIndexWarm(at: workspaceURL, rules: rules) // The tree is usable as soon as the shared snapshot is ready. Service diff --git a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift index 8e5b9f01..ecde3db7 100644 --- a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift +++ b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift @@ -1794,6 +1794,47 @@ struct DebugModuleTests { #expect(feature.exceptionInfo == nil) } + @Test + func expandingSelfReferentialVariableDoesNotRecurseForever() throws { + let session = DeferredInspectionDebugSession() + let feature = makeDeferredFeature( + session: session, + rootPath: "/tmp/java-self-referential-variable" + ) + defer { feature.stop() } + + session.emit(.stopped(reason: "breakpoint", threadID: 1, description: nil)) + feature.loadVariables(reference: 100) + session.completeVariables(at: 0, with: [DebugVariable( + id: "self", + name: "self", + value: "Node@1", + type: "Node", + evaluateName: "self", + variablesReference: 101, + containerReference: 100, + namedVariables: 1, + indexedVariables: 0 + )]) + + let root = try #require(feature.variables.first) + feature.toggleVariableExpansion(root) + session.completeVariables(at: 1, with: [DebugVariable( + id: "self", + name: "self", + value: "Node@1", + type: "Node", + evaluateName: "self", + variablesReference: 101, + containerReference: 101, + namedVariables: 1, + indexedVariables: 0 + )]) + + #expect(feature.visibleVariableRows.map(\.depth) == [0, 1]) + #expect(feature.visibleVariableRows.compactMap { $0.variable?.name } == ["self", "self"]) + } + @Test func genericBreakpointsPreserveAdvancedOptionsAcrossMuteAndClear() throws { let transport = RecordingTransport() diff --git a/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift b/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift index d8acaac4..ac92b2f5 100644 --- a/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift +++ b/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift @@ -247,6 +247,161 @@ struct ExecutionModuleTests { #expect(service.errorMessage == "go testing extension is not active.") } + @Test + func mavenServiceExecutesTheSharedLaunchPlanWithLocalRuntimeOverrides() async throws { + let workspace = URL(fileURLWithPath: "/workspace", isDirectory: true) + let reactor = workspace.appendingPathComponent("projects/demo", isDirectory: true) + let module = MavenModule( + relativePath: "service-api", + url: reactor.appendingPathComponent("service-api", isDirectory: true), + groupID: "dev.lithe", + artifactID: "service-api", + version: "1.0", + packaging: "jar", + modules: [] + ) + let project = MavenProject( + rootURL: reactor, + pomURL: reactor.appendingPathComponent("pom.xml"), + groupID: "dev.lithe", + artifactID: "demo", + version: "1.0", + packaging: "pom", + modules: [module], + profiles: [MavenProfile(id: "dev", isActiveByDefault: false)], + hasWrapper: false + ) + let plan = MavenLaunchPlan( + version: 1, + toolchain: "project-maven", + arguments: ["core-owned-argument", "-s", "/local/settings.xml", "verify"], + workingDirectory: "projects/demo", + configurationFingerprint: "sha256:test" + ) + let operations = RecordingMavenOperations(project: project, plan: plan) + let process = MavenRecordingProcess() + let runtime = MavenRecordingRuntime() + let store = RecordingMavenConfigurationStore(configuration: MavenStoredConfiguration( + portable: MavenPortableConfiguration( + selectedProfiles: ["dev"], + customProfiles: [], + skipTests: true + ), + local: MavenLocalConfiguration( + settingsPath: "/local/settings.xml", + mavenExecutablePath: "/local/apache-maven", + javaHomePath: "/local/jdk" + ) + )) + let service = MavenService( + runtimeService: runtime, + process: process, + mavenOperations: operations, + configurationStore: store + ) + + await service.loadProject(at: workspace, files: [project.pomURL]) + service.runCustomGoal( + "help:evaluate -Dexpression=fixture.config -q -DforceStdout", + module: module + ) + let request = try #require(await process.nextStart(timeout: .seconds(1))) + + #expect(request.arguments == plan.arguments) + #expect(request.workingDirectory == reactor.path) + #expect(request.environment?["TEST_JAVA_HOME"] == "/local/jdk") + #expect(runtime.lastMavenOverride == "/local/apache-maven") + #expect(operations.lastContext?.reactorPath == "projects/demo") + #expect(operations.lastContext?.profiles == ["dev"]) + #expect(operations.lastContext?.settingsPath == "/local/settings.xml") + #expect(operations.lastContext?.skipTests == true) + #expect(operations.lastModule == "service-api") + #expect(operations.lastGoals == [ + "help:evaluate", + "-Dexpression=fixture.config", + "-q", + "-DforceStdout" + ]) + #expect(service.output.contains("-s ")) + #expect(!service.output.contains("/local/settings.xml")) + } + + @Test + func mavenServiceReportsCancellationWithoutInventingAnExitCode() async throws { + let workspace = URL(fileURLWithPath: "/workspace", isDirectory: true) + let project = MavenProject( + rootURL: workspace, + pomURL: workspace.appendingPathComponent("pom.xml"), + groupID: "dev.lithe", + artifactID: "demo", + version: "1.0", + packaging: "jar", + modules: [], + profiles: [], + hasWrapper: false + ) + let plan = MavenLaunchPlan( + version: 1, + toolchain: "project-maven", + arguments: ["-B", "-ntp", "validate"], + workingDirectory: ".", + configurationFingerprint: "sha256:test" + ) + let process = MavenRecordingProcess() + let service = MavenService( + runtimeService: MavenRecordingRuntime(), + process: process, + mavenOperations: RecordingMavenOperations(project: project, plan: plan) + ) + + await service.loadProject(at: workspace, files: [project.pomURL]) + service.run(phase: .validate, module: nil) + _ = try #require(await process.nextStart(timeout: .seconds(1))) + service.stop() + + #expect(service.taskState == .cancelled) + #expect(service.runningTitle == nil) + #expect(service.lastExitCode == nil) + #expect(service.output.hasSuffix("Maven task cancelled.\n")) + #expect(!process.isRunning) + } + + @Test + func mavenServiceClearsReloadWhenConfigurationFingerprintReturnsToBaseline() async throws { + let workspace = URL(fileURLWithPath: "/workspace", isDirectory: true) + let project = MavenProject( + rootURL: workspace, + pomURL: workspace.appendingPathComponent("pom.xml"), + groupID: "dev.lithe", + artifactID: "demo", + version: "1.0", + packaging: "jar", + modules: [], + profiles: [], + hasWrapper: false + ) + let process = MavenRecordingProcess() + let service = MavenService( + runtimeService: MavenRecordingRuntime(), + process: process, + mavenOperations: FingerprintingMavenOperations(project: project) + ) + + await service.loadProject(at: workspace, files: [project.pomURL]) + #expect(!service.isReloadRequired) + + service.setSkipTests(true) + service.run(phase: .validate, module: nil) + _ = try #require(await process.nextStart(timeout: .seconds(1))) + #expect(service.isReloadRequired) + + service.stop() + service.setSkipTests(false) + service.run(phase: .validate, module: nil) + _ = try #require(await process.nextStart(timeout: .seconds(1))) + #expect(!service.isReloadRequired) + } + private func factory(recorder: Recorder) -> ModuleFactory { ModuleFactory(manifest: ExecutionModule.moduleManifest, contributions: ExecutionModule.moduleContributions) { recorder.factoryCalls += 1 @@ -328,8 +483,8 @@ private func makeTestGraph() -> ExecutionFeatureGraph { @MainActor private final class TestRuntime: MavenRuntimePort, RunRuntimePort { - func mavenExecutable(for project: MavenProject) -> URL? { nil } - func mavenProcessEnvironment() -> [String: String] { [:] } + func mavenExecutable(for project: MavenProject, overridePath: String?) -> URL? { nil } + func mavenProcessEnvironment(javaHomePath: String?) -> [String: String] { [:] } func setActiveServiceJavaHomePath(_ path: String) {} func javaHomeURL(overridePath: String?) -> URL? { nil } func mavenJavaHomeURL(overridePath: String?) -> URL? { nil } @@ -356,10 +511,179 @@ private final class TestStreamingProcess: StreamingProcess, @unchecked Sendable } private struct TestMavenOperations: MavenProjectOperations { - func scanMavenProject(at rootURL: URL, files: [URL]) -> MavenProject? { nil } + func scanMavenProject(at rootURL: URL, files: [URL]) throws -> MavenProject? { nil } + func mavenLaunchPlan( + at rootURL: URL, + context: MavenLaunchContext, + module: String?, + goals: [String] + ) throws -> MavenLaunchPlan { + MavenLaunchPlan( + version: 1, + toolchain: "project-maven", + arguments: goals, + workingDirectory: ".", + configurationFingerprint: "test" + ) + } func mavenDiagnostics(output: String, projectRoot: URL) -> [MavenBuildIssue] { [] } } +private final class RecordingMavenOperations: MavenProjectOperations, @unchecked Sendable { + private let lock = NSLock() + private let project: MavenProject + private let plan: MavenLaunchPlan + private var recordedContext: MavenLaunchContext? + private var recordedModule: String? + private var recordedGoals: [String] = [] + + init(project: MavenProject, plan: MavenLaunchPlan) { + self.project = project + self.plan = plan + } + + var lastContext: MavenLaunchContext? { + lock.lock() + defer { lock.unlock() } + return recordedContext + } + + var lastModule: String? { + lock.lock() + defer { lock.unlock() } + return recordedModule + } + + var lastGoals: [String] { + lock.lock() + defer { lock.unlock() } + return recordedGoals + } + + func scanMavenProject(at rootURL: URL, files: [URL]) throws -> MavenProject? { + project + } + + func mavenLaunchPlan( + at rootURL: URL, + context: MavenLaunchContext, + module: String?, + goals: [String] + ) throws -> MavenLaunchPlan { + lock.lock() + recordedContext = context + recordedModule = module + recordedGoals = goals + lock.unlock() + return plan + } + + func mavenDiagnostics(output: String, projectRoot: URL) -> [MavenBuildIssue] { [] } +} + +private final class FingerprintingMavenOperations: MavenProjectOperations, @unchecked Sendable { + private let project: MavenProject + + init(project: MavenProject) { + self.project = project + } + + func scanMavenProject(at rootURL: URL, files: [URL]) throws -> MavenProject? { + project + } + + func mavenLaunchPlan( + at rootURL: URL, + context: MavenLaunchContext, + module: String?, + goals: [String] + ) throws -> MavenLaunchPlan { + MavenLaunchPlan( + version: 1, + toolchain: "project-maven", + arguments: goals, + workingDirectory: context.reactorPath, + configurationFingerprint: context.skipTests ? "sha256:skip-tests" : "sha256:run-tests" + ) + } + + func mavenDiagnostics(output: String, projectRoot: URL) -> [MavenBuildIssue] { [] } +} + +private final class RecordingMavenConfigurationStore: MavenConfigurationStoring, @unchecked Sendable { + private let configuration: MavenStoredConfiguration + + init(configuration: MavenStoredConfiguration) { + self.configuration = configuration + } + + func loadMavenConfiguration( + workspaceURL: URL, + reactorPath: String + ) throws -> MavenStoredConfiguration { + configuration + } + + func saveMavenConfiguration( + _ configuration: MavenStoredConfiguration, + workspaceURL: URL, + reactorPath: String + ) throws {} +} + +@MainActor +private final class MavenRecordingRuntime: MavenRuntimePort { + private(set) var lastMavenOverride: String? + + func mavenExecutable(for project: MavenProject, overridePath: String?) -> URL? { + lastMavenOverride = overridePath + return URL(fileURLWithPath: "/test/bin/mvn") + } + + func mavenProcessEnvironment(javaHomePath: String?) -> [String: String] { + ["TEST_JAVA_HOME": javaHomePath ?? ""] + } +} + +private final class MavenRecordingProcess: StreamingProcess, @unchecked Sendable { + var isRunning = false + var onOutput: (@Sendable (String) -> Void)? + var onTermination: (@Sendable (Int32) -> Void)? + var onStateChange: (@Sendable (ProcessLifecycleEvent) -> Void)? + + private let stream: AsyncStream + private let continuation: AsyncStream.Continuation + + init() { + (stream, continuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(4)) + } + + func start(_ request: ProcessRequest) throws { + isRunning = true + continuation.yield(request) + } + + func send(_ input: Data) throws {} + func stop() { isRunning = false } + + func nextStart(timeout: Duration) async -> ProcessRequest? { + await withTaskGroup(of: ProcessRequest?.self) { group in + let stream = stream + group.addTask { + var iterator = stream.makeAsyncIterator() + return await iterator.next() + } + group.addTask { + try? await ContinuousClock().sleep(for: timeout) + return nil + } + let first = await group.next() ?? nil + group.cancelAll() + return first + } + } +} + private struct TestRunFileAccess: RunFileAccess { let contents: [URL: String] diff --git a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift index 3f35a37a..0735266f 100644 --- a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift +++ b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift @@ -317,6 +317,78 @@ struct GitModuleTests { ]) } + @Test + func gitHistoryPublishesRecentReferencesInCoreOrder() async { + let root = URL(fileURLWithPath: "/workspace") + let main = GitReference( + fullName: "refs/heads/main", + shortName: "main", + kind: .local, + isCurrent: true, + upstreamShortName: "origin/main" + ) + let featureBranch = GitReference( + fullName: "refs/heads/feature/recent", + shortName: "feature/recent", + kind: .local, + isCurrent: false, + upstreamShortName: nil + ) + let service = GitService(operations: TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: root, branch: "main", changes: []), + historyValue: GitHistorySnapshot( + references: [featureBranch, main], + recentReferences: [main, featureBranch], + commits: [], + hasMore: false + ) + )) + let feature = GitFeatureModel(service: service) + feature.configure( + workspaceURLProvider: { root }, + isGitLogVisibleProvider: { true }, + notify: { _ in }, + onStateRefreshed: {} + ) + + await feature.refreshGit() + + #expect(feature.recentGitReferences.map(\.shortName) == ["main", "feature/recent"]) + } + + @Test + func remoteReferenceActionsPreserveIdentityAndPullStrategy() async { + let root = URL(fileURLWithPath: "/workspace") + let reference = GitReference( + fullName: "refs/remotes/origin/feature/demo", + shortName: "origin/feature/demo", + kind: .remote, + isCurrent: false, + upstreamShortName: nil + ) + let service = GitService(operations: TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: root, branch: "main", changes: []) + )) + let feature = GitFeatureModel(service: service) + feature.configure( + workspaceURLProvider: { root }, + isGitLogVisibleProvider: { false }, + notify: { _ in }, + onStateRefreshed: {} + ) + + await feature.refreshGit() + await feature.checkoutAndRebase(reference) + await feature.pullRemoteReference(reference, strategy: .rebase) + await feature.pullRemoteReference(reference, strategy: .merge) + + #expect(feature.gitConsoleEntries.map(\.arguments) == [ + ["checkoutAndRebase", reference.fullName], + ["pull", "rebase", reference.fullName], + ["pull", "merge", reference.fullName] + ]) + } + @Test func postInvocationOperationErrorFailsWhileKeepingConsoleTrace() async { let root = URL(fileURLWithPath: "/workspace") @@ -419,6 +491,350 @@ struct GitModuleTests { #expect(feature.gitConsoleEntries.first?.output == "git version 2.55.0\n") } + @Test + func commitSelectionLoadsFilesWithoutSelectingTheFirstFile() async throws { + let root = URL(fileURLWithPath: "/workspace") + let commit = GitCommit( + hash: "1111111111111111", + shortHash: "1111111", + parentHashes: [], + authorName: "Ada Lovelace", + authorEmail: "ada@example.com", + date: "2026-08-28T16:00:00+08:00", + subject: "Selected commit", + decorations: "" + ) + let nextCommit = GitCommit( + hash: "2222222222222222", + shortHash: "2222222", + parentHashes: [commit.hash], + authorName: "Ada Lovelace", + authorEmail: "ada@example.com", + date: "2026-08-28T16:01:00+08:00", + subject: "Next commit", + decorations: "" + ) + let files = [GitCommitFile(status: "M", path: "README.md")] + let service = GitService(operations: TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: root, branch: "main", changes: []), + filesValue: files + )) + let feature = GitFeatureModel(service: service) + feature.configure( + workspaceURLProvider: { root }, + isGitLogVisibleProvider: { false }, + notify: { _ in }, + onStateRefreshed: {} + ) + + await feature.refreshGit() + await feature.selectGitCommit(commit) + + #expect(feature.selectedGitCommit == commit) + #expect(feature.selectedGitCommitFiles == files) + #expect(feature.selectedGitCommitFilesLoadState == .ready) + #expect(feature.selectedGitCommitFile == nil) + + feature.previewGitCommitSelection(nextCommit) + + #expect(feature.selectedGitCommit == nextCommit) + #expect(feature.selectedGitCommitFiles.isEmpty) + #expect(feature.selectedGitCommitFilesLoadState == .loading) + #expect(feature.selectedGitCommitFile == nil) + #expect(feature.selectedGitCommitDiffContext == nil) + try #require(await waitForGitWorkToBecomeIdle { + feature.hasActiveModuleWork + }) + } + + @Test + func failedCommitFileReadIsNotCachedAndRetryRecovers() async throws { + let root = URL(fileURLWithPath: "/workspace") + let commit = makeTestCommit(hash: "1111111111111111", subject: "Retry commit") + let files = [GitCommitFile(status: "M", path: "README.md")] + let filesGate = GitFilesLoadGate(results: [nil, files]) + defer { filesGate.releaseAll() } + let service = GitService(operations: TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: root, branch: "main", changes: []), + filesGate: filesGate + )) + let feature = GitFeatureModel(service: service) + feature.configure( + workspaceURLProvider: { root }, + isGitLogVisibleProvider: { false }, + notify: { _ in }, + onStateRefreshed: {} + ) + + await feature.refreshGit() + feature.previewGitCommitSelection(commit) + #expect(feature.selectedGitCommitFilesLoadState == .loading) + + let failedLoad = Task { @MainActor in + await feature.loadGitCommitFiles(for: commit) + } + defer { failedLoad.cancel() } + try #require(await filesGate.waitUntilCallStarts(0)) + filesGate.releaseCall(0) + try #require(await filesGate.waitUntilCallFinishes(0)) + try #require(await waitForGitTaskCompletion(failedLoad)) + + #expect(feature.selectedGitCommitFiles.isEmpty) + #expect(feature.selectedGitCommitFilesLoadState == .failed) + + let retryLoad = Task { @MainActor in + await feature.loadGitCommitFiles(for: commit) + } + defer { retryLoad.cancel() } + try #require(await filesGate.waitUntilCallStarts(1)) + filesGate.releaseCall(1) + try #require(await filesGate.waitUntilCallFinishes(1)) + try #require(await waitForGitTaskCompletion(retryLoad)) + + #expect(!filesGate.didTimeOut) + #expect(filesGate.callHashes == [commit.hash, commit.hash]) + #expect(feature.selectedGitCommitFiles == files) + #expect(feature.selectedGitCommitFilesLoadState == .ready) + try #require(await waitForGitWorkToBecomeIdle { + feature.hasActiveModuleWork + }) + } + + @Test + func repeatedCommitSelectionReusesCachedFilesAndResetInvalidatesCache() async throws { + let root = URL(fileURLWithPath: "/workspace") + let commit = GitCommit( + hash: "1111111111111111", + shortHash: "1111111", + parentHashes: [], + authorName: "Ada Lovelace", + authorEmail: "ada@example.com", + date: "2026-08-28T16:00:00+08:00", + subject: "Cached commit", + decorations: "" + ) + let files = [GitCommitFile(status: "M", path: "README.md")] + let filesRecorder = GitFilesCallRecorder() + let service = GitService(operations: TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: root, branch: "main", changes: []), + filesValue: files, + filesRecorder: filesRecorder + )) + let feature = GitFeatureModel(service: service) + feature.configure( + workspaceURLProvider: { root }, + isGitLogVisibleProvider: { false }, + notify: { _ in }, + onStateRefreshed: {} + ) + + await feature.refreshGit() + await feature.selectGitCommit(commit) + await feature.selectGitCommit(commit) + + #expect(filesRecorder.callCount == 1) + #expect(feature.selectedGitCommitFiles == files) + + feature.reset() + await feature.refreshGit() + await feature.selectGitCommit(commit) + + #expect(filesRecorder.callCount == 2) + #expect(feature.selectedGitCommitFiles == files) + try #require(await waitForGitWorkToBecomeIdle { + feature.hasActiveModuleWork + }) + } + + @Test + func selectedAndQueryDemandCoalescesWhilePrefetchRemainsBounded() async throws { + let root = URL(fileURLWithPath: "/workspace") + let blocker = makeTestCommit(hash: "1111111111111111", subject: "Blocker") + let shared = makeTestCommit(hash: "2222222222222222", subject: "Shared demand") + let trailing = makeTestCommit(hash: "3333333333333333", subject: "Trailing prefetch") + let blockerFiles = [GitCommitFile(status: "M", path: "blocker.txt")] + let sharedFiles = [GitCommitFile(status: "A", path: "shared.txt")] + let trailingFiles = [GitCommitFile(status: "D", path: "trailing.txt")] + let filesGate = GitFilesLoadGate(results: [blockerFiles, sharedFiles, trailingFiles]) + defer { filesGate.releaseAll() } + let service = GitService(operations: TestGitOperations( + filesGate: filesGate + )) + let loader = GitCommitFilesLoader(service: service) + + loader.replacePrefetchCandidates([blocker, shared], at: root) + try #require(await filesGate.waitUntilCallStarts(0)) + + let queryLoad = Task { @MainActor in + await loader.loadQueryFiles(for: shared, at: root) + } + defer { queryLoad.cancel() } + try #require(await filesGate.waitUntilCallStarts(1)) + + let selectedLoad = loader.requestSelectedFiles(for: shared, at: root) + defer { selectedLoad.cancel() } + loader.replacePrefetchCandidates([trailing], at: root) + + #expect(filesGate.callHashes == [blocker.hash, shared.hash]) + #expect(filesGate.maximumConcurrentCalls == 2) + + filesGate.releaseCall(1) + try #require(await filesGate.waitUntilCallFinishes(1)) + let queryOutcome = try #require(await waitForGitCommitFilesOutcome(queryLoad)) + let selectedOutcome = try #require(await waitForGitCommitFilesOutcome(selectedLoad)) + #expect(queryOutcome == .ready(sharedFiles)) + #expect(selectedOutcome == .ready(sharedFiles)) + + // The active speculative read keeps the next prefetch queued, so the + // loader never spends both physical slots on cache warming. + #expect(filesGate.callCount == 2) + filesGate.releaseCall(0) + try #require(await filesGate.waitUntilCallFinishes(0)) + try #require(await filesGate.waitUntilCallStarts(2)) + #expect(filesGate.callHashes == [blocker.hash, shared.hash, trailing.hash]) + #expect(filesGate.maximumConcurrentCalls == 2) + filesGate.releaseCall(2) + try #require(await filesGate.waitUntilCallFinishes(2)) + + #expect(!filesGate.didTimeOut) + #expect(filesGate.callCount == 3) + #expect(loader.cachedFiles(for: shared, at: root) == sharedFiles) + try #require(await waitForGitWorkToBecomeIdle { + loader.hasActiveWork + }) + } + + @Test + func latestSelectionStartsBeforeBlockedPreviousSelectionFinishes() async throws { + let root = URL(fileURLWithPath: "/workspace") + let previous = makeTestCommit(hash: "1111111111111111", subject: "Previous") + let latest = makeTestCommit(hash: "2222222222222222", subject: "Latest") + let previousFiles = [GitCommitFile(status: "M", path: "previous.txt")] + let latestFiles = [GitCommitFile(status: "A", path: "latest.txt")] + let filesGate = GitFilesLoadGate(results: [previousFiles, latestFiles]) + defer { filesGate.releaseAll() } + let service = GitService(operations: TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: root, branch: "main", changes: []), + filesGate: filesGate + )) + let feature = GitFeatureModel(service: service) + feature.configure( + workspaceURLProvider: { root }, + isGitLogVisibleProvider: { false }, + notify: { _ in }, + onStateRefreshed: {} + ) + + await feature.refreshGit() + feature.previewGitCommitSelection(previous) + let previousLoad = Task { @MainActor in + await feature.loadGitCommitFiles(for: previous) + } + defer { previousLoad.cancel() } + try #require(await filesGate.waitUntilCallStarts(0)) + + feature.previewGitCommitSelection(latest) + let latestLoad = Task { @MainActor in + await feature.loadGitCommitFiles(for: latest) + } + defer { latestLoad.cancel() } + + // The second selection must consume the free physical slot instead of + // waiting for the stale synchronous read to return. + try #require(await filesGate.waitUntilCallStarts(1)) + #expect(filesGate.callHashes == [previous.hash, latest.hash]) + #expect(filesGate.maximumConcurrentCalls == 2) + + filesGate.releaseCall(1) + try #require(await filesGate.waitUntilCallFinishes(1)) + try #require(await waitForGitTaskCompletion(latestLoad)) + #expect(feature.selectedGitCommit == latest) + #expect(feature.selectedGitCommitFiles == latestFiles) + #expect(feature.selectedGitCommitFilesLoadState == .ready) + + filesGate.releaseCall(0) + try #require(await filesGate.waitUntilCallFinishes(0)) + try #require(await waitForGitTaskCompletion(previousLoad)) + #expect(feature.selectedGitCommit == latest) + #expect(feature.selectedGitCommitFiles == latestFiles) + #expect(feature.selectedGitCommitFilesLoadState == .ready) + #expect(!filesGate.didTimeOut) + try #require(await waitForGitWorkToBecomeIdle { + feature.hasActiveModuleWork + }) + } + + @Test + func resetSupersedesStaleGenerationAndRetriesWithinPhysicalLimit() async throws { + let root = URL(fileURLWithPath: "/workspace") + let commit = makeTestCommit(hash: "1111111111111111", subject: "Reset commit") + let staleFiles = [GitCommitFile(status: "M", path: "stale.txt")] + let replacementFiles = [GitCommitFile(status: "A", path: "replacement.txt")] + let filesGate = GitFilesLoadGate(results: [staleFiles, replacementFiles]) + defer { filesGate.releaseAll() } + let service = GitService(operations: TestGitOperations( + filesGate: filesGate + )) + let loader = GitCommitFilesLoader(service: service) + + let staleLoad = loader.requestSelectedFiles(for: commit, at: root) + defer { staleLoad.cancel() } + try #require(await filesGate.waitUntilCallStarts(0)) + + loader.reset() + let replacementLoad = loader.requestSelectedFiles(for: commit, at: root) + defer { replacementLoad.cancel() } + + let staleOutcome = try #require(await waitForGitCommitFilesOutcome(staleLoad)) + #expect(staleOutcome == .superseded) + try #require(await filesGate.waitUntilCallStarts(1)) + #expect(filesGate.maximumConcurrentCalls == 2) + + filesGate.releaseCall(1) + try #require(await filesGate.waitUntilCallFinishes(1)) + let replacementOutcome = try #require(await waitForGitCommitFilesOutcome(replacementLoad)) + #expect(replacementOutcome == .ready(replacementFiles)) + + filesGate.releaseCall(0) + try #require(await filesGate.waitUntilCallFinishes(0)) + #expect(!filesGate.didTimeOut) + #expect(filesGate.callHashes == [commit.hash, commit.hash]) + #expect(loader.cachedFiles(for: commit, at: root) == replacementFiles) + try #require(await waitForGitWorkToBecomeIdle { + loader.hasActiveWork + }) + } + + @Test + func commitFilesPrefetchPrioritizesTheNextOlderAndNewerCommits() { + let commits = (0..<6).map { index in + let hash = String(index) + return GitCommit( + hash: hash, + shortHash: hash, + parentHashes: [], + authorName: "Test Author", + authorEmail: "author@example.com", + date: "2026-08-28T16:00:00+08:00", + subject: hash, + decorations: "" + ) + } + + let candidates = GitCommitFilesPrefetchPlan.candidates( + in: commits, + centeredAt: "2", + radius: 3 + ) + + #expect(candidates.map(\.hash) == ["3", "1", "4", "0", "5"]) + #expect(GitCommitFilesPrefetchPlan.candidates( + in: commits, + centeredAt: "missing", + radius: 3 + ).isEmpty) + } + @Test func clearingGitConsoleDoesNotTriggerInitialLoadAgain() async { let root = URL(fileURLWithPath: "/workspace") @@ -442,7 +858,7 @@ struct GitModuleTests { } @Test - func switchingRepositoriesDiscardsStaleInitialGitConsoleOutput() async { + func switchingRepositoriesDiscardsStaleInitialGitConsoleOutput() async throws { let firstRoot = URL(fileURLWithPath: "/first-workspace") let secondRoot = URL(fileURLWithPath: "/second-workspace") let runGate = TestGitRunGate() @@ -462,12 +878,17 @@ struct GitModuleTests { ) await feature.refreshGit() - let initialLoad = Task { await feature.loadGitConsoleIfNeeded() } - await runGate.waitUntilFirstRunStarts() + let initialLoad = Task { @MainActor in await feature.loadGitConsoleIfNeeded() } + defer { + initialLoad.cancel() + runGate.releaseFirstRun() + } + try #require(await runGate.waitUntilFirstRunStarts()) workspaceURL = secondRoot await feature.refreshGit() runGate.releaseFirstRun() - await initialLoad.value + try #require(await waitForGitTaskCompletion(initialLoad)) + #expect(!runGate.didTimeOut) #expect(feature.gitConsoleEntries.isEmpty) @@ -726,62 +1147,335 @@ private struct TestShelfStorage: GitShelfStorage { private final class TestGitRunGate: @unchecked Sendable { private let lock = NSLock() - private let firstRunRelease = DispatchSemaphore(value: 0) + private let firstRunStarted = GitModuleTestGate() + private let firstRunRelease = GitModuleTestGate() private var hasBlockedFirstRun = false - private var firstRunWaiter: CheckedContinuation? + private var didTimeOutValue = false + + var didTimeOut: Bool { + lock.lock() + defer { lock.unlock() } + return didTimeOutValue + } func blockFirstRun() { lock.lock() let shouldBlock = !hasBlockedFirstRun hasBlockedFirstRun = true - let waiter = shouldBlock ? firstRunWaiter : nil - firstRunWaiter = nil lock.unlock() guard shouldBlock else { return } - waiter?.resume() - firstRunRelease.wait() + firstRunStarted.open() + guard firstRunRelease.waitSynchronously() else { + lock.lock() + didTimeOutValue = true + lock.unlock() + return + } } - func waitUntilFirstRunStarts() async { - await withCheckedContinuation { continuation in - lock.lock() - if hasBlockedFirstRun { - lock.unlock() - continuation.resume() - } else { - firstRunWaiter = continuation - lock.unlock() + func waitUntilFirstRunStarts() async -> Bool { + await firstRunStarted.waitUntilOpen() + } + + func releaseFirstRun() { + firstRunRelease.open() + } +} + +private final class GitFilesCallRecorder: @unchecked Sendable { + private let lock = NSLock() + private var calls = 0 + + var callCount: Int { + lock.lock() + defer { lock.unlock() } + return calls + } + + func recordCall() { + lock.lock() + calls += 1 + lock.unlock() + } +} + +private func makeTestCommit(hash: String, subject: String) -> GitCommit { + GitCommit( + hash: hash, + shortHash: String(hash.prefix(7)), + parentHashes: [], + authorName: "Test Author", + authorEmail: "author@example.com", + date: "2026-08-28T16:00:00+08:00", + subject: subject, + decorations: "" + ) +} + +private final class GitModuleTestGate: @unchecked Sendable { + private let condition = NSCondition() + private var isOpen = false + private var asyncWaiters: [UUID: CheckedContinuation] = [:] + private var timeoutTasks: [UUID: Task] = [:] + + func open() { + condition.lock() + isOpen = true + let waiters = Array(asyncWaiters.values) + let tasks = Array(timeoutTasks.values) + asyncWaiters.removeAll() + timeoutTasks.removeAll() + condition.broadcast() + condition.unlock() + tasks.forEach { $0.cancel() } + waiters.forEach { $0.resume(returning: true) } + } + + func waitSynchronously(timeout: TimeInterval = 5) -> Bool { + let deadline = Date(timeIntervalSinceNow: timeout) + condition.lock() + defer { condition.unlock() } + while !isOpen { + guard condition.wait(until: deadline) else { return false } + } + return true + } + + func waitUntilOpen(timeout: Duration = .seconds(2)) async -> Bool { + let waiterID = UUID() + return await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + condition.lock() + guard !isOpen else { + condition.unlock() + continuation.resume(returning: true) + return + } + asyncWaiters[waiterID] = continuation + condition.unlock() + + let timeoutTask = Task { [weak self] in + // test-stability: allow(swift-real-sleep) reason: this watchdog bounds a failed event-driven Git test without controlling successful execution order. + try? await Task.sleep(for: timeout) + guard !Task.isCancelled else { return } + self?.finishAsyncWaiter(waiterID, result: false) + } + condition.lock() + if asyncWaiters[waiterID] == nil { + condition.unlock() + timeoutTask.cancel() + } else { + timeoutTasks[waiterID] = timeoutTask + condition.unlock() + } + if Task.isCancelled { + finishAsyncWaiter(waiterID, result: false) + } } + } onCancel: { + finishAsyncWaiter(waiterID, result: false) } } - func releaseFirstRun() { - firstRunRelease.signal() + private func finishAsyncWaiter(_ waiterID: UUID, result: Bool) { + condition.lock() + let waiter = asyncWaiters.removeValue(forKey: waiterID) + let timeoutTask = timeoutTasks.removeValue(forKey: waiterID) + condition.unlock() + timeoutTask?.cancel() + waiter?.resume(returning: result) } } +private final class GitFilesLoadGate: @unchecked Sendable { + private let lock = NSLock() + private let results: [[GitCommitFile]?] + private let startedGates: [GitModuleTestGate] + private let releaseGates: [GitModuleTestGate] + private let finishedGates: [GitModuleTestGate] + private var calls = 0 + private var activeCalls = 0 + private var peakConcurrentCalls = 0 + private var hashes: [String] = [] + private var timedOut = false + + init(results: [[GitCommitFile]?]) { + self.results = results + startedGates = results.map { _ in GitModuleTestGate() } + releaseGates = results.map { _ in GitModuleTestGate() } + finishedGates = results.map { _ in GitModuleTestGate() } + } + + var callCount: Int { + lock.lock() + defer { lock.unlock() } + return calls + } + + var didTimeOut: Bool { + lock.lock() + defer { lock.unlock() } + return timedOut + } + + var maximumConcurrentCalls: Int { + lock.lock() + defer { lock.unlock() } + return peakConcurrentCalls + } + + var callHashes: [String] { + lock.lock() + defer { lock.unlock() } + return hashes + } + + func loadFiles(for commit: GitCommit) -> [GitCommitFile]? { + lock.lock() + let callIndex = calls + calls += 1 + activeCalls += 1 + peakConcurrentCalls = max(peakConcurrentCalls, activeCalls) + hashes.append(commit.hash) + lock.unlock() + + guard results.indices.contains(callIndex) else { + finishCall(callIndex, timedOut: true) + return nil + } + startedGates[callIndex].open() + guard releaseGates[callIndex].waitSynchronously() else { + finishCall(callIndex, timedOut: true) + return nil + } + let result = results[callIndex] + finishCall(callIndex, timedOut: false) + return result + } + + func waitUntilCallStarts(_ index: Int) async -> Bool { + guard startedGates.indices.contains(index) else { return false } + return await startedGates[index].waitUntilOpen() + } + + func waitUntilCallFinishes(_ index: Int) async -> Bool { + guard finishedGates.indices.contains(index) else { return false } + return await finishedGates[index].waitUntilOpen() + } + + func releaseCall(_ index: Int) { + guard releaseGates.indices.contains(index) else { return } + releaseGates[index].open() + } + + func releaseAll() { + releaseGates.forEach { $0.open() } + } + + private func finishCall(_ index: Int, timedOut: Bool) { + lock.lock() + activeCalls = max(0, activeCalls - 1) + self.timedOut = self.timedOut || timedOut + lock.unlock() + guard finishedGates.indices.contains(index) else { return } + finishedGates[index].open() + } +} + +private func waitForGitCommitFilesOutcome( + _ task: Task, + timeout: Duration = .seconds(2) +) async -> GitCommitFilesLoadOutcome? { + await withTaskGroup(of: GitCommitFilesLoadOutcome?.self) { group in + group.addTask { + await task.value + } + group.addTask { + // test-stability: allow(swift-real-sleep) reason: this task is the bounded failure deadline for a loader outcome. + try? await Task.sleep(for: timeout) + task.cancel() + return nil + } + let result = await group.next() ?? nil + if result == nil { + task.cancel() + } + group.cancelAll() + return result + } +} + +private func waitForGitTaskCompletion( + _ task: Task, + timeout: Duration = .seconds(2) +) async -> Bool { + await withTaskGroup(of: Bool.self) { group in + group.addTask { + await task.value + return true + } + group.addTask { + // test-stability: allow(swift-real-sleep) reason: this task is the bounded failure deadline for an event-driven Git task. + try? await Task.sleep(for: timeout) + task.cancel() + return false + } + let completed = await group.next() ?? false + if !completed { + task.cancel() + } + group.cancelAll() + return completed + } +} + +@MainActor +private func waitForGitWorkToBecomeIdle( + timeout: Duration = .seconds(2), + isActive: @MainActor () -> Bool +) async -> Bool { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while isActive(), clock.now < deadline { + await Task.yield() + } + return !isActive() +} + private struct TestGitOperations: GitOperations { private let snapshotValue: GitSnapshot? private let comparisonValue: GitBranchComparison? + private let filesValue: [GitCommitFile]? private let untrackedDiffDocumentValue: DiffDocument? private let comparisonDiffDocumentValue: DiffDocument? + private let historyValue: GitHistorySnapshot? private let stageResult: GitProcessResult? private let runGate: TestGitRunGate? + private let filesRecorder: GitFilesCallRecorder? + private let filesGate: GitFilesLoadGate? init( snapshotValue: GitSnapshot? = nil, comparisonValue: GitBranchComparison? = nil, + historyValue: GitHistorySnapshot? = nil, + filesValue: [GitCommitFile]? = nil, untrackedDiffDocumentValue: DiffDocument? = nil, comparisonDiffDocumentValue: DiffDocument? = nil, stageResult: GitProcessResult? = nil, - runGate: TestGitRunGate? = nil + runGate: TestGitRunGate? = nil, + filesRecorder: GitFilesCallRecorder? = nil, + filesGate: GitFilesLoadGate? = nil ) { self.snapshotValue = snapshotValue self.comparisonValue = comparisonValue + self.historyValue = historyValue + self.filesValue = filesValue self.untrackedDiffDocumentValue = untrackedDiffDocumentValue self.comparisonDiffDocumentValue = comparisonDiffDocumentValue self.stageResult = stageResult self.runGate = runGate + self.filesRecorder = filesRecorder + self.filesGate = filesGate } func run(arguments: [String], workingDirectory: String, input: String?) -> GitProcessResult { @@ -804,8 +1498,14 @@ private struct TestGitOperations: GitOperations { func commitDiffDocument(at rootURL: URL, commit: String, pathspecs: [String], whitespace: GitDiffWhitespaceMode) -> DiffDocument? { nil } func comparisonDiffDocument(at rootURL: URL, reference: String, pathspecs: [String], whitespace: GitDiffWhitespaceMode) -> DiffDocument? { comparisonDiffDocumentValue } func applyPatch(_ patch: String, at rootURL: URL, mode: String) -> GitProcessResult? { nil } - func history(at rootURL: URL, reference: GitReference?, limit: Int) -> GitHistorySnapshot? { nil } - func files(in commit: GitCommit, at rootURL: URL) -> [GitCommitFile]? { nil } + func history(at rootURL: URL, reference: GitReference?, limit: Int) -> GitHistorySnapshot? { historyValue } + func files(in commit: GitCommit, at rootURL: URL) -> [GitCommitFile]? { + filesRecorder?.recordCall() + if let filesGate { + return filesGate.loadFiles(for: commit) + } + return filesValue + } func commit(at rootURL: URL, hash: String) -> GitCommit? { nil } func comparison(for reference: GitReference, at rootURL: URL) -> GitBranchComparison? { comparisonValue } func stashes(at rootURL: URL) -> [GitStash]? { nil } @@ -823,7 +1523,25 @@ private struct TestGitOperations: GitOperations { func deleteBranch(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { nil } func mergeBranch(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { nil } func rebaseCurrentBranch(onto reference: GitReference, at rootURL: URL) -> GitProcessResult? { nil } + func checkoutAndRebase(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { + GitProcessResult( + arguments: ["checkoutAndRebase", reference.fullName], + output: "", + exitCode: 0 + ) + } func updateCurrentBranch(at rootURL: URL, strategy: GitPullStrategy) -> GitProcessResult? { nil } + func pullRemoteReference( + _ reference: GitReference, + strategy: GitPullStrategy, + at rootURL: URL + ) -> GitProcessResult? { + GitProcessResult( + arguments: ["pull", strategy.rawValue, reference.fullName], + output: "", + exitCode: 0 + ) + } func pullPreflight(at rootURL: URL) -> GitPullPreflightState? { nil } func conflictMarkerPaths(at rootURL: URL) -> [String] { [] } func integrationPreflight(for target: GitIntegrationTarget, operation: GitIntegrationOperation, at rootURL: URL) -> GitIntegrationPreflightState? { nil } diff --git a/macos/Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift b/macos/Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift index 5dbeca25..b64520c9 100644 --- a/macos/Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift +++ b/macos/Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift @@ -147,6 +147,40 @@ struct LanguageIntelligenceModuleTests { #expect(resetFingerprint == "active-fingerprint") } + @Test + func javaSessionReceivesTheCurrentMavenContext() throws { + let root = URL(fileURLWithPath: "/workspace/java", isDirectory: true) + let source = root.appendingPathComponent("src/Main.java") + let descriptor = try #require( + LanguageProviderCatalog.compatibilityFallback.provider(for: source) + ) + let context = MavenLaunchContext( + reactorPath: ".", + profiles: ["dev", "enterprise"], + settingsPath: "/local/settings.xml", + skipTests: true, + mavenExecutablePath: "/local/maven/bin/mvn", + javaHomePath: "/local/jdk" + ) + let session = WorkspaceStateLanguageServerSession() + let manager = LanguageToolingSessionManager( + catalog: .compatibilityFallback, + runtimes: [WorkspaceStateLanguageProviderRuntime( + descriptor: descriptor, + session: session + )] + ) + manager.configureMavenContextProvider { requestedDescriptor, requestedRoot in + #expect(requestedDescriptor.id == "java") + #expect(requestedRoot == root.standardizedFileURL) + return context + } + + try manager.startLanguageServer(providerID: "java", rootURL: root) + + #expect(session.startedMavenContext == context) + } + @Test func languageServerLifecycleLogsAndCallbacksRetainTheOperationID() throws { let root = URL(fileURLWithPath: "/workspace/java", isDirectory: true) @@ -904,6 +938,7 @@ private final class WorkspaceStateLanguageServerSession: LanguageServerSession { var serverInfo: LanguageServerInfo? var onServerInfoChange: ((LanguageServerInfo?) -> Void)? private(set) var startedFingerprint: String? + private(set) var startedMavenContext: MavenLaunchContext? private(set) var stopCallCount = 0 var startError: Error? private(set) var executedCommands: [LanguageServerCommand] = [] @@ -916,9 +951,22 @@ private final class WorkspaceStateLanguageServerSession: LanguageServerSession { private var executeTimeoutTasks: [UUID: Task] = [:] private var executeValueCompletion: ((Result) -> Void)? - func start(rootURL _: URL, workspaceFingerprint: String?) throws { + func start(rootURL: URL, workspaceFingerprint: String?) throws { + try start( + rootURL: rootURL, + workspaceFingerprint: workspaceFingerprint, + mavenContext: nil + ) + } + + func start( + rootURL _: URL, + workspaceFingerprint: String?, + mavenContext: MavenLaunchContext? + ) throws { if let startError { throw startError } startedFingerprint = workspaceFingerprint + startedMavenContext = mavenContext isRunning = true let waiterIDs = Array(startWaiters.keys) waiterIDs.forEach { finishStartWaiter($0, result: .success(())) } diff --git a/macos/Tests/LitheTests/EditorChromeModelTests.swift b/macos/Tests/LitheTests/EditorChromeModelTests.swift index 797e17d9..3e5b039f 100644 --- a/macos/Tests/LitheTests/EditorChromeModelTests.swift +++ b/macos/Tests/LitheTests/EditorChromeModelTests.swift @@ -72,4 +72,79 @@ struct EditorChromeModelTests { chrome.updateFindState(currentIndex: 0, count: 2) #expect(publishCount == 2) } + + @Test + func findOptionAndReplaceStateChangesPublishOnceEach() { + let chrome = EditorChromeModel() + let options = FindInFileOptions(matchCase: true, wholeWords: false, regularExpression: false) + + var publishCount = 0 + let observation = chrome.objectWillChange.sink { _ in publishCount += 1 } + defer { observation.cancel() } + + chrome.setFindOptions(options) + chrome.setReplaceVisible(true) + chrome.setFindReplaceText("bar") + #expect(publishCount == 3) + + chrome.setFindOptions(options) + chrome.setReplaceVisible(true) + chrome.setFindReplaceText("bar") + #expect(publishCount == 3) + + #expect(chrome.findOptions == options) + #expect(chrome.isReplaceVisible) + #expect(chrome.findReplaceText == "bar") + } + + @Test + func resetFindBarKeepsFindOptionsAndReplaceText() { + // 查找选项与替换文本在当前会话内保留;可见性与匹配计数被重置 + let chrome = EditorChromeModel() + chrome.setFindBarVisible(true) + chrome.setFindOptions(FindInFileOptions(matchCase: false, wholeWords: true, regularExpression: true)) + chrome.setReplaceVisible(true) + chrome.setFindReplaceText("bar") + chrome.setFindBarQuery("foo") + chrome.updateFindState(currentIndex: 1, count: 2) + + chrome.resetFindBar() + + #expect(!chrome.isFindBarVisible) + #expect(chrome.findBarQuery.isEmpty) + #expect(!chrome.isReplaceVisible) + #expect( + chrome.findOptions == FindInFileOptions(matchCase: false, wholeWords: true, regularExpression: true) + ) + #expect(chrome.findReplaceText == "bar") + } + + @Test + func goToLineDialogAndFindBarAreMutuallyExclusive() { + // The find bar and the go-to-line dialog are mutually exclusive: + // opening either dismisses the other. + let chrome = EditorChromeModel() + chrome.setFindBarVisible(true) + chrome.setGoToLineVisible(true) + #expect(chrome.isGoToLineVisible) + #expect(!chrome.isFindBarVisible) + + chrome.setFindBarVisible(true) + #expect(chrome.isFindBarVisible) + #expect(!chrome.isGoToLineVisible) + + chrome.setGoToLineVisible(false) + #expect(!chrome.isGoToLineVisible) + #expect(chrome.isFindBarVisible) + } + + @Test + func resetClosesGoToLineBar() { + let chrome = EditorChromeModel() + chrome.setGoToLineVisible(true) + + chrome.reset() + + #expect(!chrome.isGoToLineVisible) + } } diff --git a/macos/Tests/LitheTests/FindInFileMatcherTests.swift b/macos/Tests/LitheTests/FindInFileMatcherTests.swift new file mode 100644 index 00000000..e6990f1c --- /dev/null +++ b/macos/Tests/LitheTests/FindInFileMatcherTests.swift @@ -0,0 +1,165 @@ +import Foundation +import Testing +@testable import Lithe + +struct FindInFileMatcherTests { + private let defaultOptions = FindInFileOptions() + + @Test + func literalSearchIsCaseAndDiacriticInsensitiveByDefault() { + // 回归保护:默认行为与既有查找一致(大小写、音调都不敏感) + let source = "Café cafe CAFE" as NSString + let matcher = FindInFileMatcher(query: "cafe", options: defaultOptions) + + #expect(matcher.isValid) + #expect(matcher.matchRanges(in: source) == [NSRange(location: 0, length: 4), NSRange(location: 5, length: 4), NSRange(location: 10, length: 4)]) + } + + @Test + func matchCaseRequiresExactCase() { + let source = "Café cafe CAFE" as NSString + let matcher = FindInFileMatcher( + query: "cafe", + options: FindInFileOptions(matchCase: true) + ) + + #expect(matcher.matchRanges(in: source) == [NSRange(location: 5, length: 4)]) + } + + @Test + func wholeWordsRejectsCandidatesAndKeepsScanning() { + // 下划线与数字算词字符、串首串尾视为边界;被拒候选之后继续向后扫描 + let source = "cat catalog _cat cat1 cat" as NSString + let matcher = FindInFileMatcher( + query: "cat", + options: FindInFileOptions(wholeWords: true) + ) + + #expect(matcher.matchRanges(in: source) == [NSRange(location: 0, length: 3), NSRange(location: 22, length: 3)]) + } + + @Test + func wholeWordsTreatsUnicodeLettersAsWordCharacters() { + let source = "écat cat" as NSString + let matcher = FindInFileMatcher( + query: "cat", + options: FindInFileOptions(wholeWords: true) + ) + + #expect(matcher.matchRanges(in: source) == [NSRange(location: 5, length: 3)]) + } + + @Test + func regularExpressionEnumeratesMatches() { + let source = "alice@example.com bob@test.org" as NSString + let matcher = FindInFileMatcher( + query: "(\\w+)@(\\w+)", + options: FindInFileOptions(regularExpression: true) + ) + + #expect(matcher.isValid) + #expect(matcher.matchRanges(in: source) == [NSRange(location: 0, length: 13), NSRange(location: 18, length: 8)]) + } + + @Test + func regularExpressionReplacementExpandsCaptureGroups() { + let source = "alice@example.com bob@test.org" as NSString + let matcher = FindInFileMatcher( + query: "(\\w+)@(\\w+)", + options: FindInFileOptions(regularExpression: true) + ) + + // NSRegularExpression 的模板只展开 $n 数字引用;${name} 原样返回 + #expect( + matcher.replacement(for: source, matchRange: NSRange(location: 0, length: 13), template: "$2.$1") + == "example.alice" + ) + #expect( + matcher.replacement(for: source, matchRange: NSRange(location: 18, length: 8), template: "$2.$1") + == "test.bob" + ) + } + + @Test + func literalReplacementUsesTemplateVerbatim() { + let source = "foo bar" as NSString + let matcher = FindInFileMatcher(query: "foo", options: defaultOptions) + + #expect( + matcher.replacement(for: source, matchRange: NSRange(location: 0, length: 3), template: "$1 baz") + == "$1 baz" + ) + } + + @Test + func invalidRegularExpressionReportsInvalidAndNoMatches() { + let source = "hello world" as NSString + let matcher = FindInFileMatcher( + query: "a(", + options: FindInFileOptions(regularExpression: true) + ) + + #expect(!matcher.isValid) + #expect(matcher.matchRanges(in: source).isEmpty) + #expect( + matcher.replacement(for: source, matchRange: NSRange(location: 0, length: 5), template: "$1") + == "$1" + ) + } + + @Test + func emptyQueryYieldsNoMatches() { + let source = "hello world" as NSString + + #expect(FindInFileMatcher(query: "", options: defaultOptions).matchRanges(in: source).isEmpty) + #expect( + FindInFileMatcher(query: "", options: FindInFileOptions(regularExpression: true)) + .matchRanges(in: source).isEmpty + ) + } + + @Test + func zeroWidthRegexMatchesAreSkipped() { + let source = "bab" as NSString + let matcher = FindInFileMatcher( + query: "a*", + options: FindInFileOptions(regularExpression: true) + ) + + #expect(matcher.isValid) + #expect(matcher.matchRanges(in: source) == [NSRange(location: 1, length: 1)]) + } + + @Test + func regexWithWholeWordsWrapsPatternInWordBoundaries() { + let source = "cat catalog concat" as NSString + let matcher = FindInFileMatcher( + query: "cat", + options: FindInFileOptions(wholeWords: true, regularExpression: true) + ) + + #expect(matcher.isValid) + #expect(matcher.matchRanges(in: source) == [NSRange(location: 0, length: 3)]) + } + + @Test + func subRangeEnumerationSeesContextOutsideTheRange() { + // 窗口外相邻字符必须参与判定:范围首部的 cat 前一个字符在范围之外 + let source = "zcat cat" as NSString + let matcher = FindInFileMatcher( + query: "cat", + options: FindInFileOptions(wholeWords: true) + ) + + #expect(matcher.matchRanges(in: source, range: NSRange(location: 1, length: 3)).isEmpty) + #expect(matcher.matchRanges(in: source, range: NSRange(location: 5, length: 3)) == [NSRange(location: 5, length: 3)]) + } + + @Test + func literalScanProducesNonOverlappingMatches() { + let source = "aaaa" as NSString + let matcher = FindInFileMatcher(query: "aa", options: defaultOptions) + + #expect(matcher.matchRanges(in: source) == [NSRange(location: 0, length: 2), NSRange(location: 2, length: 2)]) + } +} diff --git a/macos/Tests/LitheTests/GitLogCommitSelectionTests.swift b/macos/Tests/LitheTests/GitLogCommitSelectionTests.swift new file mode 100644 index 00000000..233542f5 --- /dev/null +++ b/macos/Tests/LitheTests/GitLogCommitSelectionTests.swift @@ -0,0 +1,60 @@ +@testable import Lithe +@testable import LitheGitModule +import Testing + +struct GitLogCommitSelectionTests { + @Test + func navigationFollowsVisibleCommitOrderAndStopsAtTheEdges() throws { + let commits = [commit("newest"), commit("middle"), commit("oldest")] + + #expect(GitLogCommitSelection.adjacentCommit( + in: commits, + selectedHash: "newest", + offset: 1 + )?.hash == "middle") + #expect(GitLogCommitSelection.adjacentCommit( + in: commits, + selectedHash: "middle", + offset: -1 + )?.hash == "newest") + #expect(GitLogCommitSelection.adjacentCommit( + in: commits, + selectedHash: "newest", + offset: -1 + ) == nil) + #expect(GitLogCommitSelection.adjacentCommit( + in: commits, + selectedHash: "oldest", + offset: 1 + ) == nil) + } + + @Test + func navigationUsesAVisibleBoundaryWhenTheSelectionIsNotVisible() throws { + let commits = [commit("newest"), commit("oldest")] + + #expect(GitLogCommitSelection.adjacentCommit( + in: commits, + selectedHash: "filtered-out", + offset: 1 + )?.hash == "newest") + #expect(GitLogCommitSelection.adjacentCommit( + in: commits, + selectedHash: nil, + offset: -1 + )?.hash == "oldest") + } + + private func commit(_ hash: String) -> GitCommit { + GitCommit( + hash: hash, + shortHash: hash, + parentHashes: [], + authorName: "Test Author", + authorEmail: "author@example.com", + date: "2026-08-28T00:00:00Z", + subject: hash, + decorations: "" + ) + } +} diff --git a/macos/Tests/LitheTests/GitLogFilterListTests.swift b/macos/Tests/LitheTests/GitLogFilterListTests.swift new file mode 100644 index 00000000..4772f15e --- /dev/null +++ b/macos/Tests/LitheTests/GitLogFilterListTests.swift @@ -0,0 +1,298 @@ +import Foundation +@testable import Lithe +@testable import LitheGitModule +import Testing + +/// Guards the pure section builders behind the Git Log filter popovers: +/// deterministic grouping, ordering, query matching, pinned reset entries, +/// and identical per-row rules across browse and search modes (issue #302 +/// regression risk: unbounded native menus returning by accident). +struct GitLogFilterListTests { + private func reference( + _ shortName: String, + kind: GitReferenceKind = .local, + isCurrent: Bool = false, + upstream: String? = nil + ) -> GitReference { + let prefix: String + switch kind { + case .local: prefix = "refs/heads" + case .remote: prefix = "refs/remotes" + case .tag: prefix = "refs/tags" + } + return GitReference( + fullName: "\(prefix)/\(shortName)", + shortName: shortName, + kind: kind, + isCurrent: isCurrent, + upstreamShortName: upstream + ) + } + + @Test + func branchMenuBuildsStarredShortcutsAndFlyoutGroups() { + let references = [ + reference("origin/preview", kind: .remote), + reference("v1.0.0", kind: .tag), + reference("feature/login"), + reference("main", isCurrent: true, upstream: "origin/main"), + reference("origin/main", kind: .remote), + ] + + let menu = GitLogFilterList.branchMenu(references: references) + + // The reset entry is an explicit kind with a localized label. + #expect(menu.reset.kind == .allBranches) + #expect(menu.reset.rowTitleKey != nil) + + // Starred shortcuts: the checked-out branch first, then its upstream. + #expect(menu.starred.map(\.rowTitle) == ["main", "origin/main"]) + #expect(menu.starred.allSatisfy { $0.rowIsStarred }) + #expect(menu.starred.allSatisfy { + if case .starred = $0.kind { return true } + return false + }) + + // Non-empty groups only, ordered Local, remotes by name, Tags. + #expect(menu.groups.map(\.id) == ["local", "remote:origin", "tags"]) + #expect(menu.groups.map(\.title) == ["Local", "origin/…", "Tags"]) + // Fixed labels localize; data-derived remote titles do not. + #expect(menu.groups.map { $0.titleKey != nil } == [true, false, true]) + + // Flyout children keep full short names; locals sort the current + // branch first and keep their upstream as detail. + #expect(menu.groups[0].children.map(\.rowTitle) == ["main", "feature/login"]) + #expect(menu.groups[0].children[0].rowDetail == "origin/main") + #expect(menu.groups[0].children[1].rowDetail == nil) + #expect(menu.groups[1].children.map(\.rowTitle) == ["origin/main", "origin/preview"]) + #expect(menu.groups[2].children.map(\.rowTitle) == ["v1.0.0"]) + } + + @Test + func branchMenuOmitsEmptyGroupsAndStarredWithoutCurrentBranch() { + let references = [ + reference("origin/preview", kind: .remote), + reference("develop"), + ] + + let menu = GitLogFilterList.branchMenu(references: references) + + #expect(menu.starred.isEmpty) + #expect(menu.groups.map(\.id) == ["local", "remote:origin"]) + // A repository without locals or remotes still yields the Tags group. + let tagsOnly = GitLogFilterList.branchMenu(references: [ + reference("v1.0.0", kind: .tag), + ]) + #expect(tagsOnly.groups.map(\.id) == ["tags"]) + #expect(tagsOnly.groups[0].title == "Tags") + } + + @Test + func referenceRowsRenderIdenticallyAcrossBrowseAndSearchModes() { + // Regression for the review's mode-inconsistency finding: the same + // branch must render with the same title and detail whether the user + // is browsing groups or filtering by a query. + let references = [ + reference("feature/login"), + reference("origin/preview", kind: .remote), + reference("main", isCurrent: true, upstream: "origin/main"), + reference("origin/main", kind: .remote), + ] + let menu = GitLogFilterList.branchMenu(references: references) + let sections = GitLogFilterList.branchSections(references: references, query: "") + + for item in menu.groups.flatMap(\.children) { + let searchItem = sections.flatMap(\.items).first { $0.id == item.id } + #expect(searchItem != nil) + #expect(searchItem?.rowTitle == item.rowTitle) + #expect(searchItem?.rowDetail == item.rowDetail) + #expect(searchItem?.rowIsStarred == item.rowIsStarred) + } + + // Remotes carry no kind label in either mode; locals surface only + // their upstream as detail. + let login = sections.flatMap(\.items).first { $0.rowTitle == "feature/login" } + #expect(login?.rowDetail == nil) + let remote = sections.flatMap(\.items).first { $0.rowTitle == "origin/preview" } + #expect(remote?.rowDetail == nil) + } + + @Test + func branchSectionsCarryPinnedRegionWithResetAndStarredRows() { + let references = [ + reference("origin/main", kind: .remote), + reference("main", isCurrent: true, upstream: "origin/main"), + ] + + let sections = GitLogFilterList.branchSections(references: references, query: "") + + // The pinned region leads with the reset entry and the starred + // shortcuts, and only it may render the trailing divider. + #expect(sections.first?.isPinned == true) + #expect(sections.first?.items.map(\.rowTitle) == ["All Branches", "main", "origin/main"]) + #expect(sections.dropFirst().allSatisfy { !$0.isPinned }) + #expect(sections.dropFirst().map(\.title) == [nil, "Remote"]) + } + + @Test + func branchSectionsFilterPinnedRowsByQueryTitle() { + let references = [ + reference("origin/main", kind: .remote), + reference("main", isCurrent: true, upstream: "origin/main"), + ] + + // The starred shortcuts survive a matching query and disappear on a + // non-matching one, exactly like the reset entry. + let matching = GitLogFilterList.branchSections(references: references, query: "main") + #expect(matching.first?.isPinned == true) + #expect(matching.first?.items.map(\.rowTitle) == ["main", "origin/main"]) + + let nonMatching = GitLogFilterList.branchSections(references: references, query: "zzz") + #expect(nonMatching.first { $0.isPinned } == nil) + } + + @Test + func branchSectionsGroupAndOrderReferences() { + let references = [ + reference("origin/preview", kind: .remote), + reference("v1.0.0", kind: .tag), + reference("feature/login"), + reference("main", isCurrent: true, upstream: "origin/main"), + reference("feature/search"), + ] + + let sections = GitLogFilterList.branchSections(references: references, query: "") + + // Ungrouped locals first (current branch leading), then namespaces, + // remotes, and tags. + let groups = sections.dropFirst().filter { !$0.isPinned } + #expect(groups.map(\.title) == [nil, "feature", "Remote", "Tags"]) + #expect(groups.first?.items.map(\.rowTitle) == ["main"]) + + // Reference rows keep full names inside namespace groups. + let feature = groups.first { $0.title == "feature" } + #expect(feature?.items.map(\.rowTitle) == ["feature/login", "feature/search"]) + } + + @Test + func branchSectionsMatchQueryAgainstNameAndUpstream() { + let references = [ + reference("main", isCurrent: true, upstream: "origin/preview"), + reference("release/2.0", kind: .remote), + reference("v2-preview", kind: .tag), + ] + + // Matching is case-insensitive across short names and upstreams; the + // pinned entry is hidden unless the query matches its title. + let matched = GitLogFilterList.branchSections(references: references, query: "PREVIEW") + #expect(matched.flatMap(\.items).map(\.rowTitle) == ["main", "v2-preview"]) + + let resetOnly = GitLogFilterList.branchSections(references: [], query: "all") + #expect(resetOnly.flatMap(\.items).map(\.rowTitle) == ["All Branches"]) + } + + @Test + func authorSectionsPinResetEntriesAndSortAuthors() { + let authors = [ + GitLogAuthorOption(id: "bob|b@example.com", name: "Bob", email: "b@example.com"), + GitLogAuthorOption(id: "carol|c@example.com", name: "carol", email: "c@example.com"), + GitLogAuthorOption(id: "alice|a@example.com", name: "Alice", email: "a@example.com"), + ] + + let sections = GitLogFilterList.authorSections(authors: authors, query: "") + + #expect(sections.map(\.id) == ["pinned", "authors"]) + #expect(sections[0].isPinned) + #expect(!sections[1].isPinned) + #expect(sections[0].items.map(\.rowTitle) == ["All Users", "Me"]) + #expect(sections[1].items.map(\.rowTitle) == ["Alice", "Bob", "carol"]) + } + + @Test + func authorSectionsMatchQueryAgainstNameAndEmail() { + let authors = [ + GitLogAuthorOption(id: "bob|b@example.com", name: "Bob", email: "b@example.com"), + GitLogAuthorOption(id: "dana|d@example.com", name: "Dana", email: "d@example.com"), + ] + + let sections = GitLogFilterList.authorSections(authors: authors, query: "D@EXAMPLE") + #expect(sections.flatMap(\.items).map(\.rowTitle) == ["Dana"]) + } + + @Test + func authorFilterItemsMapBackToSelections() { + let resetEntry = GitLogFilterList.authorSections(authors: [], query: "all") + .flatMap(\.items) + .first + #expect(resetEntry?.kind == .allUsers) + #expect(resetEntry?.selection == nil) + + let meEntry = GitLogFilterList.authorSections(authors: [], query: "me") + .flatMap(\.items) + .first + #expect(meEntry?.kind == .currentUser) + #expect(meEntry?.selection == .currentUser) + + let authorSections = GitLogFilterList.authorSections( + authors: [GitLogAuthorOption(id: "a|a@example.com", name: "Ada", email: "a@example.com")], + query: "ada" + ) + let authorEntry = authorSections.flatMap(\.items).first + #expect(authorEntry?.selection == .author(name: "Ada", email: "a@example.com")) + } + + @Test + func filterItemsMatchSelectedState() { + let selectedBranch = reference("main", isCurrent: true) + + #expect(GitLogBranchFilterItem.allBranches.matches(selected: nil)) + #expect(!GitLogBranchFilterItem.allBranches.matches(selected: selectedBranch)) + #expect(GitLogBranchFilterItem.reference(selectedBranch).matches(selected: selectedBranch)) + #expect(!GitLogBranchFilterItem.reference(selectedBranch).matches(selected: nil)) + #expect(GitLogBranchFilterItem.starred(selectedBranch).matches(selected: selectedBranch)) + + #expect(GitLogAuthorFilterItem.allUsers.matches(selected: nil)) + #expect(!GitLogAuthorFilterItem.allUsers.matches(selected: .currentUser)) + #expect(GitLogAuthorFilterItem.currentUser.matches(selected: .currentUser)) + #expect(GitLogAuthorFilterItem.author(name: "Ada", email: "a@example.com") + .matches(selected: .author(name: "Ada", email: "a@example.com"))) + #expect(!GitLogAuthorFilterItem.author(name: "Ada", email: "a@example.com") + .matches(selected: .currentUser)) + } + + @Test + func fixedLabelsMatchQueriesByKeyAndLocalizedText() { + // The label must be findable through either wording so a zh-Hans user + // searching the displayed text still reaches the pinned entry. + let label = GitLogFilterFixedLabel(key: "All Branches") + + #expect(label.matches("All Branches")) + #expect(label.matches("all br")) + #expect(label.matches("全部分支", localizedTitle: "全部分支")) + #expect(label.matches("部分", localizedTitle: "全部分支")) + #expect(!label.matches("xyz", localizedTitle: "全部分支")) + + // Data rows never pick up the localized fallback; they match names. + let item = GitLogBranchFilterItem.reference(reference("feature/login")) + #expect(item.matches(query: "login")) + #expect(!item.matches(query: "全部分支")) + } + + @Test + func zhHansTableKeepsFixedFilterLabelsTranslated() throws { + // Pin the zh-Hans table against copy edits that drop or rename the + // fixed filter labels: search matching relies on these keys resolving + // to localized text in the app. + let testFile = URL(fileURLWithPath: #filePath) + let stringsURL = testFile + .deletingLastPathComponent() // LitheTests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // macos + .appendingPathComponent("Resources/zh-Hans.lproj/Localizable.strings") + let table = try #require(NSDictionary(contentsOf: stringsURL) as? [String: String]) + + for key in ["All Branches", "All Users", "Me", "Search users", "No matching users"] { + #expect(table[key]?.isEmpty == false, "missing zh-Hans translation for \(key)") + } + } +} diff --git a/macos/Tests/LitheTests/GitPushDialogPresentationTests.swift b/macos/Tests/LitheTests/GitPushDialogPresentationTests.swift new file mode 100644 index 00000000..72402038 --- /dev/null +++ b/macos/Tests/LitheTests/GitPushDialogPresentationTests.swift @@ -0,0 +1,37 @@ +@testable import Lithe +@testable import LitheGitModule +import Testing + +struct GitPushDialogPresentationTests { + @Test + func branchWithoutUpstreamOffersPublication() { + let reference = GitReference( + fullName: "refs/heads/feature/recent", + shortName: "feature/recent", + kind: .local, + isCurrent: true, + upstreamShortName: nil + ) + + let presentation = GitPushDialogPresentation(reference: reference) + + #expect(presentation.destination == "Publish feature/recent (Core selects default remote)") + #expect(presentation.actionTitle == "Publish Branch") + } + + @Test + func trackedBranchKeepsItsConfiguredDestination() { + let reference = GitReference( + fullName: "refs/heads/main", + shortName: "main", + kind: .local, + isCurrent: true, + upstreamShortName: "upstream/stable" + ) + + let presentation = GitPushDialogPresentation(reference: reference) + + #expect(presentation.destination == "Tracking upstream/stable") + #expect(presentation.actionTitle == "Push") + } +} diff --git a/macos/Tests/LitheTests/GoToLineInputTests.swift b/macos/Tests/LitheTests/GoToLineInputTests.swift new file mode 100644 index 00000000..6a3a224d --- /dev/null +++ b/macos/Tests/LitheTests/GoToLineInputTests.swift @@ -0,0 +1,145 @@ +import Foundation +import Testing +@testable import Lithe + +struct GoToLineInputTests { + @Test + func parsesLineOnlyInputAsZeroBasedLineWithZeroColumn() { + // "120" means 1-based line 120, line start; converted to 0-based here. + #expect(GoToLineInput.parse("120") == GoToLineInput(line: 119, column: 0)) + #expect(GoToLineInput.parse("1") == GoToLineInput(line: 0, column: 0)) + } + + @Test + func parsesLineAndColumnInput() { + #expect( + GoToLineInput.parse("120:35") + == GoToLineInput(line: 119, column: 34, hasExplicitColumn: true) + ) + } + + @Test + func marksExplicitColumnOnlyForColonInput() { + // 只有显式输入了列号才标记 hasExplicitColumn:行号跳转整行选中, + // 行:列跳转把 caret 放到该列 + #expect(GoToLineInput.parse("120")?.hasExplicitColumn == false) + #expect(GoToLineInput.parse("120:35")?.hasExplicitColumn == true) + #expect(GoToLineInput.parse(" 120 : 35 ")?.hasExplicitColumn == true) + } + + @Test + func toleratesWhitespaceAroundAndBetweenNumbers() { + #expect(GoToLineInput.parse(" 120 ") == GoToLineInput(line: 119, column: 0)) + #expect( + GoToLineInput.parse("12 : 34") + == GoToLineInput(line: 11, column: 33, hasExplicitColumn: true) + ) + } + + @Test + func rejectsEmptyNonNumericAndMultiColonInput() { + #expect(GoToLineInput.parse("") == nil) + #expect(GoToLineInput.parse(" ") == nil) + #expect(GoToLineInput.parse("abc") == nil) + #expect(GoToLineInput.parse("12abc") == nil) + #expect(GoToLineInput.parse("1:2:3") == nil) + #expect(GoToLineInput.parse("120:") == nil) + #expect(GoToLineInput.parse(":35") == nil) + } + + @Test + func rejectsZeroAndNegativeNumbers() { + // In 1-based input, zero and negatives are invalid values. + #expect(GoToLineInput.parse("0") == nil) + #expect(GoToLineInput.parse("-1") == nil) + #expect(GoToLineInput.parse("0:5") == nil) + #expect(GoToLineInput.parse("5:0") == nil) + #expect(GoToLineInput.parse("5:-2") == nil) + } + + @Test + func keepsInBoundsLineAndColumnUnchanged() { + let content = "first\nsecond line\nthird" + #expect(GoToLineInput.clamped(line: 0, column: 2, in: content) == GoToLineInput(line: 0, column: 2)) + #expect(GoToLineInput.clamped(line: 2, column: 4, in: content) == GoToLineInput(line: 2, column: 4)) + } + + @Test + func clampsOutOfRangeLineToLastLine() { + let content = "first\nsecond line\nthird" + #expect(GoToLineInput.clamped(line: 99, column: 0, in: content) == GoToLineInput(line: 2, column: 0)) + } + + @Test + func clampsOutOfRangeColumnToLineEnd() { + // Columns are counted in UTF-16 units, matching the editor caret's + // utf16Column convention. + let content = "first\nsecond line\nthird" + #expect(GoToLineInput.clamped(line: 1, column: 99, in: content) == GoToLineInput(line: 1, column: 11)) + } + + @Test + func clampsNegativeValuesToDocumentStart() { + let content = "first\nsecond line\nthird" + #expect(GoToLineInput.clamped(line: -3, column: -1, in: content) == GoToLineInput(line: 0, column: 0)) + } + + @Test + func clampsAnyInputInEmptyDocumentToOrigin() { + // An empty document (0 lines) only ever addresses the document start. + #expect(GoToLineInput.clamped(line: 4, column: 9, in: "") == GoToLineInput(line: 0, column: 0)) + } + + @Test + func clampsToTrailingEmptyLineAfterFinalNewline() { + // "a\n" has an addressable second line (the trailing empty line). + #expect(GoToLineInput.clamped(line: 9, column: 3, in: "a\n") == GoToLineInput(line: 1, column: 0)) + #expect(GoToLineInput.clamped(line: 9, column: 3, in: "a\nb") == GoToLineInput(line: 1, column: 1)) + } + + @Test + func clampsLineAndColumnInCRLFContent() { + // CRLF 终止符不计入上一行的列上限 + let content = "first\r\nsecond\r\nthird" + #expect(GoToLineInput.clamped(line: 0, column: 99, in: content) == GoToLineInput(line: 0, column: 5)) + #expect(GoToLineInput.clamped(line: 1, column: 99, in: content) == GoToLineInput(line: 1, column: 6)) + #expect(GoToLineInput.clamped(line: 2, column: 1, in: content) == GoToLineInput(line: 2, column: 1)) + #expect(GoToLineInput.clamped(line: 9, column: 0, in: content) == GoToLineInput(line: 2, column: 0)) + } + + @Test + func clampsLineAndColumnInCRonlyContent() { + // CR-only 换行与编辑器 TextLineIndex 的行索引规则一致 + let content = "a\rb" + #expect(GoToLineInput.clamped(line: 0, column: 99, in: content) == GoToLineInput(line: 0, column: 1)) + #expect(GoToLineInput.clamped(line: 1, column: 0, in: content) == GoToLineInput(line: 1, column: 0)) + #expect(GoToLineInput.clamped(line: 9, column: 0, in: content) == GoToLineInput(line: 1, column: 0)) + } + + @Test + func preservesExplicitColumnThroughClamping() { + // 收敛不改变显式列号标记 + let parsed = GoToLineInput.parse("99:2") + // 与 AppModel.goToLine 一致:把解析出的显式列号标记一并传入收敛 + let clamped = parsed.map { + GoToLineInput.clamped( + line: $0.line, + column: $0.column, + hasExplicitColumn: $0.hasExplicitColumn, + in: "a\nb" + ) + } + #expect(clamped?.hasExplicitColumn == true) + #expect(clamped == GoToLineInput(line: 1, column: 1, hasExplicitColumn: true)) + } + + @Test + func clampsColumnUsingUTF16LengthOfEmojiLine() { + // An emoji spans two UTF-16 units, so convergence counts UTF-16 + // length rather than character count. + let content = "a\u{1F600}b" + #expect(GoToLineInput.clamped(line: 0, column: 3, in: content) == GoToLineInput(line: 0, column: 3)) + #expect(GoToLineInput.clamped(line: 0, column: 4, in: content) == GoToLineInput(line: 0, column: 4)) + #expect(GoToLineInput.clamped(line: 0, column: 5, in: content) == GoToLineInput(line: 0, column: 4)) + } +} diff --git a/macos/Tests/LitheTests/GoToLineSelectionTests.swift b/macos/Tests/LitheTests/GoToLineSelectionTests.swift new file mode 100644 index 00000000..2baeb32a --- /dev/null +++ b/macos/Tests/LitheTests/GoToLineSelectionTests.swift @@ -0,0 +1,144 @@ +import Foundation +import Testing +@testable import Lithe + +/// 回归测试:Go to Line 跳转后编辑器的最终 caret/selection 范围。 +/// 只输入行号时整行选中(行尾终止符不计入选区);显式输入列号时 +/// caret 落在该列;行索引遵循 LF、CRLF、CR 三种终止符规则。 +struct GoToLineSelectionTests { + private let content = "first\nsecond line\nthird" as NSString + + @Test + func placesZeroLengthCaretAtExplicitColumn() { + // "120:35" 类输入:caret 落在第 2 行(0-based 1)第 4 列 + let range = GoToLineSelection.targetRange( + line: 1, + utf16Column: 4, + selectsWholeLine: false, + in: content + ) + #expect(range == NSRange(location: 10, length: 0)) + } + + @Test + func selectsWholeLineContentWithoutTerminator() { + let range = GoToLineSelection.targetRange( + line: 0, + utf16Column: 0, + selectsWholeLine: true, + in: content + ) + #expect(range == NSRange(location: 0, length: 5)) + } + + @Test + func clampsColumnBeyondLineEndToLastColumn() { + let range = GoToLineSelection.targetRange( + line: 1, + utf16Column: 99, + selectsWholeLine: false, + in: content + ) + // "second line" 长 11,行起点 6 → caret 在行尾(UTF-16 位置 17) + #expect(range == NSRange(location: 17, length: 0)) + } + + @Test + func clampsOutOfRangeLineToLastLine() { + let range = GoToLineSelection.targetRange( + line: 99, + utf16Column: 0, + selectsWholeLine: true, + in: content + ) + // 最后一行 "third" 从 18 开始,长 5 + #expect(range == NSRange(location: 18, length: 5)) + } + + @Test + func selectsWholeLineInCRLFContentWithoutCarriageReturn() { + // CRLF 文件的整行选区不能把 \r 带进来 + let crlf = "ab\r\ncd" as NSString + let range = GoToLineSelection.targetRange( + line: 0, + utf16Column: 0, + selectsWholeLine: true, + in: crlf + ) + #expect(range == NSRange(location: 0, length: 2)) + + let caret = GoToLineSelection.targetRange( + line: 0, + utf16Column: 99, + selectsWholeLine: false, + in: crlf + ) + // caret 收敛到行内容末尾(\r 之前) + #expect(caret == NSRange(location: 2, length: 0)) + } + + @Test + func indexesCRonlyContentByLine() { + // CR-only 换行同样按行定位 + let crOnly = "a\rb" as NSString + let wholeLine = GoToLineSelection.targetRange( + line: 1, + utf16Column: 0, + selectsWholeLine: true, + in: crOnly + ) + #expect(wholeLine == NSRange(location: 2, length: 1)) + + let caret = GoToLineSelection.targetRange( + line: 0, + utf16Column: 0, + selectsWholeLine: false, + in: crOnly + ) + #expect(caret == NSRange(location: 0, length: 0)) + } + + @Test + func clampsAnyTargetInEmptyDocumentToOrigin() { + let empty = "" as NSString + let wholeLine = GoToLineSelection.targetRange( + line: 4, + utf16Column: 9, + selectsWholeLine: true, + in: empty + ) + #expect(wholeLine == NSRange(location: 0, length: 0)) + + let caret = GoToLineSelection.targetRange( + line: 4, + utf16Column: 9, + selectsWholeLine: false, + in: empty + ) + #expect(caret == NSRange(location: 0, length: 0)) + } + + @Test + func trailingNewlineYieldsEmptyFinalLineSelection() { + // "a\n" 存在可定位的第 2 行(末尾空行),整行选区为零长度 + let trailing = "a\n" as NSString + let range = GoToLineSelection.targetRange( + line: 9, + utf16Column: 0, + selectsWholeLine: true, + in: trailing + ) + #expect(range == NSRange(location: 2, length: 0)) + } + + @Test + func clampsNegativeLineAndColumnToOrigin() { + let range = GoToLineSelection.targetRange( + line: -3, + utf16Column: -1, + selectsWholeLine: false, + in: content + ) + #expect(range == NSRange(location: 0, length: 0)) + } +} diff --git a/macos/Tests/LitheTests/KeyboardShortcutTests.swift b/macos/Tests/LitheTests/KeyboardShortcutTests.swift index 61b035fc..c2751bca 100644 --- a/macos/Tests/LitheTests/KeyboardShortcutTests.swift +++ b/macos/Tests/LitheTests/KeyboardShortcutTests.swift @@ -8,7 +8,7 @@ struct KeyboardShortcutTests { @Test func catalogHasStableUniqueCommandsAndConflictFreeDefaults() { let commands = LitheCommandCatalog.commands - #expect(commands.count == 37) + #expect(commands.count == 39) #expect(Set(commands.map(\.id)).count == commands.count) let owners = commands.flatMap { command in diff --git a/macos/Tests/LitheTests/LitheCoreLogicTests.swift b/macos/Tests/LitheTests/LitheCoreLogicTests.swift index 17d575b3..a2a93d8e 100644 --- a/macos/Tests/LitheTests/LitheCoreLogicTests.swift +++ b/macos/Tests/LitheTests/LitheCoreLogicTests.swift @@ -2,9 +2,11 @@ import AppKit import Combine import CoreServices import Foundation +import LitheApplicationKernel @testable import LitheDatabaseModule @testable import LitheGitModule import LitheLocalHistoryModule +import LitheModuleAPI import LitheSearchModule import Testing import LitheTerminalModule @@ -88,6 +90,205 @@ struct LitheCoreLogicTests { #expect(sessions.closeActiveProjectCallCount == 1) } + @Test + @MainActor + func commandWClosesActiveWorkbenchContentBeforeTheWindow() { + let sessions = TestProjectWindowSessions(hasActiveProject: true) + sessions.consumesWorkbenchCloseCommand = true + let coordinator = LitheWindowCoordinator(projectSessions: sessions) + let window = CloseCommandTestWindow() + coordinator.attach(to: window, layout: .workspace) + + coordinator.performCloseCommand() + + #expect(sessions.closeActiveWorkbenchItemCallCount == 1) + #expect(window.performCloseCallCount == 0) + #expect(sessions.closeActiveProjectCallCount == 0) + } + + @Test + @MainActor + func commandWUsesNativeWindowCloseAfterWorkbenchContentIsGone() async { + let sessions = TestProjectWindowSessions(hasActiveProject: true) + let coordinator = LitheWindowCoordinator(projectSessions: sessions) + let window = CloseCommandTestWindow() + coordinator.attach(to: window, layout: .workspace) + + coordinator.performCloseCommand() + + #expect(sessions.closeActiveWorkbenchItemCallCount == 1) + #expect(window.performCloseCallCount == 1) + #expect(!window.delegateAllowedClose) + #expect(await window.waitUntilNativeCloseAllowed()) + #expect(window.performCloseCallCount == 2) + #expect(window.delegateAllowedClose) + #expect(sessions.resetForProjectWindowCloseCallCount == 1) + #expect(sessions.closeActiveProjectCallCount == 0) + } + + @Test + @MainActor + func commandWWaitsForProjectCleanupBeforeAllowingNativeClose() async { + let cleanupStarted = TestGate() + let releaseCleanup = TestGate() + let sessions = TestProjectWindowSessions(hasActiveProject: true) + sessions.projectWindowCleanupStarted = cleanupStarted + sessions.projectWindowCleanupRelease = releaseCleanup + let coordinator = LitheWindowCoordinator(projectSessions: sessions) + let window = CloseCommandTestWindow() + coordinator.attach(to: window, layout: .workspace) + defer { releaseCleanup.open() } + + coordinator.performCloseCommand() + + #expect(await cleanupStarted.waitUntilOpen()) + #expect(window.performCloseCallCount == 1) + #expect(!window.delegateAllowedClose) + + releaseCleanup.open() + + #expect(await window.waitUntilNativeCloseAllowed()) + #expect(window.performCloseCallCount == 2) + #expect(window.delegateAllowedClose) + #expect(sessions.resetForProjectWindowCloseCallCount == 1) + } + + @Test + @MainActor + func commandWCancelDoesNotResetSessionsOrLeakIntoTheNextWindowClose() { + let sessions = TestProjectWindowSessions(hasActiveProject: true) + let coordinator = LitheWindowCoordinator( + projectSessions: sessions, + confirmUnsavedDocuments: { _ in false } + ) + let window = CloseCommandTestWindow() + coordinator.attach(to: window, layout: .workspace) + + coordinator.performCloseCommand() + + #expect(window.performCloseCallCount == 1) + #expect(!window.delegateAllowedClose) + #expect(sessions.resetForProjectWindowCloseCallCount == 0) + + #expect(!coordinator.windowShouldClose(window)) + #expect(sessions.requestCloseActiveSessionCallCount == 1) + } + + @Test + @MainActor + func commandWSaveFailureDoesNotResetSessions() { + let sessions = TestProjectWindowSessions(hasActiveProject: true) + sessions.hasUnsavedDocuments = true + sessions.saveAllDocumentsResult = false + let coordinator = LitheWindowCoordinator( + projectSessions: sessions, + confirmUnsavedDocuments: { owner in + #expect(owner.hasUnsavedDocuments) + #expect(!owner.saveAllDocuments()) + return false + } + ) + let window = CloseCommandTestWindow() + coordinator.attach(to: window, layout: .workspace) + + coordinator.performCloseCommand() + + #expect(!window.delegateAllowedClose) + #expect(sessions.saveAllDocumentsCallCount == 1) + #expect(sessions.resetForProjectWindowCloseCallCount == 0) + } + + @Test + @MainActor + func ordinaryWindowCloseStillClosesOnlyTheActiveSession() { + let sessions = TestProjectWindowSessions(hasActiveProject: true) + let coordinator = LitheWindowCoordinator(projectSessions: sessions) + + #expect(!coordinator.windowShouldClose(NSWindow())) + #expect(sessions.requestCloseActiveSessionCallCount == 1) + #expect(sessions.resetForProjectWindowCloseCallCount == 0) + } + + @Test + @MainActor + func closingAProjectWindowReplacesAllSessionsWithAnEmptyActiveSession() async { + let store = MutableKeyValueStore() + let settings = AppSettings(store: store) + var createdModels: [AppModel] = [] + let manager = ProjectSessionManager( + settings: settings, + modelFactory: { + let model = AppModel( + settings: settings, + services: MacServiceContainer( + store: store, + settings: settings, + moduleLaunchMode: .safeMode + ).services + ) + createdModels.append(model) + return model + }, + newWindowOpener: { _ in } + ) + + manager.openStandaloneFile(URL(fileURLWithPath: "/tmp/lithe-close-first.swift")) + manager.openStandaloneFile(URL(fileURLWithPath: "/tmp/lithe-close-second.swift")) + let oldIDs = Set(manager.sessions.map(\.id)) + manager.pendingProjectOpen = PendingProjectOpen( + url: URL(fileURLWithPath: "/tmp/lithe-close-pending"), + sourceSessionID: manager.activeSessionID + ) + + await manager.resetForProjectWindowClose() + + #expect(manager.sessions.count == 1) + #expect(!oldIDs.contains(manager.activeSessionID)) + #expect(manager.activeModel.workspaceURL == nil) + #expect(manager.activeModel.standaloneFileURL == nil) + #expect(manager.pendingProjectOpen == nil) + #expect(manager.activeModel === createdModels.last) + } + + @Test + @MainActor + func projectWindowResetWaitsForModuleShutdownBeforeReplacingSessions() async throws { + let shutdownStarted = TestGate() + let releaseShutdown = TestGate() + let store = MutableKeyValueStore() + let settings = AppSettings(store: store) + let manager = ProjectSessionManager( + settings: settings, + modelFactory: { + AppModel( + settings: settings, + services: MacServiceContainer(store: store, settings: settings).services + ) + }, + newWindowOpener: { _ in } + ) + let previousModel = manager.activeModel + let runtime = previousModel.services.moduleRuntime + try runtime.register(ModuleFactory(manifest: projectWindowShutdownTestManifest) { + ProjectWindowShutdownTestModule( + shutdownStarted: shutdownStarted, + releaseShutdown: releaseShutdown + ) + }) + _ = try await runtime.activate(projectWindowShutdownTestManifest.id) + defer { releaseShutdown.open() } + + let resetTask = Task { await manager.resetForProjectWindowClose() } + + #expect(await shutdownStarted.waitUntilOpen()) + #expect(manager.activeModel === previousModel) + + releaseShutdown.open() + await resetTask.value + + #expect(manager.activeModel !== previousModel) + } + @Test @MainActor func closingTheWelcomeWindowAllowsTheApplicationToTerminate() { @@ -2264,8 +2465,8 @@ struct LitheCoreLogicTests { updates.append((index, count)) } - textView.syncFindState(isVisible: true, query: "") - textView.syncFindState(isVisible: true, query: "") + textView.syncFindState(isVisible: true, query: "", options: .default) + textView.syncFindState(isVisible: true, query: "", options: .default) #expect(updates.count == 1) #expect(updates.first?.index == -1) @@ -2317,7 +2518,7 @@ struct LitheCoreLogicTests { let textView = CodeTextView(frame: .zero) textView.string = "alpha beta alpha" textView.rebuildLineIndex() - textView.updateFindMatches(query: "alpha") + textView.updateFindMatches(query: "alpha", options: .default) #expect(textView.currentFindMatchCountForTesting == 2) textView.string = "Xalpha beta alpha" @@ -2336,13 +2537,77 @@ struct LitheCoreLogicTests { reportedStates.append("\(index):\(count)") } - textView.syncFindState(isVisible: true, query: "") - textView.syncFindState(isVisible: true, query: "alpha") - textView.syncFindState(isVisible: true, query: "alpha") + textView.syncFindState(isVisible: true, query: "", options: .default) + textView.syncFindState(isVisible: true, query: "alpha", options: .default) + textView.syncFindState(isVisible: true, query: "alpha", options: .default) #expect(reportedStates == ["-1:0", "0:2"]) } + @Test + @MainActor + func codeEditorKeepsCrossLineRegexMatchesAcrossEdits() { + // 正则可能产生跨行匹配:在匹配所在行附近编辑无关内容后, + // 整篇重算必须找回该匹配(行窗口增量曾把它移除且无法在窗口内复原)。 + let textView = CodeTextView(frame: .zero) + textView.string = "alpha\nbeta gamma" + textView.rebuildLineIndex() + let options = FindInFileOptions(regularExpression: true) + textView.updateFindMatches(query: "a\\nb", options: options) + #expect(textView.currentFindMatchCountForTesting == 1) + #expect(textView.findMatchLocationsForTesting == [4]) + + textView.string = "alpha\nbeXta gamma" + textView.applyFindEdit( + replacedRange: NSRange(location: 8, length: 0), + insertedLength: 1, + query: "a\\nb" + ) + #expect(textView.currentFindMatchCountForTesting == 1) + #expect(textView.findMatchLocationsForTesting == [4]) + } + + @Test + @MainActor + func replaceNotificationsOnlyApplyToTheBoundDocument() { + let textView = CodeTextView(frame: .zero) + let documentID = UUID() + textView.documentID = documentID + textView.string = "foo bar" + textView.updateFindMatches(query: "foo", options: .default) + + // 文档不匹配的替换通知必须被忽略,防止分栏时误伤其他编辑器 + NotificationCenter.default.post( + name: .litheFindReplaceNext, + object: nil, + userInfo: [ + FindNotificationKeys.documentID: UUID(), + FindNotificationKeys.replacement: "baz" + ] + ) + #expect(textView.string == "foo bar") + + NotificationCenter.default.post( + name: .litheFindReplaceNext, + object: nil, + userInfo: [ + FindNotificationKeys.documentID: documentID, + FindNotificationKeys.replacement: "baz" + ] + ) + #expect(textView.string == "baz bar") + + NotificationCenter.default.post( + name: .litheFindReplaceAll, + object: nil, + userInfo: [ + FindNotificationKeys.documentID: UUID(), + FindNotificationKeys.replacement: "qux" + ] + ) + #expect(textView.string == "baz bar") + } + @Test func doubleShiftRecognizerRequiresTwoStandaloneTaps() { var recognizer = DoubleShiftGestureRecognizer(threshold: 0.35) @@ -2693,11 +2958,52 @@ struct LitheCoreLogicTests { } } +private let projectWindowShutdownTestManifest = ModuleManifest( + id: ModuleID("dev.lithe.tests.project-window-shutdown"), + displayName: "Project Window Shutdown Test Module", + scope: .application, + defaultState: .enabled, + activationPolicy: .onDemand +) + +@MainActor +private final class ProjectWindowShutdownTestModule: LitheModule { + let manifest = projectWindowShutdownTestManifest + private let shutdownStarted: TestGate + private let releaseShutdown: TestGate + + init(shutdownStarted: TestGate, releaseShutdown: TestGate) { + self.shutdownStarted = shutdownStarted + self.releaseShutdown = releaseShutdown + } + + func activate(context: ModuleContext) async throws {} + func prepareForSleep() async throws {} + func sleep() async {} + + func shutdown() async { + shutdownStarted.open() + _ = await releaseShutdown.waitUntilOpen() + } + + func exportedCapabilities() -> [ModuleCapabilityID: AnyObject] { [:] } +} + @MainActor private final class TestProjectWindowSessions: ProjectWindowSessionHandling { var hasActiveProject: Bool var hasActiveStandaloneFile = false + var consumesWorkbenchCloseCommand = false + var hasUnsavedDocuments = false + var unsavedDocumentNames: [String] = [] + var saveAllDocumentsResult = true + var projectWindowCleanupStarted: TestGate? + var projectWindowCleanupRelease: TestGate? private(set) var closeActiveProjectCallCount = 0 + private(set) var closeActiveWorkbenchItemCallCount = 0 + private(set) var requestCloseActiveSessionCallCount = 0 + private(set) var resetForProjectWindowCloseCallCount = 0 + private(set) var saveAllDocumentsCallCount = 0 init(hasActiveProject: Bool) { self.hasActiveProject = hasActiveProject @@ -2707,10 +3013,48 @@ private final class TestProjectWindowSessions: ProjectWindowSessionHandling { closeActiveProjectCallCount += 1 } + func requestCloseActiveWorkbenchItem() -> Bool { + closeActiveWorkbenchItemCallCount += 1 + return consumesWorkbenchCloseCommand + } + func requestCloseActiveSession() -> Bool { + requestCloseActiveSessionCallCount += 1 closeActiveProject() return false } + + func saveAllDocuments() -> Bool { + saveAllDocumentsCallCount += 1 + return saveAllDocumentsResult + } + + func resetForProjectWindowClose() async { + resetForProjectWindowCloseCallCount += 1 + projectWindowCleanupStarted?.open() + if let projectWindowCleanupRelease { + _ = await projectWindowCleanupRelease.waitUntilOpen() + } + } +} + +@MainActor +private final class CloseCommandTestWindow: NSWindow { + private(set) var performCloseCallCount = 0 + private(set) var delegateAllowedClose = false + private let nativeCloseAllowed = TestGate() + + override func performClose(_ sender: Any?) { + performCloseCallCount += 1 + delegateAllowedClose = delegate?.windowShouldClose?(self) ?? true + if delegateAllowedClose { + nativeCloseAllowed.open() + } + } + + func waitUntilNativeCloseAllowed() async -> Bool { + await nativeCloseAllowed.waitUntilOpen() + } } private final class RecordingProcessRunner: ProcessRunner, DatabaseProcessRunning, @unchecked Sendable { diff --git a/macos/Tests/LitheTests/MavenRuntimeTests.swift b/macos/Tests/LitheTests/MavenRuntimeTests.swift index 45ee9d9d..c3245fde 100644 --- a/macos/Tests/LitheTests/MavenRuntimeTests.swift +++ b/macos/Tests/LitheTests/MavenRuntimeTests.swift @@ -1,9 +1,29 @@ import Foundation +import LitheCoreContracts import Testing @testable import Lithe @Suite("Maven and runtime integration") struct MavenRuntimeTests { + @Test + func mavenLifecyclePhasesMatchTheSharedPlatformContract() throws { + let fixture = try Self.platformContractFixture() + #expect(MavenLifecyclePhase.allCases.map(\.rawValue) == fixture.lifecyclePhases) + } + + @Test + func macMavenStorageIdentityMatchesTheSharedPlatformContract() throws { + let fixture = try Self.platformContractFixture() + let macCases = fixture.storageIdentityCases.filter { $0.platform == "macos" } + #expect(macCases.count == 1) + for item in macCases { + #expect(MacMavenConfigurationStore.storageIdentity( + workspacePath: item.workspacePath, + reactorPath: item.reactorPath + ) == item.expectedIdentity) + } + } + @Test func mavenScanPayloadDecodesRustCamelCaseIdentifiers() throws { let json = #""" @@ -88,6 +108,74 @@ struct MavenRuntimeTests { #expect(modules.map { $0.module.relativePath } == ["service"]) } + @Test + func mavenLaunchPlanPayloadDecodesSharedCoreResponse() throws { + let json = #""" + { + "version": 1, + "executable": { "toolchain": "project-maven" }, + "arguments": ["-B", "-ntp", "verify"], + "workingDirectory": "projects/demo", + "configurationFingerprint": "sha256:fixture" + } + """# + + let payload = try JSONDecoder().decode( + RustCoreBridge.MavenLaunchPlanPayload.self, + from: Data(json.utf8) + ) + let plan = payload.makeModel() + + #expect(plan.version == 1) + #expect(plan.toolchain == "project-maven") + #expect(plan.arguments == ["-B", "-ntp", "verify"]) + #expect(plan.workingDirectory == "projects/demo") + #expect(plan.configurationFingerprint == "sha256:fixture") + } + + @Test + func mavenConfigurationSeparatesPortableAndLocalPaths() throws { + let testRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-maven-store-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: testRoot) } + let workspace = testRoot.appendingPathComponent("workspace", isDirectory: true) + try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true) + let storage = MavenTestFileStorage(root: testRoot) + let store = MacMavenConfigurationStore(storage: storage) + let configuration = MavenStoredConfiguration( + portable: MavenPortableConfiguration( + selectedProfiles: ["dev", "qa"], + customProfiles: ["qa"], + skipTests: true + ), + local: MavenLocalConfiguration( + settingsPath: "/private/settings.xml", + mavenExecutablePath: "/private/apache-maven", + javaHomePath: "/private/jdk" + ) + ) + + try store.saveMavenConfiguration( + configuration, + workspaceURL: workspace, + reactorPath: "." + ) + + let portableURL = workspace.appendingPathComponent(".lithe/maven/config.json") + let portableText = String(decoding: try Data(contentsOf: portableURL), as: UTF8.self) + #expect(portableText.contains("\"selectedProfiles\"")) + #expect(!portableText.contains("/private/")) + #expect(try store.loadMavenConfiguration( + workspaceURL: workspace, + reactorPath: "." + ) == configuration) + let localFiles = try FileManager.default.contentsOfDirectory( + at: storage.applicationSupportDirectory().appendingPathComponent("Lithe/Maven"), + includingPropertiesForKeys: nil + ) + #expect(localFiles.count == 1) + } + @Test @MainActor func projectRelativeJavaOverridesResolveAgainstProjectRoot() { @@ -106,6 +194,54 @@ struct MavenRuntimeTests { #expect(service.mavenJavaHomeURL(overridePath: "toolchains/maven-jdk") == mavenJavaHome) } + @Test + @MainActor + func mavenOverrideUsesRuntimeLocatorForHomeExecutableAndInvalidPaths() { + let root = URL(fileURLWithPath: "/workspace", isDirectory: true) + let home = root.appendingPathComponent("toolchains/maven", isDirectory: true) + let homeExecutable = home.appendingPathComponent("bin/mvn") + let directExecutable = root.appendingPathComponent("toolchains/custom-mvn") + let locator = MavenOverrideRuntimeLocator(resolutions: [ + home.standardizedFileURL.path: homeExecutable, + directExecutable.standardizedFileURL.path: directExecutable + ]) + let service = ProjectRuntimeService(runtimeLocator: locator, store: EmptyKeyValueStore()) + + #expect(service.mavenExecutable(at: root, overridePath: "toolchains/maven") == homeExecutable) + #expect(service.mavenExecutable(at: root, overridePath: directExecutable.path) == directExecutable) + #expect(service.mavenExecutable(at: root, overridePath: "toolchains/missing") == nil) + } + + @Test + func macRuntimeDiscoveryDistinguishesMavenHomeFromExecutable() throws { + let testRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-maven-runtime-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: testRoot) } + let home = testRoot.appendingPathComponent("apache-maven", isDirectory: true) + let homeExecutable = home.appendingPathComponent("bin/mvn") + let directExecutable = testRoot.appendingPathComponent("custom-mvn") + let invalidHome = testRoot.appendingPathComponent("invalid-maven", isDirectory: true) + try FileManager.default.createDirectory( + at: homeExecutable.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try FileManager.default.createDirectory(at: invalidHome, withIntermediateDirectories: true) + #expect(FileManager.default.createFile(atPath: homeExecutable.path, contents: Data())) + #expect(FileManager.default.createFile(atPath: directExecutable.path, contents: Data())) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: homeExecutable.path + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: directExecutable.path + ) + + #expect(MacRuntimeDiscovery.mavenExecutable(forHomePath: home.path) == homeExecutable) + #expect(MacRuntimeDiscovery.mavenExecutable(forHomePath: directExecutable.path) == directExecutable) + #expect(MacRuntimeDiscovery.mavenExecutable(forHomePath: invalidHome.path) == nil) + } + @Test @MainActor func canceledRuntimeDiscoveryClearsDiscoveringState() async throws { @@ -125,6 +261,82 @@ struct MavenRuntimeTests { #expect(!service.isDiscovering) } + + private static func platformContractFixture() throws -> MavenPlatformContractFixture { + let repositoryRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let url = repositoryRoot.appendingPathComponent( + "shared/fixtures/maven/platform-contract-v1.json" + ) + return try JSONDecoder().decode( + MavenPlatformContractFixture.self, + from: Data(contentsOf: url) + ) + } +} + +private struct MavenPlatformContractFixture: Decodable { + struct StorageIdentityCase: Decodable { + let platform: String + let workspacePath: String + let reactorPath: String + let expectedIdentity: String + } + + let lifecyclePhases: [String] + let storageIdentityCases: [StorageIdentityCase] +} + +private struct MavenTestFileStorage: FileStorage { + let root: URL + + func homeDirectory() -> URL { root.appendingPathComponent("home", isDirectory: true) } + func cacheDirectory() -> URL { root.appendingPathComponent("cache", isDirectory: true) } + func applicationSupportDirectory() -> URL { + root.appendingPathComponent("application-support", isDirectory: true) + } + func temporaryDirectory() -> URL { root.appendingPathComponent("temporary", isDirectory: true) } + func metadata(for url: URL) -> FileMetadata? { + guard let values = try? url.resourceValues(forKeys: [ + .fileSizeKey, .contentModificationDateKey, .isRegularFileKey, .isDirectoryKey + ]) else { return nil } + return FileMetadata( + byteCount: values.fileSize, + modificationDate: values.contentModificationDate, + isRegularFile: values.isRegularFile == true, + isDirectory: values.isDirectory == true + ) + } + func fileExists(at url: URL) -> Bool { FileManager.default.fileExists(atPath: url.path) } + func isExecutable(at url: URL) -> Bool { FileManager.default.isExecutableFile(atPath: url.path) } + func listDirectory(at url: URL) -> [URL] { + (try? FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil)) ?? [] + } + func readPrefix(from url: URL, byteCount: Int) throws -> Data { + Data(try Data(contentsOf: url).prefix(byteCount)) + } + func readData(from url: URL, options: Data.ReadingOptions) throws -> Data { + try Data(contentsOf: url, options: options) + } + func writeData(_ data: Data, to url: URL, options: Data.WritingOptions) throws { + try data.write(to: url, options: options) + } + func createDirectory(at url: URL, withIntermediateDirectories: Bool) throws { + try FileManager.default.createDirectory( + at: url, + withIntermediateDirectories: withIntermediateDirectories + ) + } + func removeItem(at url: URL) throws { try FileManager.default.removeItem(at: url) } + func moveItem(at sourceURL: URL, to destinationURL: URL) throws { + try FileManager.default.moveItem(at: sourceURL, to: destinationURL) + } + func copyItem(at sourceURL: URL, to destinationURL: URL) throws { + try FileManager.default.copyItem(at: sourceURL, to: destinationURL) + } } private struct ProjectRelativeRuntimeLocator: RuntimeLocator { @@ -145,6 +357,26 @@ private struct ProjectRelativeRuntimeLocator: RuntimeLocator { func javaLanguageServerExecutable() -> URL? { nil } } +private struct MavenOverrideRuntimeLocator: RuntimeLocator { + let resolutions: [String: URL] + + func environment() -> [String: String] { [:] } + func discover() -> RuntimeDiscoveryResult { + RuntimeDiscoveryResult(javaRuntimes: [], mavenRuntimes: []) + } + func validJavaHome(path: String) -> URL? { nil } + func javaRuntime(at homeURL: URL) -> JavaRuntimeCandidate? { nil } + func isExecutable(at url: URL) -> Bool { + resolutions[url.standardizedFileURL.path]?.standardizedFileURL == url.standardizedFileURL + } + func systemMavenExecutable() -> URL? { nil } + func mavenExecutable(forHomePath path: String) -> URL? { + resolutions[URL(fileURLWithPath: path).standardizedFileURL.path] + } + func mavenRuntime(at executableURL: URL) -> MavenRuntimeCandidate? { nil } + func systemJDBExecutable() -> URL? { nil } +} + private final class BlockingRuntimeLocator: RuntimeLocator, @unchecked Sendable { private let started = TestGate() private let releaseGate = TestGate() diff --git a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift index cdb44a3c..9ebb3d9e 100644 --- a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -1708,16 +1708,26 @@ struct RunConfigurationIntegrationTests { jdtlsLaunchResourcesResolver: { _, _ in .available(resources) } ) let session = try #require(runtime.makeLanguageServerSession()) + let mavenContext = MavenLaunchContext( + reactorPath: ".", + profiles: ["dev", "enterprise"], + settingsPath: "/local/settings.xml", + skipTests: true, + mavenExecutablePath: "/local/maven/bin/mvn", + javaHomePath: "/local/jdk" + ) try session.start( rootURL: URL(fileURLWithPath: "/workspace", isDirectory: true), - workspaceFingerprint: nil + workspaceFingerprint: "workspace-fingerprint", + mavenContext: mavenContext ) let start = try #require(core.startCalls.last) #expect(start.runtimeExecutableURL?.path == "/jdk/bin/java") #expect(start.jdtlsLaunchResources == resources) - #expect(session.javaTestRunnerURL == resources.javaTestRunnerURL) + #expect(start.workspaceFingerprint == "workspace-fingerprint") + #expect(start.mavenContext == mavenContext) session.stop() } @@ -2998,7 +3008,8 @@ struct RunConfigurationIntegrationTests { await fixture.service.loadProject( at: fixture.root, files: [], - mavenProject: fixture.mavenProject + mavenProject: fixture.mavenProject, + snapshotID: UUID() ) #expect(fixture.service.configurationStatus == .missing) @@ -3031,7 +3042,8 @@ struct RunConfigurationIntegrationTests { await fixture.service.loadProject( at: fixture.root, files: [], - mavenProject: fixture.mavenProject + mavenProject: fixture.mavenProject, + snapshotID: UUID() ) fixture.service.run(configuration: configuration, currentFileURL: nil) @@ -3515,7 +3527,8 @@ struct RunConfigurationIntegrationTests { await fixture.service.loadProject( at: fixture.root, files: [], - mavenProject: fixture.mavenProject + mavenProject: fixture.mavenProject, + snapshotID: UUID() ) await fixture.service.generateRunConfigurations() @@ -3666,6 +3679,7 @@ struct RunConfigurationIntegrationTests { try store.saveOptions( RunOptions( javaHomePath: "/test/jdk-21", + mavenSkipTests: false, mavenExecutablePath: "/test/maven/bin/mvn", mavenJavaHomePath: "/test/maven-jdk" ), @@ -3679,6 +3693,7 @@ struct RunConfigurationIntegrationTests { #expect(options.javaHomePath == "/test/jdk-21") #expect(options.mavenExecutablePath == "/test/maven/bin/mvn") #expect(options.mavenJavaHomePath == "/test/maven-jdk") + #expect(options.mavenSkipTests == false) } @Test @@ -4105,10 +4120,11 @@ struct RunConfigurationIntegrationTests { runConfigurationOperations: operations ) - await service.loadProject(at: root, files: [], mavenProject: nil) + let snapshotID = UUID() + await service.loadProject(at: root, files: [], mavenProject: nil, snapshotID: snapshotID) let generation = Task { await service.generateRunConfigurations() } #expect(await operations.waitUntilBlocked()) - await service.loadProject(at: root, files: [], mavenProject: nil) + await service.loadProject(at: root, files: [], mavenProject: nil, snapshotID: snapshotID) operations.releaseGeneration() await generation.value @@ -4733,6 +4749,8 @@ private final class TestLanguageServerRuntimeCore: LanguageServerRuntimeCore, @u let runtimeExecutableURL: URL? let jdtlsLaunchResources: JDTLSLaunchResources? let cacheDirectoryURL: URL? + let workspaceFingerprint: String? + let mavenContext: MavenLaunchContext? let initializeTimeout: TimeInterval let requestTimeout: TimeInterval let shutdownTimeout: TimeInterval @@ -4803,6 +4821,42 @@ private final class TestLanguageServerRuntimeCore: LanguageServerRuntimeCore, @u initializeTimeout: TimeInterval, requestTimeout: TimeInterval, shutdownTimeout: TimeInterval + ) -> Result { + startLanguageServer( + providerID: providerID, + executableURL: executableURL, + arguments: arguments, + environment: environment, + rootURL: rootURL, + workingDirectoryURL: workingDirectoryURL, + initializationOptions: initializationOptions, + runtimeExecutableURL: runtimeExecutableURL, + jdtlsLaunchResources: jdtlsLaunchResources, + cacheDirectoryURL: cacheDirectoryURL, + workspaceFingerprint: workspaceFingerprint, + mavenContext: nil, + initializeTimeout: initializeTimeout, + requestTimeout: requestTimeout, + shutdownTimeout: shutdownTimeout + ) + } + + func startLanguageServer( + providerID: String, + executableURL: URL, + arguments: [String], + environment: [String: String], + rootURL: URL, + workingDirectoryURL: URL, + initializationOptions: ToolingJSONValue?, + runtimeExecutableURL: URL?, + jdtlsLaunchResources: JDTLSLaunchResources?, + cacheDirectoryURL: URL?, + workspaceFingerprint: String?, + mavenContext: MavenLaunchContext?, + initializeTimeout: TimeInterval, + requestTimeout: TimeInterval, + shutdownTimeout: TimeInterval ) -> Result { startCalls.append(StartCall( providerID: providerID, @@ -4815,6 +4869,8 @@ private final class TestLanguageServerRuntimeCore: LanguageServerRuntimeCore, @u runtimeExecutableURL: runtimeExecutableURL, jdtlsLaunchResources: jdtlsLaunchResources, cacheDirectoryURL: cacheDirectoryURL, + workspaceFingerprint: workspaceFingerprint, + mavenContext: mavenContext, initializeTimeout: initializeTimeout, requestTimeout: requestTimeout, shutdownTimeout: shutdownTimeout @@ -5153,7 +5209,8 @@ private struct RunTestRuntimeLocator: RuntimeLocator { func isExecutable(at url: URL) -> Bool { url.lastPathComponent != "mvnw" } func systemMavenExecutable() -> URL? { URL(fileURLWithPath: "/toolchains/maven/bin/mvn") } func mavenExecutable(forHomePath path: String) -> URL? { - URL(fileURLWithPath: path, isDirectory: true).appendingPathComponent("bin/mvn") + let url = URL(fileURLWithPath: path) + return url.lastPathComponent == "mvn" ? url : url.appendingPathComponent("bin/mvn") } func mavenRuntime(at executableURL: URL) -> MavenRuntimeCandidate? { MavenRuntimeCandidate( diff --git a/macos/Tests/LitheTests/TerminalPlacementFeatureModelTests.swift b/macos/Tests/LitheTests/TerminalPlacementFeatureModelTests.swift index 1a8c85b1..69100978 100644 --- a/macos/Tests/LitheTests/TerminalPlacementFeatureModelTests.swift +++ b/macos/Tests/LitheTests/TerminalPlacementFeatureModelTests.swift @@ -1,4 +1,5 @@ import Foundation +import LitheModuleAPI import LitheTerminalModule import Testing @testable import Lithe @@ -70,6 +71,69 @@ struct TerminalPlacementFeatureModelTests { #expect(model.activeEditorSessionID == third) } + @Test + func cancelingRunningTerminalCloseKeepsTheSessionAlive() { + let context = makeTerminalCloseContext() + + #expect(context.model.requestCloseActiveWorkbenchItem()) + #expect(context.model.pendingTerminalCloseSessionID == context.session.id) + #expect(context.model.terminalSessions.contains { $0.id == context.session.id }) + #expect(context.transport.stopCount == 0) + + context.model.cancelTerminalClose() + + #expect(context.model.pendingTerminalCloseSessionID == nil) + #expect(context.model.terminalSessions.contains { $0.id == context.session.id }) + #expect(context.session.isRunning) + #expect(context.transport.stopCount == 0) + } + + @Test + func confirmingRunningTerminalCloseStopsAndRemovesTheSession() { + let context = makeTerminalCloseContext() + + #expect(context.model.requestCloseActiveWorkbenchItem()) + #expect(context.model.pendingTerminalCloseSessionID == context.session.id) + #expect(context.transport.stopCount == 0) + + context.model.confirmTerminalClose() + + #expect(context.model.pendingTerminalCloseSessionID == nil) + #expect(!context.model.terminalSessions.contains { $0.id == context.session.id }) + #expect(context.model.activeEditorTerminalSession == nil) + #expect(!context.session.isRunning) + #expect(context.transport.stopCount == 1) + } + + private func makeTerminalCloseContext() -> ( + model: AppModel, + session: TerminalSession, + transport: PlacementTestTerminalTransport + ) { + let store = TerminalPlacementTestStore() + let settings = AppSettings(store: store) + let services = MacServiceContainer( + store: store, + settings: settings, + moduleLaunchMode: .safeMode + ).services + let model = AppModel(settings: settings, services: services) + let transport = PlacementTestTerminalTransport() + let feature = TerminalFeatureModel(terminalFactory: { transport }) + model.cacheModuleCapability( + TerminalModuleCapability(feature: feature), + id: .terminalWorkspace, + moduleID: .terminal + ) + let session = feature.createSession( + in: URL(fileURLWithPath: "/tmp/lithe-terminal-close-tests"), + shellPath: "/bin/zsh" + ) + model.terminalPlacementFeature.registerSession(session.id) + model.terminalPlacementFeature.moveToEditor(session.id) + return (model, session, transport) + } + @Test func movingPresentationNeverStopsOrRecreatesTheTerminalTransport() { let transport = PlacementTestTerminalTransport() @@ -141,3 +205,13 @@ private final class PlacementTestTerminalTransport: TerminalTransport { isRunning = false } } + +private final class TerminalPlacementTestStore: KeyValueStore, @unchecked Sendable { + private var values: [String: Any] = [:] + + func data(forKey key: String) -> Data? { values[key] as? Data } + func object(forKey key: String) -> Any? { values[key] } + func string(forKey key: String) -> String? { values[key] as? String } + func stringArray(forKey key: String) -> [String]? { values[key] as? [String] } + func set(_ value: Any?, forKey key: String) { values[key] = value } +} diff --git a/rust/lithe-core/src/execution/configuration.rs b/rust/lithe-core/src/execution/configuration.rs index 6a3807e3..77a83413 100644 --- a/rust/lithe-core/src/execution/configuration.rs +++ b/rust/lithe-core/src/execution/configuration.rs @@ -81,6 +81,9 @@ pub struct LaunchPlanRequest { /// Host-owned local layer. When present, Core uses it instead of `.lithe/run/local.json`. #[serde(default)] pub local_document: Option, + /// Project Maven defaults supplied by the native host for Maven-backed plans. + #[serde(default)] + pub maven_context: Option, } #[derive(Debug, Deserialize)] @@ -118,6 +121,11 @@ pub struct UpdateOptionsRequest { pub environment: BTreeMap, #[serde(default)] pub maven_profiles: Vec, + /// Per-configuration Maven test policy. `None` inherits the project context; + /// `Some(false)` must remain distinct so a test configuration can override + /// a project-wide Skip Tests default. + #[serde(default)] + pub maven_skip_tests: Option, #[serde(default)] pub java_home_path: String, #[serde(default)] @@ -1117,15 +1125,15 @@ fn update_configuration_options( normalize_scoped_toolchain_path(&root, &request.scope, &request.maven_executable_path)?; let maven_java_home_path = normalize_scoped_toolchain_path(&root, &request.scope, &request.maven_java_home_path)?; - let working_directory = normalize_project_directory( - &root, - if request.working_directory.trim().is_empty() { - "." - } else { - request.working_directory.trim() - }, - false, - )?; + let working_directory = if request.working_directory.trim().is_empty() { + None + } else { + Some(normalize_project_directory( + &root, + request.working_directory.trim(), + false, + )?) + }; let mut document = if request.scope == "local" { local_layer_document(&root, request.local_document)? } else { @@ -1138,9 +1146,11 @@ fn update_configuration_options( .ok_or_else(|| CoreError::new(ErrorCode::ParseFailed, "configurations must be an array"))?; let mut patch = json!({ "id": request.configuration_id, - "cwd": working_directory, "env": request.environment }); + if let Some(working_directory) = working_directory.as_ref() { + patch["cwd"] = json!(working_directory); + } let mut java_extension = serde_json::Map::new(); if !java_home_path.is_empty() { java_extension.insert("homePath".to_string(), json!(java_home_path)); @@ -1155,14 +1165,25 @@ fn update_configuration_options( java_extension.insert("mavenJavaHomePath".to_string(), json!(maven_java_home_path)); } if uses_maven_capability { - let mut extensions = serde_json::Map::from_iter([( - "maven".to_string(), - json!({ - "jvmArguments": split_arguments(&request.jvm_arguments), - "programArguments": split_arguments(&request.arguments), - "profiles": request.maven_profiles.into_iter().collect::>() - }), - )]); + let mut maven_extension = serde_json::Map::from_iter([ + ( + "jvmArguments".to_string(), + json!(split_arguments(&request.jvm_arguments)), + ), + ( + "programArguments".to_string(), + json!(split_arguments(&request.arguments)), + ), + ( + "profiles".to_string(), + json!(request.maven_profiles.into_iter().collect::>()), + ), + ]); + if let Some(skip_tests) = request.maven_skip_tests { + maven_extension.insert("skipTests".to_string(), json!(skip_tests)); + } + let mut extensions = + serde_json::Map::from_iter([("maven".to_string(), Value::Object(maven_extension))]); if !java_extension.is_empty() { extensions.insert("java".to_string(), Value::Object(java_extension)); } @@ -1183,6 +1204,12 @@ fn update_configuration_options( ) })?; remove_java_toolchain_overrides(target)?; + if working_directory.is_none() { + target.remove("cwd"); + } + if uses_maven_capability && request.maven_skip_tests.is_none() { + remove_maven_skip_tests_override(target); + } for (key, value) in patch.as_object_mut().expect("patch is an object") { if key == "extensions" { merge_extensions(target, value)?; @@ -1234,6 +1261,18 @@ fn remove_java_toolchain_overrides( Ok(()) } +fn remove_maven_skip_tests_override(configuration: &mut serde_json::Map) { + let Some(maven) = configuration + .get_mut("extensions") + .and_then(Value::as_object_mut) + .and_then(|extensions| extensions.get_mut("maven")) + .and_then(Value::as_object_mut) + else { + return; + }; + maven.remove("skipTests"); +} + /// Creates a user configuration while preserving stable IDs in existing layers. pub fn create_user_configuration( request: CreateUserConfigurationRequest, @@ -1320,7 +1359,6 @@ pub fn create_user_configuration( "execution": if framework_goal(configuration_kind).is_some() { "service" } else { "task" }, "confidence": "native", "toolchains": {"java": "project-jdk", "maven": "project-maven"}, - "cwd": ".", "debug": {"adapter": "jdwp"}, "extensions": {"maven": maven} }); @@ -1340,10 +1378,16 @@ pub fn create_user_configuration( /// Resolves one configuration into the exact executable, arguments, and environment. pub fn create_launch_plan(request: LaunchPlanRequest) -> Result { let workspace_root = existing_root(&request.root)?; + let has_explicit_cwd_override = configuration_override_has_key( + &workspace_root, + request.local_document.as_ref(), + &request.configuration_id, + "cwd", + )?; let resolved = resolve(ResolveRequest { - root: request.root, + root: request.root.clone(), toolchain_candidates: Vec::new(), - local_document: request.local_document, + local_document: request.local_document.clone(), })?; let config = resolved["configurations"] .as_array() @@ -1429,9 +1473,11 @@ pub fn create_launch_plan(request: LaunchPlanRequest) -> Result Result>() - .join(",")), - ]); + if request.maven_context.is_none() { + arguments.extend([json!("-B"), json!("-ntp")]); + if let Some(module) = maven["module"].as_str().filter(|m| *m != ".") { + arguments.extend([json!("-pl"), json!(module)]); + } + if let Some(profiles) = maven["profiles"].as_array().filter(|p| !p.is_empty()) { + arguments.extend([ + json!("-P"), + json!(profiles + .iter() + .filter_map(Value::as_str) + .collect::>() + .join(",")), + ]); + } } if let Some(goal) = goal { arguments.extend(framework_arguments( @@ -1518,16 +1566,82 @@ pub fn create_launch_plan(request: LaunchPlanRequest) -> Result, + configuration_id: &str, + key: &str, +) -> Result { + let team = read_document_value(root, "run/configurations.json")? + .unwrap_or_else(|| json!({"version": VERSION, "configurations": []})); + let local = local_layer_document(root, provided_local.cloned())?; + for document in [&team, &local] { + validate_version_value(document)?; + if document["configurations"] + .as_array() + .and_then(|items| items.iter().find(|item| item["id"] == configuration_id)) + .is_some_and(|item| item.get(key).is_some()) + { + return Ok(true); + } + } + Ok(false) +} + /// Providers the launch layer assembles a JVM command line for, rather than /// spawning a process the detector described. /// diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index a3d28dc3..8132218d 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -1,5 +1,7 @@ //! Deterministic Git inspection and mutation behind the shared command contract. +mod mutations; + use crate::protocol::{CoreError, ErrorCode}; use crate::protocol::{ GitBlameLineResponse, GitBlameResponse, GitChange, GitCheckoutPreflightResponse, @@ -20,6 +22,10 @@ use std::process::Command; use std::thread; use std::time::Duration; +const RECENT_BRANCH_LIMIT: usize = 5; +const RECENT_BRANCH_REFLOG_LIMIT: &str = "100"; +const DEFAULT_BRANCH_FALLBACKS: [&str; 2] = ["main", "master"]; + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] /// Request for a deterministic porcelain status snapshot. @@ -552,6 +558,7 @@ fn write_with_trace(request: GitWriteRequest) -> Result return mutations::checkout_and_rebase(&root, request), "fetch" => arguments = vec!["fetch".into(), "--all".into(), "--prune".into()], // Strategy comes from the caller because only the user can decide whether a // divergent history should be merged or replayed. Absent a choice we stay on @@ -568,6 +575,17 @@ fn write_with_trace(request: GitWriteRequest) -> Result return push(&root, request.reference.as_deref()), "checkout" => return checkout(&root, request), @@ -626,7 +644,7 @@ fn write_with_trace(request: GitWriteRequest) -> Result, @@ -634,7 +652,7 @@ fn execute_git( execute_git_with_options(root, arguments, input, false) } -fn execute_git_readonly( +pub(super) fn execute_git_readonly( root: &str, arguments: &[String], input: Option, @@ -772,6 +790,7 @@ pub fn diff(request: GitDiffRequest) -> Result { )); } + let include_untracked_with_reference = request.reference.is_some() && request.untracked; let mut arguments = if let Some(commit) = request.commit { validate_revision(&commit)?; vec![ @@ -810,13 +829,52 @@ pub fn diff(request: GitDiffRequest) -> Result { arguments.push("--ignore-all-space".to_string()); } arguments.push("--".to_string()); - if request.untracked { + if request.untracked && !include_untracked_with_reference { arguments.push(null_device().to_string()); } arguments.extend(request.pathspecs); let root = validate_root(&request.root)?; - let output = capture_git_with_options(&root, &arguments, None, true)?; + let mut output = capture_git_with_options(&root, &arguments, None, true)?; + if include_untracked_with_reference { + let status = readonly_command(GitCommandRequest { + root: root.clone(), + arguments: vec![ + "status".into(), + "--porcelain".into(), + "--untracked-files=all".into(), + ], + input: None, + })?; + if status.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Git status failed") + .with_details(status.output), + ); + } + for line in status.output.lines().filter(|line| line.starts_with("?? ")) { + let path = line[3..].trim(); + if path.is_empty() || !is_safe_pathspec(path) { + continue; + } + let untracked = capture_git_with_options( + &root, + &[ + "diff".into(), + "--no-ext-diff".into(), + "--binary".into(), + "--no-index".into(), + "--".into(), + null_device().into(), + path.into(), + ], + None, + true, + )?; + output.stdout.extend(untracked.stdout); + output.stderr.extend(untracked.stderr); + } + } Ok(structured_diff_from_output(output)) } @@ -918,6 +976,7 @@ pub fn history(request: GitHistoryRequest) -> Result>(); + let recent_references = recent_local_references(&root, &references, RECENT_BRANCH_LIMIT); let mut arguments = vec!["log".to_string()]; if let Some(reference) = request.reference { @@ -959,6 +1018,7 @@ pub fn history(request: GitHistoryRequest) -> Result limit; Ok(GitHistoryResponse { references, + recent_references, commits: all_commits.into_iter().take(limit).collect(), has_more, user_name, @@ -966,6 +1026,113 @@ pub fn history(request: GitHistoryRequest) -> Result Vec { + let local_references = references + .iter() + .filter(|reference| reference.kind == "local") + .collect::>(); + let mut recent = Vec::with_capacity(limit.min(local_references.len())); + + if let Some(current) = local_references + .iter() + .find(|reference| reference.is_current) + { + append_recent_reference(&mut recent, &local_references, ¤t.short_name, limit); + } + + if let Some(reflog) = command_value( + root, + &[ + "reflog", + "show", + "-n", + RECENT_BRANCH_REFLOG_LIMIT, + "--format=%gs", + "HEAD", + ], + ) { + for line in reflog.lines() { + let Some(checkout) = line.strip_prefix("checkout: moving from ") else { + continue; + }; + let Some((source, destination)) = checkout.split_once(" to ") else { + continue; + }; + append_recent_reference(&mut recent, &local_references, destination, limit); + append_recent_reference(&mut recent, &local_references, source, limit); + if recent.len() >= limit { + break; + } + } + } + + if recent.len() < limit { + if let Some(remote_head) = command_value( + root, + &[ + "symbolic-ref", + "--quiet", + "--short", + "refs/remotes/origin/HEAD", + ], + ) { + append_recent_reference( + &mut recent, + &local_references, + remote_head + .split_once('/') + .map_or(remote_head.as_str(), |(_, branch)| branch), + limit, + ); + } + } + for branch in DEFAULT_BRANCH_FALLBACKS { + append_recent_reference(&mut recent, &local_references, branch, limit); + } + + for reference in &local_references { + append_recent_reference(&mut recent, &local_references, &reference.short_name, limit); + if recent.len() >= limit { + break; + } + } + + recent.into_iter().cloned().collect() +} + +fn append_recent_reference<'a>( + recent: &mut Vec<&'a GitReferenceResponse>, + references: &[&'a GitReferenceResponse], + raw_name: &str, + limit: usize, +) { + if recent.len() >= limit { + return; + } + let name = raw_name.trim().trim_start_matches("refs/heads/"); + let Some(reference) = references + .iter() + .find(|reference| reference.short_name == name) + else { + return; + }; + if !recent + .iter() + .any(|existing| existing.full_name == reference.full_name) + { + recent.push(*reference); + } +} + /// Reads one effective repository configuration value without making a missing /// optional value fail the surrounding history request. fn git_config_value(root: &str, key: &str) -> Option { @@ -1748,7 +1915,7 @@ fn validated_revision(value: Option<&str>) -> Result { Ok(value) } -fn validated_reference(value: Option<&str>) -> Result { +pub(super) fn validated_reference(value: Option<&str>) -> Result { let value = required_text(value, "reference")?; if value.starts_with('-') || value.chars().any(char::is_whitespace) { return Err(CoreError::new( @@ -1805,7 +1972,7 @@ fn local_branch_name(reference: &str) -> Result { Ok(branch.to_string()) } -fn current_branch(root: &str) -> Result { +pub(super) fn current_branch(root: &str) -> Result { let response = execute_git(root, &["branch".into(), "--show-current".into()], None)?; if response.exit_code != 0 { return Err(CoreError::new( @@ -2105,12 +2272,29 @@ fn publish_branch(root: &str, name: Option<&str>) -> Result Result { + if request.reference_kind.as_deref() == Some("local") { + if let Some(reference) = request + .reference + .as_deref() + .filter(|value| !value.starts_with('-') && !value.chars().any(char::is_whitespace)) + { + if is_current_reference(root, reference)? { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The current branch is already checked out", + )); + } + } + } if request.auto_stash { return checkout_with_auto_stash(root, request); } switch_reference(root, &request) } +/// Checks out a local or remote branch, then rebases it onto the branch that +/// was current before the switch. A dirty tree is rejected before checkout so +/// the composite operation cannot leave the repository half-switched. /// Stash, switch, restore. A failed switch leaves the stash untouched so the caller can /// recover it, and a conflicting restore is reported as a failure rather than silently /// leaving the entry behind. @@ -2162,7 +2346,7 @@ fn checkout_with_auto_stash( Ok(restored) } -fn switch_reference( +pub(super) fn switch_reference( root: &str, request: &GitWriteRequest, ) -> Result { @@ -2185,13 +2369,8 @@ fn switch_reference( execute_git(root, &base, None) } Some("remote") => { - let remote_path = reference.strip_prefix("refs/remotes/").ok_or_else(|| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid remote branch name") - })?; - let (_, local_name) = remote_path.split_once('/').ok_or_else(|| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid remote branch name") - })?; - if !is_safe_pathspec(local_name) { + let (_, local_name) = mutations::remote_branch_components(root, &reference)?; + if !is_safe_pathspec(&local_name) { return Err(CoreError::new( ErrorCode::InvalidRequest, "Invalid remote branch name", @@ -2326,7 +2505,7 @@ fn parse_stash(line: &str) -> Option { }) } -fn is_safe_pathspec(path: &str) -> bool { +pub(super) fn is_safe_pathspec(path: &str) -> bool { let normalized = path.replace('\\', "/"); !normalized.is_empty() && !normalized.starts_with('/') @@ -2979,7 +3158,9 @@ mod tests { line_similarity, pair_diff_entries, parse_diff, structured_diff_from_output, DiffEntry, GitCommandInvocation, GitCommandResponse, GitProcessOutput, MAX_ALIGNMENT_CELLS, }; - use crate::protocol::{CoreError, ErrorCode}; + use crate::protocol::{ + CoreError, ErrorCode, GitCommitResponse, GitHistoryResponse, GitReferenceResponse, + }; use serde_json::Value; #[cfg(target_os = "windows")] @@ -3219,6 +3400,51 @@ mod tests { ); } + #[test] + fn history_response_matches_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/git/history-response-v1.json" + ))) + .expect("Git history response fixture should be valid JSON"); + let feature = GitReferenceResponse { + full_name: "refs/heads/feature/recent".into(), + short_name: "feature/recent".into(), + kind: "local".into(), + is_current: true, + upstream_short_name: None, + }; + let main = GitReferenceResponse { + full_name: "refs/heads/main".into(), + short_name: "main".into(), + kind: "local".into(), + is_current: false, + upstream_short_name: Some("origin/main".into()), + }; + let response = GitHistoryResponse { + references: vec![feature.clone(), main.clone()], + recent_references: vec![feature, main], + commits: vec![GitCommitResponse { + hash: "0123456789abcdef0123456789abcdef01234567".into(), + short_hash: "0123456".into(), + parent_hashes: Vec::new(), + author_name: "Lithe Test".into(), + author_email: "test@example.invalid".into(), + date: "2026/08/30 12:00".into(), + subject: "Initial commit".into(), + decorations: "HEAD -> feature/recent".into(), + }], + has_more: false, + user_name: Some("Lithe Test".into()), + user_email: Some("test@example.invalid".into()), + }; + + assert_eq!( + serde_json::to_value(response).expect("Git history response should serialize"), + fixture + ); + } + #[test] fn command_error_response_matches_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!(concat!( diff --git a/rust/lithe-core/src/git/mutations.rs b/rust/lithe-core/src/git/mutations.rs new file mode 100644 index 00000000..f0d7e506 --- /dev/null +++ b/rust/lithe-core/src/git/mutations.rs @@ -0,0 +1,101 @@ +//! Shared Git mutation helpers kept outside the command facade. + +use super::{capture_git_with_options, is_safe_pathspec}; +use crate::protocol::{CoreError, ErrorCode}; + +use super::{ + current_branch, execute_git, switch_reference, validated_reference, GitCommandResponse, + GitWriteRequest, +}; + +/// Checks out a branch and rebases it onto the branch that was current before the switch. +pub(super) fn checkout_and_rebase( + root: &str, + request: GitWriteRequest, +) -> Result { + if !matches!(request.reference_kind.as_deref(), Some("local" | "remote")) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Checkout and rebase requires a local or remote branch", + )); + } + let original_branch = current_branch(root)?; + let reference = validated_reference(request.reference.as_deref())?; + if reference == original_branch || reference == format!("refs/heads/{original_branch}") { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The current branch cannot be checked out and rebased onto itself", + )); + } + let status = execute_git( + root, + &[ + "status".into(), + "--porcelain".into(), + "--untracked-files=normal".into(), + ], + None, + )?; + if status.exit_code != 0 { + return Ok(status); + } + if !status.output.trim().is_empty() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Checkout and rebase requires a clean working tree", + )); + } + let switched = switch_reference(root, &request)?; + if switched.exit_code != 0 { + return Ok(switched); + } + execute_git( + root, + &["rebase".into(), format!("refs/heads/{original_branch}")], + None, + ) +} + +pub(super) fn remote_branch_components( + root: &str, + reference: &str, +) -> Result<(String, String), CoreError> { + let remote_path = reference + .strip_prefix("refs/remotes/") + .ok_or_else(|| CoreError::new(ErrorCode::InvalidRequest, "Invalid remote branch name"))?; + let remotes = capture_git_with_options(root, &["remote".into()], None, true)?; + let remote_output = String::from_utf8_lossy(&remotes.stdout).to_string(); + if remotes.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Git remote lookup failed") + .with_details(String::from_utf8_lossy(&remotes.stderr).to_string()), + ); + } + let mut matches = remote_output + .lines() + .map(str::trim) + .filter(|remote| !remote.is_empty() && remote_path.starts_with(&format!("{remote}/"))) + .collect::>(); + matches.sort_by_key(|remote| std::cmp::Reverse(remote.len())); + let Some(remote) = matches.first().copied() else { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid remote branch name", + )); + }; + let branch = remote_path + .strip_prefix(&format!("{remote}/")) + .unwrap_or_default(); + if branch.is_empty() + || remote.starts_with('-') + || branch.starts_with('-') + || !is_safe_pathspec(remote) + || !is_safe_pathspec(branch) + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid remote branch name", + )); + } + Ok((remote.to_string(), branch.to_string())) +} diff --git a/rust/lithe-core/src/languages/spring.rs b/rust/lithe-core/src/languages/spring.rs index 1c974f38..e752599d 100644 --- a/rust/lithe-core/src/languages/spring.rs +++ b/rust/lithe-core/src/languages/spring.rs @@ -1186,6 +1186,7 @@ macro_rules! spring_annotations { #[cfg(test)] pub(crate) const ALL: &'static [Self] = &[$(Self::$variant),+]; + #[cfg(test)] pub(crate) fn name(self) -> &'static str { match self { $(Self::$variant => $name),+ @@ -1219,10 +1220,69 @@ spring_annotations! { Service => "Service", } +/// Declares the Spring web mapping annotations exactly once, and derives the +/// type, spelling, boundary pattern, fixed HTTP method, and test list from that +/// one declaration. `RequestMapping` carries `None` so methods come from its +/// `RequestMethod.*` arguments instead. +macro_rules! spring_mapping_annotations { + ($($variant:ident => $name:literal, $method:expr),+ $(,)?) => { + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + enum SpringMappingAnnotation { + $($variant),+ + } + + impl SpringMappingAnnotation { + /// Every recognized mapping annotation. Production selection walks + /// this list and keeps the earliest source match, not declaration + /// order by itself. + const ALL: &'static [Self] = &[$(Self::$variant),+]; + + #[cfg(test)] + fn name(self) -> &'static str { + match self { + $(Self::$variant => $name),+ + } + } + + fn pattern(self) -> &'static Regex { + match self { + $(Self::$variant => { + static PATTERN: LazyLock = + LazyLock::new(|| annotation_boundary_pattern($name)); + &PATTERN + })+ + } + } + + /// Fixed verb for method mappings; `None` for [`Self::RequestMapping`]. + fn fixed_http_method(self) -> Option<&'static str> { + match self { + $(Self::$variant => $method),+ + } + } + + #[cfg(test)] + fn is_present(self, context: &str) -> bool { + self.pattern().is_match(context) + } + } + }; +} + +spring_mapping_annotations! { + GetMapping => "GetMapping", Some("GET"), + PostMapping => "PostMapping", Some("POST"), + PutMapping => "PutMapping", Some("PUT"), + DeleteMapping => "DeleteMapping", Some("DELETE"), + PatchMapping => "PatchMapping", Some("PATCH"), + RequestMapping => "RequestMapping", None, +} + impl SpringAnnotation { - /// Annotations that declare a Spring component on a type declaration. The - /// order also drives the alternation in [`component_name`], so changing it - /// changes which annotation wins on a type carrying several of them. + /// Annotations that declare a Spring component on a type declaration. + /// Detection and naming share this closed set so they cannot drift; when + /// several are present, the leftmost source match wins rather than this + /// array's declaration order. const COMPONENTS: [Self; 6] = [ Self::Component, Self::Service, @@ -1246,24 +1306,31 @@ fn has_component_annotation(context: &str) -> bool { .any(|annotation| annotation.is_present(context)) } +/// Returns the explicit name of the earliest component annotation in source +/// order. The value is read only from that annotation's own argument list so a +/// later neighbor such as `@Component("c")` cannot pollute `@Service("s")`. +/// An empty string such as `@Component("")` is not a name; callers then use +/// the default type name. fn component_name(context: &str) -> Option { - // Built from COMPONENTS so the recognized set cannot diverge from the one - // has_component_annotation uses. - static ANNOTATION: LazyLock = LazyLock::new(|| { - let alternation = SpringAnnotation::COMPONENTS - .iter() - .map(|annotation| regex::escape(annotation.name())) - .collect::>() - .join("|"); - Regex::new(&format!( - r#"@({alternation})\s*\([^\)]*[\"']([^\"']+)[\"']"# - )) - .expect("escaped annotation names produce a valid pattern") - }); - ANNOTATION - .captures(context) - .and_then(|capture| capture.get(2)) - .map(|value| value.as_str().to_string()) + let start = earliest_component_annotation_start(context)?; + quoted_values(isolate_annotation_at(context, start)) + .into_iter() + .find(|value| !value.is_empty()) +} + +/// Locates every exact component annotation with the cached boundary patterns +/// and keeps the leftmost source start. Array order in [`SpringAnnotation::COMPONENTS`] +/// is not a priority. +fn earliest_component_annotation_start(context: &str) -> Option { + SpringAnnotation::COMPONENTS + .iter() + .filter_map(|annotation| { + annotation + .pattern() + .find(context) + .map(|found| found.start()) + }) + .min() } fn qualifier_names(context: &str) -> Vec { @@ -1277,13 +1344,16 @@ fn qualifier_names(context: &str) -> Vec { .collect() } +/// Returns declared `@Bean` aliases from the exact `@Bean` annotation only. +/// A prefix decoy such as `@BeanFactory("decoy")` cannot supply the name. fn bean_names(context: &str) -> Vec { - let Some(start) = context.find("@Bean") else { + let Some(found) = SpringAnnotation::Bean.pattern().find(context) else { return Vec::new(); }; - let remaining = &context[start..]; - let end = remaining.find(')').unwrap_or(remaining.len()); - quoted_values(&remaining[..end]) + quoted_values(isolate_annotation_at(context, found.start())) + .into_iter() + .filter(|value| !value.is_empty()) + .collect() } fn quoted_values(value: &str) -> Vec { @@ -1433,7 +1503,7 @@ fn endpoint_index(sources: &[(String, String)]) -> Vec { continue; } let (annotation, annotation_end) = annotation_block(&lines, index); - let Some((methods, routes)) = mapping(&annotation) else { + let Some((mapping_annotation, methods, routes)) = mapping(&annotation) else { index = annotation_end + 1; continue; }; @@ -1441,7 +1511,11 @@ fn endpoint_index(sources: &[(String, String)]) -> Vec { let declaration = declaration_index .and_then(|value| lines.get(value).copied()) .unwrap_or_default(); - if annotation.contains("@RequestMapping") && CLASS.is_match(declaration) { + // Class-level base routes come only from an exact @RequestMapping, not + // a longer custom name that merely starts with that spelling. + if mapping_annotation == SpringMappingAnnotation::RequestMapping + && CLASS.is_match(declaration) + { base_routes = routes; index = annotation_end + 1; continue; @@ -1477,35 +1551,115 @@ fn endpoint_index(sources: &[(String, String)]) -> Vec { endpoints } -fn mapping(annotation_text: &str) -> Option<(Vec, Vec)> { - for (annotation, method) in [ - ("@GetMapping", "GET"), - ("@PostMapping", "POST"), - ("@PutMapping", "PUT"), - ("@DeleteMapping", "DELETE"), - ("@PatchMapping", "PATCH"), - ] { - if annotation_text.contains(annotation) { - return Some((vec![method.to_string()], annotation_routes(annotation_text))); +/// Finds the earliest exact Mapping annotation in `annotation_text` and returns +/// its HTTP methods and routes. Custom names that only share a standard prefix +/// (for example `@GetMappingCustom`) do not match. +/// +/// When several Mapping annotations appear in one context, the leftmost source +/// span wins so selection does not depend on declaration order in +/// [`SpringMappingAnnotation::ALL`]. +fn mapping(annotation_text: &str) -> Option<(SpringMappingAnnotation, Vec, Vec)> { + let (annotation, isolated) = find_mapping_annotation(annotation_text)?; + let methods = match annotation.fixed_http_method() { + Some(method) => vec![method.to_string()], + None => request_mapping_methods(isolated), + }; + Some((annotation, methods, annotation_routes(isolated))) +} + +/// Returns the earliest exact Mapping annotation and the isolated annotation +/// text used for route and `RequestMethod` parsing. +fn find_mapping_annotation(text: &str) -> Option<(SpringMappingAnnotation, &str)> { + let mut best: Option<(usize, SpringMappingAnnotation)> = None; + for &annotation in SpringMappingAnnotation::ALL { + if let Some(found) = annotation.pattern().find(text) { + let start = found.start(); + match best { + Some((best_start, _)) if start >= best_start => {} + _ => best = Some((start, annotation)), + } } } - if annotation_text.contains("@RequestMapping") { - static METHOD_PATTERN: LazyLock = LazyLock::new(|| { - Regex::new(r"RequestMethod\.(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS|TRACE)") - .expect("literal pattern is valid") - }); - let mut methods = METHOD_PATTERN - .captures_iter(annotation_text) - .filter_map(|capture| capture.get(1).map(|value| value.as_str().to_string())) - .collect::>(); - if methods.is_empty() { - methods.push("ANY".to_string()); + let (start, annotation) = best?; + Some((annotation, isolate_annotation_at(text, start))) +} + +/// Slices one annotation starting at `start` (`@Name` followed by an optional +/// argument list) so name or route extraction cannot read string literals from +/// a neighboring decoy. +/// +/// A later annotation's `(` is not this annotation's argument list. After the +/// name, only whitespace may appear before `(`. Parentheses inside quoted +/// strings, including escaped quotes, do not change the argument-list depth. +fn isolate_annotation_at(text: &str, start: usize) -> &str { + let rest = &text[start..]; + let name_end = rest + .char_indices() + .skip(1) + .find(|(_, character)| { + !(character.is_ascii_alphanumeric() || *character == '_' || *character == '$') + }) + .map(|(index, _)| index) + .unwrap_or(rest.len()); + let after_name = rest.get(name_end..).unwrap_or(""); + let whitespace_len = after_name + .chars() + .take_while(|character| character.is_whitespace()) + .map(char::len_utf8) + .sum::(); + let after_whitespace = after_name.get(whitespace_len..).unwrap_or(""); + if !after_whitespace.starts_with('(') { + return &rest[..name_end]; + } + let open_index = name_end + whitespace_len; + let mut depth = 0isize; + let mut in_string = None; + let mut escaped = false; + for (index, character) in rest[open_index..].char_indices() { + if let Some(quote) = in_string { + if escaped { + escaped = false; + continue; + } + if character == '\\' { + escaped = true; + continue; + } + if character == quote { + in_string = None; + } + continue; + } + match character { + '"' | '\'' => in_string = Some(character), + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + return &rest[..open_index + index + 1]; + } + } + _ => {} } - methods.sort(); - methods.dedup(); - return Some((methods, annotation_routes(annotation_text))); } - None + rest +} + +fn request_mapping_methods(annotation: &str) -> Vec { + static METHOD_PATTERN: LazyLock = LazyLock::new(|| { + Regex::new(r"RequestMethod\.(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS|TRACE)") + .expect("literal pattern is valid") + }); + let mut methods = METHOD_PATTERN + .captures_iter(annotation) + .filter_map(|capture| capture.get(1).map(|value| value.as_str().to_string())) + .collect::>(); + if methods.is_empty() { + methods.push("ANY".to_string()); + } + methods.sort(); + methods.dedup(); + methods } fn annotation_routes(annotation: &str) -> Vec { @@ -1662,6 +1816,91 @@ mod tests { assert_eq!(component_name("@Qualifier(\"custom\")"), None); } + /// `@BeanFactory` shares a prefix with `@Bean`, so a naive `@Bean` search + /// would take the decoy name. Naming must reuse the cached boundary match. + #[test] + fn bean_names_uses_the_exact_bean_annotation_not_a_prefix() { + assert_eq!( + bean_names(r#"@BeanFactory("decoy") @Bean("real")"#), + vec!["real".to_string()] + ); + assert_eq!(bean_names(r#"@BeanFactory("decoy")"#), Vec::::new()); + assert_eq!(bean_names(r#"@Bean("real")"#), vec!["real".to_string()]); + assert_eq!(bean_names("@Bean"), Vec::::new()); + assert_eq!( + bean_names(r#"@BeanFactory("decoy") @Bean"#), + Vec::::new() + ); + } + + /// A `(` inside a string is not the annotation argument list. Counting it + /// would swallow the real closer and pull a later neighbor into the aliases. + #[test] + fn isolate_annotation_at_ignores_parentheses_inside_strings() { + assert_eq!( + isolate_annotation_at(r#"@Bean("foo(") @Bean("bar")"#, 0), + r#"@Bean("foo(")"# + ); + assert_eq!( + isolate_annotation_at(r#"@Bean('foo(') @Service("s")"#, 0), + r#"@Bean('foo(')"# + ); + assert_eq!( + isolate_annotation_at(r#"@Bean("foo\")") @Bean("bar")"#, 0), + r#"@Bean("foo\")")"# + ); + assert_eq!( + bean_names(r#"@Bean("foo(") @Bean("bar")"#), + vec!["foo(".to_string()] + ); + assert_eq!( + component_name(r#"@Service("s(") @Component("c")"#).as_deref(), + Some("s(") + ); + let mapping_with_paren = mapping(r#"@GetMapping("/foo(") @PostMapping("/bar")"#) + .expect("GetMapping with a parenthesis in its route should still isolate"); + assert_eq!(mapping_with_paren.0, SpringMappingAnnotation::GetMapping); + assert_eq!(mapping_with_paren.2, vec!["/foo(".to_string()]); + } + + /// A wide capture can start at the first closing quote and swallow + /// `) @Component(`. Isolation plus source-position selection must keep + /// the first annotation's own value, independent of COMPONENTS order. + #[test] + fn component_name_selects_the_earliest_annotation_and_isolates_its_value() { + assert_eq!( + component_name(r#"@Service("s") @Component("c")"#).as_deref(), + Some("s") + ); + assert_eq!( + component_name(r#"@Component("c") @Service("s")"#).as_deref(), + Some("c") + ); + assert_ne!( + component_name(r#"@Service("s") @Component("c")"#).as_deref(), + Some(") @Component(") + ); + assert_eq!(component_name("@Service @Component"), None); + assert_eq!(component_name(r#"@Service @Component("c")"#), None); + assert_eq!(component_name("@Service"), None); + assert_eq!(component_name("@ServiceLocator(\"x\")"), None); + } + + /// `quoted_values` accepts empty captures. An empty annotation value is not + /// an explicit bean name, so naming must return None and let bean_index use + /// the default type or method name. + #[test] + fn empty_annotation_values_are_not_explicit_bean_names() { + assert_eq!(component_name(r#"@Component("")"#), None); + assert_eq!(component_name(r#"@Service('')"#), None); + assert_eq!( + component_name(r#"@Component("") @Service("s")"#), + None, + "an empty leftmost name must not take a later neighbor" + ); + assert_eq!(bean_names(r#"@Bean("")"#), Vec::::new()); + } + /// Record components and constructor parameters share one pattern, so a /// declaration parsed by one path must be parsed identically by the other. #[test] @@ -1676,4 +1915,105 @@ mod tests { assert_eq!(fields[0].type_name, "PaymentService"); assert_eq!(injections[0].type_name, "PaymentService"); } + + /// Mapping detection must use the same closed set for spelling, boundary + /// matching, and HTTP methods so a prefix decoy cannot become an endpoint. + #[test] + fn every_mapping_annotation_has_a_compiled_boundary_pattern() { + for annotation in SpringMappingAnnotation::ALL.iter().copied() { + let name = annotation.name(); + assert!( + annotation.is_present(&format!("@{name}(\"/x\")")), + "{name} should match its own annotation" + ); + assert!( + annotation.is_present(&format!("@{name}")), + "{name} should match at the end of a context" + ); + assert!( + !annotation.is_present(&format!("@{name}Custom(\"/x\")")), + "{name} must not match a longer annotation sharing its prefix" + ); + } + + let names = SpringMappingAnnotation::ALL + .iter() + .map(|annotation| annotation.name()) + .collect::>(); + assert_eq!( + names.len(), + SpringMappingAnnotation::ALL.len(), + "every mapping case needs a distinct annotation name" + ); + } + + #[test] + fn mapping_rejects_custom_annotations_that_only_share_a_standard_prefix() { + assert!(mapping("@GetMappingCustom(\"/x\")").is_none()); + assert!(mapping("@PostMappingCustom(\"/x\")").is_none()); + assert!(mapping("@PutMappingCustom(\"/x\")").is_none()); + assert!(mapping("@DeleteMappingCustom(\"/x\")").is_none()); + assert!(mapping("@PatchMappingCustom(\"/x\")").is_none()); + assert!(mapping("@RequestMappingFoo(\"/base\")").is_none()); + } + + #[test] + fn mapping_keeps_standard_annotations_and_request_method_arrays() { + let get = mapping("@GetMapping(\"/x\")").expect("GetMapping should match"); + assert_eq!(get.0, SpringMappingAnnotation::GetMapping); + assert_eq!(get.1, vec!["GET".to_string()]); + assert_eq!(get.2, vec!["/x".to_string()]); + + let post = mapping("@PostMapping(path = \"/y\")").expect("PostMapping should match"); + assert_eq!(post.1, vec!["POST".to_string()]); + assert_eq!(post.2, vec!["/y".to_string()]); + + let put = mapping("@PutMapping(\"/z\")").expect("PutMapping should match"); + assert_eq!(put.1, vec!["PUT".to_string()]); + + let delete = mapping("@DeleteMapping(\"/d\")").expect("DeleteMapping should match"); + assert_eq!(delete.1, vec!["DELETE".to_string()]); + + let patch = mapping("@PatchMapping(\"/p\")").expect("PatchMapping should match"); + assert_eq!(patch.1, vec!["PATCH".to_string()]); + + let any = mapping("@RequestMapping(\"/base\")").expect("RequestMapping should match"); + assert_eq!(any.0, SpringMappingAnnotation::RequestMapping); + assert_eq!(any.1, vec!["ANY".to_string()]); + assert_eq!(any.2, vec!["/base".to_string()]); + + let multi = mapping( + "@RequestMapping(path = \"/multi\", method = { RequestMethod.POST, RequestMethod.GET, RequestMethod.GET })", + ) + .expect("RequestMapping with methods should match"); + assert_eq!(multi.1, vec!["GET".to_string(), "POST".to_string()]); + assert_eq!(multi.2, vec!["/multi".to_string()]); + } + + #[test] + fn mapping_selects_the_earliest_annotation_and_isolates_its_routes() { + let selected = mapping("@GetMappingCustom(\"/decoy\") @PostMapping(\"/real\")") + .expect("exact PostMapping should still match"); + assert_eq!(selected.0, SpringMappingAnnotation::PostMapping); + assert_eq!(selected.1, vec!["POST".to_string()]); + assert_eq!(selected.2, vec!["/real".to_string()]); + + let leftmost = mapping("@PutMapping(\"/first\") @GetMapping(\"/second\")") + .expect("leftmost Mapping should win"); + assert_eq!(leftmost.0, SpringMappingAnnotation::PutMapping); + assert_eq!(leftmost.2, vec!["/first".to_string()]); + } + + #[test] + fn mapping_does_not_take_routes_from_a_following_unrelated_annotation() { + let bare = + mapping("@GetMapping @Custom(\"/decoy\")").expect("bare GetMapping should still match"); + assert_eq!(bare.0, SpringMappingAnnotation::GetMapping); + assert_eq!(bare.1, vec!["GET".to_string()]); + assert_eq!(bare.2, vec![String::new()]); + + let spaced = mapping("@GetMapping (\"/x\") @Custom(\"/decoy\")") + .expect("GetMapping with its own argument list should keep that route"); + assert_eq!(spaced.2, vec!["/x".to_string()]); + } } diff --git a/rust/lithe-core/src/lsp/interface/engine.rs b/rust/lithe-core/src/lsp/interface/engine.rs index fd0d76ce..07909e05 100644 --- a/rust/lithe-core/src/lsp/interface/engine.rs +++ b/rust/lithe-core/src/lsp/interface/engine.rs @@ -13,10 +13,11 @@ use super::{ }; use crate::lsp::languages::jdt::{ adapt_initialization_options, adapt_start, import_progress, initialized_notification, - is_structured_import_notification, is_virtual_source_uri, normalize_location, readiness_signal, - virtual_source_content, virtual_source_resolve_params, waits_for_service_ready, - workspace_configuration, JdtDirectLaunchResources, JdtReadinessSignal, JdtStartContext, - ProviderLocation, WorkspaceConfigurationItem, + is_structured_import_notification, is_virtual_source_uri, maven_profile_update_requests, + normalize_location, readiness_signal, virtual_source_content, virtual_source_resolve_params, + waits_for_service_ready, workspace_configuration, JdtDirectLaunchResources, + JdtMavenConfiguration, JdtReadinessSignal, JdtStartContext, ProviderLocation, + WorkspaceConfigurationItem, }; use crate::lsp::languages::jdt_navigation::{JavaNavigationMarkerBatch, MAX_JAVA_NAVIGATION_TASKS}; use crate::lsp::languages::jdt_progress::JavaPreparationDiagnostics; @@ -87,6 +88,9 @@ pub struct StartServerRequest { /// reuse a stale project model. See `JdtStartContext::workspace_fingerprint`. #[serde(default)] pub workspace_fingerprint: Option, + /// Project Maven context applied to JDT LS settings and imported modules. + #[serde(default)] + pub maven_context: Option, #[serde(default = "default_initialize_timeout")] pub initialize_timeout_milliseconds: u64, /// Maximum silence while waiting for a provider-specific readiness signal. @@ -429,6 +433,8 @@ enum PendingKind { JavaNavigationMarkerResolve, /// Click-time Java parent or implementation resolution. JavaResolveNavigation, + /// JDT LS project setting update that applies selected Maven profiles. + JdtMavenProfiles, /// Shutdown handshake after which the engine sends `exit`. Shutdown, } @@ -479,6 +485,7 @@ struct SessionState { struct RuntimeSession { id: String, provider_id: String, + jdt_maven_configuration: Option, #[cfg(test)] root_uri: String, /// Serializes protocol-state commits with complete outbound message batches. @@ -714,6 +721,40 @@ impl LspEngine { self.next_session_id.fetch_add(1, Ordering::Relaxed) ); let workspace_root = PathBuf::from(&request.working_directory); + let jdt_maven_configuration = request + .maven_context + .clone() + .map(|context| { + crate::project::jdt_configuration(&request.working_directory, context).and_then( + |configuration| { + let project_uris = configuration + .project_paths + .iter() + .map(|path| { + let directory = if path == "." { + workspace_root.clone() + } else { + workspace_root.join(path) + }; + url::Url::from_directory_path(directory) + .map(|url| url.to_string()) + .map_err(|_| { + CoreError::new( + ErrorCode::InvalidRequest, + "Maven project path cannot be represented as a URI", + ) + }) + }) + .collect::, _>>()?; + Ok(JdtMavenConfiguration { + settings_path: configuration.settings_path, + profiles: configuration.profiles, + project_uris, + }) + }, + ) + }) + .transpose()?; let data_root = request .cache_directory .as_deref() @@ -821,6 +862,7 @@ impl LspEngine { let session = Arc::new(RuntimeSession { id: session_id.clone(), provider_id: request.provider_id, + jdt_maven_configuration, #[cfg(test)] root_uri: request.root_uri, outbound_order: Mutex::new(()), @@ -1704,7 +1746,9 @@ impl RuntimeSession { let mut ready_server_info = None; let mut fail_initialize: Option<(String, Option)> = None; let mut service_ready = false; + let mut apply_maven_context = false; let mut fail_service_ready = None; + let mut fail_maven_context = None; { let mut state = self.lock_state()?; state.client = reduced.state; @@ -1958,6 +2002,25 @@ impl RuntimeSession { } } } + Some(PendingKind::JdtMavenProfiles) => { + let server_error = value.get("error").map(Value::to_string); + if let Some(detail) = server_error { + fail_maven_context = Some(detail); + } else if !state + .pending + .values() + .any(|pending| pending.kind == PendingKind::JdtMavenProfiles) + { + service_ready = true; + push_log_event( + self, + &mut state, + "info", + "Java language service applied Maven profiles", + None, + ); + } + } Some(PendingKind::VirtualDocument) => { if let Some(pending) = pending_before.as_ref() { if let Some(operation_id) = &pending.operation_id { @@ -2038,6 +2101,7 @@ impl RuntimeSession { match readiness.as_ref() { Some(JdtReadinessSignal::Ready) if state.client.initialized => { service_ready = true; + apply_maven_context = true; } Some(JdtReadinessSignal::Failed(detail)) => { fail_service_ready = Some(detail.clone()); @@ -2106,8 +2170,21 @@ impl RuntimeSession { self.kill_process(); return Ok(()); } + if let Some(detail) = fail_maven_context { + self.fail( + "mavenContextFailed", + "serviceReady", + "JDT LS could not apply the selected Maven profiles.", + Some(detail), + None, + ); + self.kill_process(); + return Ok(()); + } if flush_documents { - if let Some(notification) = initialized_notification(&self.provider_id) { + if let Some(notification) = + initialized_notification(&self.provider_id, self.jdt_maven_configuration.as_ref()) + { outbound.push( json!({ "jsonrpc": "2.0", @@ -2140,6 +2217,13 @@ impl RuntimeSession { } return Ok(()); } + if apply_maven_context { + let (profile_requests, profiles_pending) = self.maven_profile_requests()?; + if profiles_pending { + service_ready = false; + } + outbound.extend(profile_requests); + } self.send_messages_or_fail(&outbound_order, outbound, "serverResponse")?; if service_ready { let queued_messages = { @@ -2193,7 +2277,11 @@ impl RuntimeSession { .map(ToString::to_string), }) .collect(); - let Some(values) = workspace_configuration(&self.provider_id, &items) else { + let Some(values) = workspace_configuration( + &self.provider_id, + &items, + self.jdt_maven_configuration.as_ref(), + ) else { return Ok(None); }; Ok(Some( @@ -2212,6 +2300,52 @@ impl RuntimeSession { )) } + fn maven_profile_requests(&self) -> Result<(Vec, bool), CoreError> { + let requests = maven_profile_update_requests(self.jdt_maven_configuration.as_ref()); + if requests.is_empty() { + return Ok((Vec::new(), false)); + } + let mut state = self.lock_state()?; + if state + .pending + .values() + .any(|pending| pending.kind == PendingKind::JdtMavenProfiles) + { + return Ok((Vec::new(), true)); + } + let now = Instant::now(); + let deadline = now + state.request_timeout; + let project_count = requests.len(); + let mut messages = Vec::with_capacity(requests.len()); + for params in requests { + let response = + allocate_raw_request(state.client.clone(), "workspace/executeCommand", params)?; + let request_id = (response.state.next_request_id - 1).to_string(); + state.client = response.state; + state.pending.insert( + request_id, + PendingRequest { + kind: PendingKind::JdtMavenProfiles, + operation_id: None, + method: "workspace/executeCommand".to_string(), + document_uri: None, + document_version: None, + created_at: now, + deadline, + }, + ); + messages.extend(response.messages); + } + push_log_event( + self, + &mut state, + "info", + "Applying Maven profiles to Java projects", + Some(format!("projectCount={project_count}")), + ); + Ok((messages, true)) + } + fn flush_queued_documents(&self) -> Result, CoreError> { let mut state = self.lock_state()?; flush_queued_documents_locked(&mut state) @@ -2222,6 +2356,7 @@ impl RuntimeSession { let mut cancellations = Vec::new(); let mut initialize_timeout = false; let mut service_ready_timeout = None; + let mut maven_context_timeout = false; let mut shutdown_timeout = false; if let Ok(mut state) = self.lock_state() { if state.lifecycle == LspLifecycleState::Initializing @@ -2232,7 +2367,14 @@ impl RuntimeSession { state.initialize_deadline = None; initialize_timeout = true; } - if state.lifecycle == LspLifecycleState::Initializing && !initialize_timeout { + let applying_maven_context = state + .pending + .values() + .any(|pending| pending.kind == PendingKind::JdtMavenProfiles); + if state.lifecycle == LspLifecycleState::Initializing + && !initialize_timeout + && !applying_maven_context + { service_ready_timeout = state .java_preparation .as_mut() @@ -2284,6 +2426,21 @@ impl RuntimeSession { } cancellations.push(request_id); } + let expired_maven_requests: Vec<_> = state + .pending + .iter() + .filter(|(_, pending)| { + pending.kind == PendingKind::JdtMavenProfiles && now >= pending.deadline + }) + .map(|(id, _)| id.clone()) + .collect(); + if !expired_maven_requests.is_empty() { + maven_context_timeout = true; + for request_id in expired_maven_requests { + state.pending.remove(&request_id); + state.client.pending_requests.remove(&request_id); + } + } if state.lifecycle == LspLifecycleState::Stopping && state .shutdown_deadline @@ -2320,6 +2477,15 @@ impl RuntimeSession { None, ); self.kill_process(); + } else if maven_context_timeout { + self.fail( + "mavenContextTimeout", + "serviceReady", + "JDT LS timed out while applying the selected Maven profiles.", + None, + None, + ); + self.kill_process(); } else if shutdown_timeout { self.kill_process(); } else if !cancellations.is_empty() { @@ -3326,6 +3492,7 @@ mod tests { jdtls_launch_resources: None, cache_directory: None, workspace_fingerprint: None, + maven_context: None, initialize_timeout_milliseconds: 10_000, service_ready_idle_timeout_milliseconds: 45_000, service_ready_absolute_timeout_milliseconds: 600_000, @@ -3344,6 +3511,67 @@ mod tests { events: Vec, } + struct TemporaryMavenWorkspace { + root: PathBuf, + } + + impl TemporaryMavenWorkspace { + fn recursive(label: &str) -> Self { + static NEXT_ID: AtomicU64 = AtomicU64::new(1); + let root = std::env::temp_dir().join(format!( + "lithe-lsp-{label}-{}-{}", + std::process::id(), + NEXT_ID.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(root.join("reactor/module-a/nested")) + .expect("recursive Maven fixture should be creatable"); + std::fs::write( + root.join("reactor/pom.xml"), + r#"reactorpommodule-a"#, + ) + .expect("reactor pom should be writable"); + std::fs::write( + root.join("reactor/module-a/pom.xml"), + r#"module-apomnested"#, + ) + .expect("module pom should be writable"); + std::fs::write( + root.join("reactor/module-a/nested/pom.xml"), + r#"nested"#, + ) + .expect("nested module pom should be writable"); + Self { root } + } + + fn configure(&self, request: &mut StartServerRequest) { + request.provider_id = "java".to_string(); + request.root_uri = url::Url::from_directory_path(&self.root) + .expect("fixture root should convert to a URI") + .to_string(); + request.working_directory = self.root.to_string_lossy().into_owned(); + request.cache_directory = Some(self.root.join("cache").to_string_lossy().into_owned()); + request.maven_context = Some(crate::project::MavenLaunchContextRequest { + version: 1, + reactor_path: "reactor".to_string(), + profiles: vec![ + "enterprise".to_string(), + "dev".to_string(), + "enterprise".to_string(), + ], + settings_path: Some("/local/settings.xml".to_string()), + skip_tests: true, + maven_executable_path: Some("/local/maven/bin/mvn".to_string()), + java_home_path: Some("/local/jdk".to_string()), + }); + } + } + + impl Drop for TemporaryMavenWorkspace { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } + } + impl Harness { fn start(configure: impl FnOnce(&mut StartServerRequest)) -> Self { let server = ScriptedServer::new(); @@ -3693,6 +3921,136 @@ mod tests { harness.await_state(LspLifecycleState::Ready); } + #[test] + fn java_applies_maven_settings_and_recursive_profiles_before_ready() { + let workspace = TemporaryMavenWorkspace::recursive("maven-context-ready"); + let mut harness = Harness::start(|request| workspace.configure(request)); + harness.server.complete_initialize(ready_capabilities()); + assert!(harness + .server + .await_notification("workspace/didChangeConfiguration")); + + let configuration = harness + .notification("workspace/didChangeConfiguration") + .expect("Java configuration notification should be sent"); + assert_eq!( + configuration["params"]["settings"]["java"]["configuration"]["maven"]["userSettings"], + "/local/settings.xml" + ); + harness.server.send(json!({ + "jsonrpc": "2.0", + "method": "language/status", + "params": { "type": "ServiceReady" } + })); + + let request_ids: Vec<_> = (0..3) + .map(|index| { + harness + .server + .await_request_at("workspace/executeCommand", index) + .expect("every recursive Maven project should receive its profile update") + }) + .collect(); + let updates: Vec<_> = harness + .server + .messages() + .into_iter() + .filter(|message| { + message.get("method").and_then(Value::as_str) == Some("workspace/executeCommand") + }) + .collect(); + let expected_uris = ["reactor/", "reactor/module-a/", "reactor/module-a/nested/"]; + assert_eq!(updates.len(), expected_uris.len()); + for (update, expected_uri) in updates.iter().zip(expected_uris) { + let project_uri = update["params"]["arguments"][0] + .as_str() + .expect("the project URI should be a string"); + assert!( + project_uri.ends_with(expected_uri), + "unexpected URI: {project_uri}" + ); + assert_eq!( + update["params"]["arguments"][1]["org.eclipse.m2e.core.selectedProfiles"], + "dev,enterprise" + ); + } + + harness.server.send(json!({ + "jsonrpc": "2.0", + "method": "language/status", + "params": { "type": "ServiceReady" } + })); + for request_id in &request_ids[..2] { + harness.server.send(json!({ + "jsonrpc": "2.0", + "id": request_id, + "result": null + })); + } + harness.server.send(json!({ + "jsonrpc": "2.0", + "method": "test/profile-response-barrier" + })); + harness + .await_event(|event| event.message.as_deref() == Some("test/profile-response-barrier")); + assert_eq!( + harness + .server + .messages() + .iter() + .filter(|message| { + message.get("method").and_then(Value::as_str) + == Some("workspace/executeCommand") + }) + .count(), + 3, + "a duplicate readiness notification must not enqueue duplicate profile updates" + ); + assert_eq!(harness.snapshot().state, LspLifecycleState::Initializing); + + harness.server.send(json!({ + "jsonrpc": "2.0", + "id": request_ids[2], + "result": null + })); + harness.await_state(LspLifecycleState::Ready); + } + + #[test] + fn java_profile_update_error_fails_instead_of_using_a_stale_model() { + let workspace = TemporaryMavenWorkspace::recursive("maven-context-failure"); + let mut harness = Harness::start(|request| workspace.configure(request)); + harness.server.complete_initialize(ready_capabilities()); + assert!(harness.server.await_notification("initialized")); + harness.server.send(json!({ + "jsonrpc": "2.0", + "method": "language/status", + "params": { "type": "ServiceReady" } + })); + let request_id = harness + .server + .await_request("workspace/executeCommand") + .expect("the Maven profile request should be sent"); + harness.server.send(json!({ + "jsonrpc": "2.0", + "id": request_id, + "error": { "code": -32603, "message": "Maven project update failed" } + })); + harness.await_state(LspLifecycleState::Failed); + + let failure = harness + .events + .iter() + .find_map(|event| event.error.as_ref()) + .expect("the Maven profile failure should be reported"); + assert_eq!(failure.code, "mavenContextFailed"); + assert_eq!(failure.stage, "serviceReady"); + assert!(failure + .underlying_message + .as_deref() + .is_some_and(|detail| detail.contains("Maven project update failed"))); + } + #[test] fn java_service_error_fails_the_preparing_session() { let mut harness = Harness::start(|request| { @@ -5357,6 +5715,7 @@ public class Main { jdtls_launch_resources: None, cache_directory: Some(root.join("cache").to_string_lossy().into_owned()), workspace_fingerprint: None, + maven_context: None, initialize_timeout_milliseconds: 90_000, service_ready_idle_timeout_milliseconds: 45_000, service_ready_absolute_timeout_milliseconds: 600_000, @@ -6083,6 +6442,7 @@ public class Main { jdtls_launch_resources: None, cache_directory: None, workspace_fingerprint: None, + maven_context: None, initialize_timeout_milliseconds: 1, service_ready_idle_timeout_milliseconds: 1, service_ready_absolute_timeout_milliseconds: 1, diff --git a/rust/lithe-core/src/lsp/languages/jdt.rs b/rust/lithe-core/src/lsp/languages/jdt.rs index 985656ac..4885f660 100644 --- a/rust/lithe-core/src/lsp/languages/jdt.rs +++ b/rust/lithe-core/src/lsp/languages/jdt.rs @@ -89,6 +89,17 @@ pub(crate) struct JdtStartContext { pub workspace_fingerprint: Option, } +/// Maven import settings already validated by the shared project domain. +#[derive(Debug, Clone, Eq, PartialEq)] +pub(crate) struct JdtMavenConfiguration { + /// Optional machine-local Maven settings file consumed by JDT LS. + pub settings_path: Option, + /// Sorted, de-duplicated Maven profile IDs selected for this workspace. + pub profiles: Vec, + /// Deterministically ordered reactor and recursive-module directory URIs. + pub project_uris: Vec, +} + /// Platform-resolved files required to launch JDT LS without a shell wrapper. #[derive(Debug, Clone, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] @@ -265,6 +276,7 @@ pub(crate) fn adapt_initialization_options( pub(crate) fn workspace_configuration( provider_id: &str, items: &[WorkspaceConfigurationItem], + maven_configuration: Option<&JdtMavenConfiguration>, ) -> Option> { if !is_java_provider(provider_id) { return None; @@ -272,21 +284,49 @@ pub(crate) fn workspace_configuration( Some( items .iter() - .map(|item| java_configuration_for_section(item.section.as_deref())) + .map(|item| { + java_configuration_for_section(item.section.as_deref(), maven_configuration) + }) .collect(), ) } /// Notification the engine sends after the generic `initialized` handshake. -pub(crate) fn initialized_notification(provider_id: &str) -> Option { +pub(crate) fn initialized_notification( + provider_id: &str, + maven_configuration: Option<&JdtMavenConfiguration>, +) -> Option { is_java_provider(provider_id).then(|| ProviderNotification { method: DID_CHANGE_CONFIGURATION_METHOD.to_string(), params: json!({ - "settings": java_settings() + "settings": java_settings(maven_configuration) }), }) } +/// Commands that apply the selected Maven profiles to every imported project. +pub(crate) fn maven_profile_update_requests( + configuration: Option<&JdtMavenConfiguration>, +) -> Vec { + let Some(configuration) = configuration else { + return Vec::new(); + }; + let profiles = configuration.profiles.join(","); + configuration + .project_uris + .iter() + .map(|uri| { + json!({ + "command": "java.project.updateSettings", + "arguments": [ + uri, + { "org.eclipse.m2e.core.selectedProfiles": profiles } + ] + }) + }) + .collect() +} + /// Returns whether the provider has a readiness phase after standard LSP /// initialization. JDT LS cannot safely serve semantic requests until its Java /// project import publishes `ServiceReady`. @@ -668,8 +708,8 @@ fn without_wrapper_owned_arguments(arguments: &[String]) -> Vec { retained } -fn java_settings() -> Value { - json!({ +fn java_settings(maven_configuration: Option<&JdtMavenConfiguration>) -> Value { + let mut settings = json!({ "java": { "eclipse": { "downloadSources": false @@ -694,33 +734,22 @@ fn java_settings() -> Value { "enabled": true } } - }) + }); + if let Some(settings_path) = maven_configuration.and_then(|value| value.settings_path.as_ref()) + { + settings["java"]["configuration"]["maven"] = json!({ + "userSettings": settings_path + }); + } + settings } -fn java_configuration_for_section(section: Option<&str>) -> Value { +fn java_configuration_for_section( + section: Option<&str>, + maven_configuration: Option<&JdtMavenConfiguration>, +) -> Value { match section { - Some("java") => json!({ - "eclipse": { - "downloadSources": false - }, - "maven": { - "downloadSources": false - }, - "configuration": { - "updateBuildConfiguration": "automatic" - }, - "inlayHints": { - "parameterNames": { - "enabled": "all" - } - }, - "implementationsCodeLens": { - "enabled": true - }, - "referencesCodeLens": { - "enabled": true - } - }), + Some("java") => java_settings(maven_configuration)["java"].clone(), Some("java.inlayHints") => json!({ "parameterNames": { "enabled": "all" @@ -732,8 +761,17 @@ fn java_configuration_for_section(section: Option<&str>) -> Value { Some("java.eclipse.downloadSources") => json!(false), Some("java.maven") => json!({ "downloadSources": false }), Some("java.maven.downloadSources") => json!(false), - Some("java.configuration") => json!({ "updateBuildConfiguration": "automatic" }), + Some("java.configuration") => { + java_settings(maven_configuration)["java"]["configuration"].clone() + } Some("java.configuration.updateBuildConfiguration") => json!("automatic"), + Some("java.configuration.maven") => maven_configuration + .and_then(|value| value.settings_path.as_ref()) + .map(|path| json!({ "userSettings": path })) + .unwrap_or(Value::Null), + Some("java.configuration.maven.userSettings") => maven_configuration + .and_then(|value| value.settings_path.as_ref()) + .map_or(Value::Null, |path| json!(path)), Some("java.implementationsCodeLens") => json!({ "enabled": true }), Some("java.implementationsCodeLens.enabled") => json!(true), Some("java.referencesCodeLens") => json!({ "enabled": true }), @@ -1129,8 +1167,8 @@ mod tests { assert_eq!(adapted.arguments, context.arguments); assert_eq!(adapted.data_directory, None); - assert!(workspace_configuration("rust", &[]).is_none()); - assert!(initialized_notification("rust").is_none()); + assert!(workspace_configuration("rust", &[], None).is_none()); + assert!(initialized_notification("rust", None).is_none()); assert!(virtual_source_resolve_params("rust", "jdt://contents/A.class").is_none()); assert_eq!( adapt_initialization_options("rust", Some(json!({ "custom": true })), &[]), @@ -1203,7 +1241,7 @@ mod tests { scope_uri: Some("file:///workspace/project".to_string()), section: Some(section.to_string()), }); - let values = workspace_configuration("java", &items).unwrap(); + let values = workspace_configuration("java", &items, None).unwrap(); assert_eq!(values[0]["inlayHints"]["parameterNames"]["enabled"], "all"); assert_eq!(values[0]["eclipse"]["downloadSources"], false); @@ -1224,7 +1262,7 @@ mod tests { #[test] fn java_initialized_notification_publishes_inlay_settings() { - let notification = initialized_notification("JAVA").unwrap(); + let notification = initialized_notification("JAVA", None).unwrap(); assert_eq!(notification.method, "workspace/didChangeConfiguration"); assert_eq!( @@ -1249,6 +1287,70 @@ mod tests { ); } + #[test] + fn java_configuration_and_profile_updates_consume_the_maven_context() { + let configuration = JdtMavenConfiguration { + settings_path: Some("/local/settings.xml".to_string()), + profiles: vec!["dev".to_string(), "enterprise".to_string()], + project_uris: vec![ + "file:///workspace/reactor/".to_string(), + "file:///workspace/reactor/module-a/".to_string(), + "file:///workspace/reactor/module-a/nested/".to_string(), + ], + }; + let items = [ + "java", + "java.configuration", + "java.configuration.maven", + "java.configuration.maven.userSettings", + ] + .map(|section| WorkspaceConfigurationItem { + scope_uri: Some("file:///workspace/reactor/".to_string()), + section: Some(section.to_string()), + }); + + let values = workspace_configuration("java", &items, Some(&configuration)).unwrap(); + assert_eq!( + values[0]["configuration"]["maven"]["userSettings"], + "/local/settings.xml" + ); + assert_eq!(values[1]["maven"]["userSettings"], "/local/settings.xml"); + assert_eq!(values[2]["userSettings"], "/local/settings.xml"); + assert_eq!(values[3], "/local/settings.xml"); + + let notification = initialized_notification("java", Some(&configuration)).unwrap(); + assert_eq!( + notification.params["settings"]["java"]["configuration"]["maven"]["userSettings"], + "/local/settings.xml" + ); + assert_eq!( + maven_profile_update_requests(Some(&configuration)), + vec![ + json!({ + "command": "java.project.updateSettings", + "arguments": [ + "file:///workspace/reactor/", + { "org.eclipse.m2e.core.selectedProfiles": "dev,enterprise" } + ] + }), + json!({ + "command": "java.project.updateSettings", + "arguments": [ + "file:///workspace/reactor/module-a/", + { "org.eclipse.m2e.core.selectedProfiles": "dev,enterprise" } + ] + }), + json!({ + "command": "java.project.updateSettings", + "arguments": [ + "file:///workspace/reactor/module-a/nested/", + { "org.eclipse.m2e.core.selectedProfiles": "dev,enterprise" } + ] + }), + ] + ); + } + #[test] fn jdt_location_is_read_only_with_a_source_display_path() { let location = normalize_location( diff --git a/rust/lithe-core/src/project/maven.rs b/rust/lithe-core/src/project/maven.rs index f90621ba..c1da9975 100644 --- a/rust/lithe-core/src/project/maven.rs +++ b/rust/lithe-core/src/project/maven.rs @@ -2,13 +2,14 @@ use crate::protocol::{CoreError, ErrorCode}; use crate::protocol::{ - MavenDiagnosticResponse, MavenDiagnosticsResponse, MavenModuleResponse, MavenProfileResponse, - MavenScanResponse, + MavenDiagnosticResponse, MavenDiagnosticsResponse, MavenLaunchExecutableResponse, + MavenLaunchPlanResponse, MavenModuleResponse, MavenProfileResponse, MavenScanResponse, }; use quick_xml::events::Event; use quick_xml::Reader; use regex::Regex; use serde::Deserialize; +use sha2::{Digest, Sha256}; use std::collections::{BTreeSet, HashSet}; use std::fs; use std::path::{Component, Path, PathBuf}; @@ -30,6 +31,331 @@ pub struct MavenDiagnosticsRequest { pub output: String, } +const MAVEN_CONTEXT_VERSION: u32 = 1; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Versioned Maven defaults merged by the platform before launch planning. +pub struct MavenLaunchContextRequest { + pub version: u32, + pub reactor_path: String, + #[serde(default)] + pub profiles: Vec, + #[serde(default)] + pub settings_path: Option, + #[serde(default)] + pub skip_tests: bool, + #[serde(default)] + pub maven_executable_path: Option, + #[serde(default)] + pub java_home_path: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Ordered Maven invocation tokens selected by a tool-window consumer. +/// +/// The first token is a lifecycle or custom goal. Remaining tokens are passed +/// directly to Maven as arguments without shell interpretation. +pub struct MavenLaunchPlanRequest { + pub root: String, + pub context: MavenLaunchContextRequest, + #[serde(default)] + pub module: Option, + pub goals: Vec, +} + +#[derive(Debug, Clone)] +/// Validated Maven import settings consumed by the JDT LS adapter. +pub(crate) struct MavenJdtConfiguration { + pub profiles: Vec, + pub settings_path: Option, + /// Workspace-relative reactor and recursive module directories. + pub project_paths: Vec, +} + +struct ValidatedMavenContext { + reactor_path: String, + canonical_reactor: PathBuf, + profiles: Vec, + settings_path: Option, + skip_tests: bool, + maven_executable_path: Option, + java_home_path: Option, +} + +/// Produces a deterministic Maven plan without resolving a native executable. +pub fn launch_plan(request: MavenLaunchPlanRequest) -> Result { + let arguments = normalized_tool_window_arguments(request.goals)?; + launch_plan_with_arguments( + request.root, + request.context, + request.module, + arguments, + true, + ) +} + +/// Applies a validated Maven context to Core-owned run/debug arguments. +/// +/// Unlike the public tool-window command, the trailing arguments may contain +/// properties such as `-Dexec.mainClass`: those values were generated and +/// validated by the run-configuration domain rather than entered as goals. +pub(crate) fn launch_plan_with_arguments( + root: String, + context: MavenLaunchContextRequest, + module: Option, + trailing_arguments: Vec, + also_make: bool, +) -> Result { + let validated = validated_maven_context(&root, context)?; + + let module = module + .as_deref() + .map(|value| normalized_project_path(value, "Maven module")) + .transpose()?; + if let Some(module) = module.as_deref().filter(|value| *value != ".") { + let declared = declared_modules(&validated.canonical_reactor)?; + if !declared + .iter() + .any(|candidate| candidate.relative_path == module) + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Maven module is not part of the selected reactor", + ) + .with_details(module)); + } + } + + let arguments = maven_arguments( + &validated.profiles, + validated.settings_path.as_deref(), + module.as_deref(), + also_make, + validated.skip_tests, + &trailing_arguments, + ); + let configuration_fingerprint = maven_context_fingerprint( + &validated.reactor_path, + &validated.profiles, + validated.settings_path.as_deref(), + validated.skip_tests, + validated.maven_executable_path.as_deref(), + validated.java_home_path.as_deref(), + ); + + Ok(MavenLaunchPlanResponse { + version: MAVEN_CONTEXT_VERSION, + executable: MavenLaunchExecutableResponse { + toolchain: "project-maven".to_string(), + }, + arguments, + working_directory: validated.reactor_path, + configuration_fingerprint, + }) +} + +/// Resolves the same Maven profiles and settings for JDT LS project import. +pub(crate) fn jdt_configuration( + root: &str, + context: MavenLaunchContextRequest, +) -> Result { + let validated = validated_maven_context(root, context)?; + let project_paths = declared_modules(&validated.canonical_reactor)? + .into_iter() + .map(|module| { + if module.relative_path == "." { + validated.reactor_path.clone() + } else if validated.reactor_path == "." { + module.relative_path + } else { + format!("{}/{}", validated.reactor_path, module.relative_path) + } + }) + .collect(); + Ok(MavenJdtConfiguration { + profiles: validated.profiles, + settings_path: validated.settings_path, + project_paths, + }) +} + +fn validated_maven_context( + root: &str, + context: MavenLaunchContextRequest, +) -> Result { + let workspace_root = existing_root(root)?; + if context.version != MAVEN_CONTEXT_VERSION { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Unsupported Maven context version", + ) + .with_details(context.version.to_string())); + } + + let reactor_path = normalized_project_path(&context.reactor_path, "Maven reactor")?; + let reactor_root = if reactor_path == "." { + workspace_root.clone() + } else { + workspace_root.join(&reactor_path) + }; + let canonical_reactor = reactor_root.canonicalize().map_err(|_| { + CoreError::new(ErrorCode::WorkspaceNotFound, "Maven reactor does not exist") + })?; + if !canonical_reactor.starts_with(&workspace_root) + || !canonical_reactor.join("pom.xml").is_file() + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Maven reactor must contain pom.xml inside the workspace", + )); + } + + Ok(ValidatedMavenContext { + reactor_path, + canonical_reactor, + profiles: normalized_profiles(context.profiles)?, + settings_path: normalized_local_path(context.settings_path, "Maven settings")?, + skip_tests: context.skip_tests, + maven_executable_path: normalized_local_path( + context.maven_executable_path, + "Maven executable", + )?, + java_home_path: normalized_local_path(context.java_home_path, "Maven JDK")?, + }) +} + +/// Builds the shared Maven option prefix used by tool-window and run plans. +pub(crate) fn maven_arguments( + profiles: &[String], + settings_path: Option<&str>, + module: Option<&str>, + also_make: bool, + skip_tests: bool, + goals: &[String], +) -> Vec { + let mut arguments = vec!["-B".to_string(), "-ntp".to_string()]; + if !profiles.is_empty() { + arguments.extend(["-P".to_string(), profiles.join(",")]); + } + if let Some(settings_path) = settings_path { + arguments.extend(["-s".to_string(), settings_path.to_string()]); + } + if let Some(module) = module.filter(|value| *value != ".") { + arguments.extend(["-pl".to_string(), module.to_string()]); + if also_make { + arguments.push("-am".to_string()); + } + } + if skip_tests { + arguments.push("-DskipTests".to_string()); + } + arguments.extend(goals.iter().cloned()); + arguments +} + +fn normalized_profiles(values: Vec) -> Result, CoreError> { + let mut profiles = BTreeSet::new(); + for value in values { + let profile = value.trim(); + if profile.is_empty() || profile.contains(',') || profile.chars().any(char::is_control) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Maven profile ID is invalid", + )); + } + profiles.insert(profile.to_string()); + } + Ok(profiles.into_iter().collect()) +} + +fn normalized_tool_window_arguments(values: Vec) -> Result, CoreError> { + if values.is_empty() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "At least one Maven goal is required", + )); + } + + let mut arguments = Vec::with_capacity(values.len()); + for (index, value) in values.into_iter().enumerate() { + let argument = value.trim(); + if argument.is_empty() || argument.chars().any(char::is_control) { + return Err( + CoreError::new(ErrorCode::InvalidRequest, "Maven argument is invalid") + .with_details(argument), + ); + } + if index == 0 { + let valid_goal = !argument.starts_with('-') + && argument.chars().all(|character| { + character.is_ascii_alphanumeric() || ".:_-".contains(character) + }); + if !valid_goal { + return Err( + CoreError::new(ErrorCode::InvalidRequest, "Maven goal is invalid") + .with_details(argument), + ); + } + } + arguments.push(argument.to_string()); + } + Ok(arguments) +} + +fn normalized_project_path(value: &str, label: &str) -> Result { + let trimmed = value.trim(); + if trimmed == "." { + return Ok(".".to_string()); + } + normalize_relative_path(trimmed).ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + format!("{label} path is invalid"), + ) + }) +} + +fn normalized_local_path(value: Option, label: &str) -> Result, CoreError> { + let Some(value) = value else { return Ok(None) }; + let trimmed = value.trim(); + if trimmed.is_empty() { + return Ok(None); + } + if trimmed.chars().any(char::is_control) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + format!("{label} path is invalid"), + )); + } + Ok(Some(trimmed.to_string())) +} + +fn maven_context_fingerprint( + reactor_path: &str, + profiles: &[String], + settings_path: Option<&str>, + skip_tests: bool, + maven_executable_path: Option<&str>, + java_home_path: Option<&str>, +) -> String { + let mut digest = Sha256::new(); + for value in [ + MAVEN_CONTEXT_VERSION.to_string(), + reactor_path.to_string(), + profiles.join(","), + settings_path.unwrap_or_default().to_string(), + skip_tests.to_string(), + maven_executable_path.unwrap_or_default().to_string(), + java_home_path.unwrap_or_default().to_string(), + ] { + digest.update(value.as_bytes()); + digest.update([0]); + } + format!("sha256:{:x}", digest.finalize()) +} + #[derive(Debug, Default)] /// Parsed POM fields needed by reactor discovery and run-configuration detection. struct Descriptor { diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index ac42b0b1..fc5b6d44 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -79,6 +79,8 @@ pub enum CoreCommand { HistoryDelete, /// Inspects a declared Maven reactor (`maven.scan`). MavenScan, + /// Produces a deterministic Maven invocation (`maven.launchPlan`). + MavenLaunchPlan, /// Normalizes diagnostics from Maven output (`maven.diagnostics`). MavenDiagnostics, /// Renders and sanitizes shared Markdown (`markdown.render`). @@ -263,6 +265,7 @@ impl CoreCommand { "history.rename" => Some(Self::HistoryRename), "history.delete" => Some(Self::HistoryDelete), "maven.scan" => Some(Self::MavenScan), + "maven.launchPlan" => Some(Self::MavenLaunchPlan), "maven.diagnostics" => Some(Self::MavenDiagnostics), "markdown.render" => Some(Self::MarkdownRender), "debug.createSession" => Some(Self::DebugCreateSession), diff --git a/rust/lithe-core/src/protocol/contracts.rs b/rust/lithe-core/src/protocol/contracts.rs index d27cc78c..2bf803be 100644 --- a/rust/lithe-core/src/protocol/contracts.rs +++ b/rust/lithe-core/src/protocol/contracts.rs @@ -191,6 +191,24 @@ pub struct MavenScanResponse { pub has_wrapper: bool, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Platform-neutral executable reference returned by Maven launch planning. +pub struct MavenLaunchExecutableResponse { + pub toolchain: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Deterministic Maven invocation consumed by native process adapters. +pub struct MavenLaunchPlanResponse { + pub version: u32, + pub executable: MavenLaunchExecutableResponse, + pub arguments: Vec, + pub working_directory: String, + pub configuration_fingerprint: String, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] /// One normalized issue parsed from Maven process output. @@ -396,6 +414,8 @@ pub struct GitCommitResponse { /// References and a bounded page of commit history. pub struct GitHistoryResponse { pub references: Vec, + /// Up to five local branches ordered from most to least recently checked out. + pub recent_references: Vec, pub commits: Vec, pub has_more: bool, pub user_name: Option, diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index 90e44bbc..b87bd388 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -451,6 +451,24 @@ fn execute(request: &str) -> CoreResponse { ), Err(error) => CoreResponse::failure(id, error), }, + CoreCommand::MavenLaunchPlan => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Maven launch-plan request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::project::launch_plan) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Maven launch plan should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::MavenDiagnostics => { match serde_json::from_value::(parsed.payload) .map_err(|error| { diff --git a/rust/lithe-core/src/tests/git.rs b/rust/lithe-core/src/tests/git.rs index ac96c912..e20d535f 100644 --- a/rust/lithe-core/src/tests/git.rs +++ b/rust/lithe-core/src/tests/git.rs @@ -197,6 +197,7 @@ fn git_write_validates_and_executes_shared_mutations() { .status .success()); assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); + assert!(run(&["remote", "add", "origin", "."]).status.success()); fs::write(root.join("example.txt"), "initial\n").expect("file should be writable"); let request = |operation: &str, payload: Value| -> Value { @@ -386,6 +387,79 @@ fn git_write_validates_and_executes_shared_mutations() { ); assert!(run(&["switch", ¤t]).status.success()); + let repeated_checkout = request( + "checkout", + serde_json::json!({ + "reference": format!("refs/heads/{current}"), + "referenceKind": "local" + }), + ); + assert_eq!( + repeated_checkout["data"]["operationError"]["code"], "invalid_request", + "{repeated_checkout:?}" + ); + + assert!(run(&["branch", "feature/rebase"]).status.success()); + fs::write(root.join("rebase.txt"), "new base\n").expect("file should be writable"); + assert!(run(&["add", "rebase.txt"]).status.success()); + assert!(run(&["commit", "-qm", "new base"]).status.success()); + let checkout_and_rebase = request( + "checkoutAndRebase", + serde_json::json!({ + "reference": "refs/heads/feature/rebase", + "referenceKind": "local" + }), + ); + assert_eq!(checkout_and_rebase["ok"], true, "{checkout_and_rebase:?}"); + assert_eq!( + checkout_and_rebase["data"]["exitCode"], 0, + "{checkout_and_rebase:?}" + ); + assert_eq!( + String::from_utf8_lossy(&run(&["branch", "--show-current"]).stdout).trim(), + "feature/rebase" + ); + assert!(run(&["merge-base", "--is-ancestor", ¤t, "HEAD"]) + .status + .success()); + assert!(run(&["switch", ¤t]).status.success()); + + fs::write(root.join("dirty.txt"), "keep me\n").expect("file should be writable"); + let dirty_checkout_and_rebase = request( + "checkoutAndRebase", + serde_json::json!({ + "reference": "refs/heads/feature/rebase", + "referenceKind": "local" + }), + ); + assert_eq!( + dirty_checkout_and_rebase["ok"], true, + "{dirty_checkout_and_rebase:?}" + ); + assert_eq!( + dirty_checkout_and_rebase["data"]["operationError"]["code"], "invalid_request", + "{dirty_checkout_and_rebase:?}" + ); + assert_eq!( + String::from_utf8_lossy(&run(&["branch", "--show-current"]).stdout).trim(), + current + ); + fs::remove_file(root.join("dirty.txt")).expect("file should be removable"); + + let explicit_pull = request( + "pull", + serde_json::json!({ + "reference": "refs/remotes/origin/feature/core", + "referenceKind": "remote", + "mode": "rebase" + }), + ); + assert_eq!(explicit_pull["ok"], true, "{explicit_pull:?}"); + assert_eq!( + explicit_pull["data"]["arguments"], + serde_json::json!(["pull", "--rebase", "--", "origin", "feature/core"]) + ); + fs::write(root.join("example.txt"), "working tree\n").expect("file should be writable"); let stash = request( "stashPush", @@ -1285,6 +1359,87 @@ fn git_history_returns_references_and_commit_graph_fields() { fs::remove_dir_all(root).expect("temporary workspace should be removable"); } + +#[test] +fn git_history_returns_bounded_recent_checkout_order_and_stable_fallback() { + struct RemoveOnDrop(std::path::PathBuf); + + impl Drop for RemoveOnDrop { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + let root = temporary_root("git-recent-branches"); + let _cleanup = RemoveOnDrop(root.clone()); + fs::create_dir_all(&root).expect("temporary repository should be creatable"); + let run = |arguments: &[&str]| { + Command::new("git") + .args(arguments) + .current_dir(&root) + .output() + .expect("git should be available") + }; + assert!(run(&["init", "-q"]).status.success()); + assert!(run(&["config", "user.email", "test@example.com"]) + .status + .success()); + assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); + fs::write(root.join("example.txt"), "initial\n").expect("file should be writable"); + assert!(run(&["add", "example.txt"]).status.success()); + assert!(run(&["commit", "-qm", "initial"]).status.success()); + assert!(run(&["branch", "-M", "main"]).status.success()); + for branch in ["alpha", "beta", "gamma", "delta", "epsilon", "zeta"] { + assert!(run(&["branch", branch]).status.success()); + } + + let history = || { + let request = serde_json::json!({ + "id": "recent-branches", + "command": "git.history", + "payload": {"root": root, "limit": 10} + }); + serde_json::from_str::(&execute_json( + &serde_json::to_string(&request).expect("history request should encode"), + )) + .expect("history response should be JSON") + }; + let recent_names = |response: &Value| { + response["data"]["recentReferences"] + .as_array() + .expect("recent references should be an array") + .iter() + .map(|reference| { + reference["shortName"] + .as_str() + .expect("recent reference name should be text") + .to_string() + }) + .collect::>() + }; + + let initial = history(); + assert_eq!(initial["ok"], true, "{initial:?}"); + assert_eq!( + recent_names(&initial), + ["main", "alpha", "beta", "delta", "epsilon"] + ); + + for branch in [ + "alpha", "beta", "gamma", "delta", "epsilon", "zeta", "gamma", + ] { + assert!(run(&["checkout", "-q", branch]).status.success()); + } + assert!(run(&["branch", "-D", "beta"]).status.success()); + + let switched = history(); + assert_eq!(switched["ok"], true, "{switched:?}"); + assert_eq!( + recent_names(&switched), + ["gamma", "zeta", "epsilon", "delta", "alpha"] + ); +} + #[test] fn git_conflict_markers_ignore_markdown_headings() { let root = temporary_root("git-markers"); @@ -1678,3 +1833,94 @@ fn git_pull_preflight_reports_divergence_and_strategies_resolve_it() { fs::remove_dir_all(root).expect("Git fixture should be removable"); } + +#[test] +fn explicit_pull_resolves_nested_remote_and_branch_names_against_bare_remote() { + let root = temporary_root("git-pull-nested-ref"); + let source = root.join("source"); + let work = root.join("work"); + fs::create_dir_all(&source).expect("source should be creatable"); + let git = |directory: &Path, arguments: &[&str]| { + Command::new("git") + .args(arguments) + .current_dir(directory) + .output() + .expect("git should be available") + }; + assert!(git(&source, &["init", "--bare", "-q"]).status.success()); + let seed = root.join("seed"); + fs::create_dir_all(&seed).expect("seed should be creatable"); + assert!(git(&seed, &["init", "-q", "-b", "main"]).status.success()); + assert!(git(&seed, &["config", "user.email", "test@example.com"]) + .status + .success()); + assert!(git(&seed, &["config", "user.name", "Lithe Test"]) + .status + .success()); + fs::write(seed.join("base.txt"), "base\n").expect("file should be writable"); + assert!(git(&seed, &["add", "."]).status.success()); + assert!(git(&seed, &["commit", "-qm", "base"]).status.success()); + assert!(git( + &seed, + &[ + "remote", + "add", + "company/origin", + source.to_string_lossy().as_ref() + ] + ) + .status + .success()); + assert!(git(&seed, &["push", "-q", "company/origin", "main"]) + .status + .success()); + assert!(git(&seed, &["switch", "-c", "feature/core"]) + .status + .success()); + fs::write(seed.join("nested.txt"), "nested\n").expect("file should be writable"); + assert!(git(&seed, &["add", "."]).status.success()); + assert!(git(&seed, &["commit", "-qm", "nested"]).status.success()); + assert!( + git(&seed, &["push", "-q", "company/origin", "feature/core"]) + .status + .success() + ); + assert!(git( + &root, + &[ + "clone", + "-q", + seed.to_string_lossy().as_ref(), + work.to_string_lossy().as_ref() + ] + ) + .status + .success()); + assert!(git(&work, &["remote", "remove", "origin"]).status.success()); + assert!(git( + &work, + &[ + "remote", + "add", + "company/origin", + source.to_string_lossy().as_ref() + ] + ) + .status + .success()); + let response: Value = serde_json::from_str(&execute_json(&serde_json::to_string(&serde_json::json!({ + "id": "nested-pull", "command": "git.write", "payload": { + "root": work, "operation": "pull", "reference": "refs/remotes/company/origin/feature/core", + "referenceKind": "remote", "mode": "rebase" + } + })).expect("request should encode"))).expect("response should be JSON"); + assert_eq!(response["ok"], true, "{response}"); + assert_eq!(response["data"]["exitCode"], 0, "{response}"); + assert_eq!( + fs::read_to_string(work.join("nested.txt")) + .expect("file should exist") + .replace("\r\n", "\n"), + "nested\n" + ); + fs::remove_dir_all(root).expect("fixture should be removable"); +} diff --git a/rust/lithe-core/src/tests/languages.rs b/rust/lithe-core/src/tests/languages.rs index 8b9dfe1c..3407bef8 100644 --- a/rust/lithe-core/src/tests/languages.rs +++ b/rust/lithe-core/src/tests/languages.rs @@ -189,6 +189,101 @@ fn maven_scan_returns_recursive_shared_project_model() { fs::remove_dir_all(root).expect("Maven fixture should be removable"); } +#[test] +fn maven_launch_plan_matches_the_shared_compatibility_fixture() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/maven/launch-plan-v1.json" + )) + .expect("Maven launch-plan fixture should be valid JSON"); + let root = temporary_root("maven-launch-plan"); + fs::create_dir_all(root.join("projects/demo/service-api")) + .expect("nested Maven reactor should be creatable"); + fs::write( + root.join("projects/demo/pom.xml"), + r#"demopomservice-api"#, + ) + .expect("reactor pom should be writable"); + fs::write( + root.join("projects/demo/service-api/pom.xml"), + r#"service-api"#, + ) + .expect("module pom should be writable"); + fs::write( + root.join("pom.xml"), + r#"root"#, + ) + .expect("root pom should be writable"); + fs::create_dir_all(root.join(".mvn")).expect("Maven config directory should be creatable"); + fs::write(root.join(".mvn/maven.config"), "-DfromConfig=true\n") + .expect("Maven config should be writable"); + + for case in fixture["cases"] + .as_array() + .expect("Maven launch-plan fixture should contain cases") + { + let request = serde_json::json!({ + "id": case["name"], + "command": "maven.launchPlan", + "payload": { + "root": root, + "context": case["context"], + "module": case["module"], + "goals": case["goals"] + } + }); + let response: Value = serde_json::from_str(&execute_json(&request.to_string())) + .expect("Maven launch-plan response should be JSON"); + + assert_eq!(response["ok"], true, "case {}: {response}", case["name"]); + assert_eq!(response["data"], case["expected"], "case {}", case["name"]); + assert!(!response["data"]["arguments"] + .as_array() + .expect("arguments should be an array") + .iter() + .any(|argument| argument == "-DfromConfig=true")); + } + fs::remove_dir_all(root).expect("Maven launch-plan fixture should be removable"); +} + +#[test] +fn maven_launch_plan_rejects_unknown_modules_and_invalid_invocation_tokens() { + let root = temporary_root("maven-launch-invalid"); + fs::create_dir_all(&root).expect("invalid Maven fixture should be creatable"); + fs::write( + root.join("pom.xml"), + r#"demo"#, + ) + .expect("pom should be writable"); + for (name, module, goals) in [ + ("unknown-module", Some("missing"), vec!["verify"]), + ("missing-goal", None, vec!["-q", "-DskipTests"]), + ("invalid-goal", None, vec!["verify;", "-q"]), + ( + "control-character", + None, + vec!["verify", "-Dvalue=line\nbreak"], + ), + ] { + let response: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": name, + "command": "maven.launchPlan", + "payload": { + "root": root, + "context": {"version": 1, "reactorPath": "."}, + "module": module, + "goals": goals + } + }) + .to_string(), + )) + .expect("invalid Maven response should be JSON"); + assert_eq!(response["ok"], false, "case {name}: {response}"); + assert_eq!(response["error"]["code"], "invalid_request"); + } + fs::remove_dir_all(root).expect("invalid Maven fixture should be removable"); +} + #[test] fn maven_scan_discovers_a_deterministic_project_below_the_workspace() { let root = temporary_root("nested-maven"); diff --git a/rust/lithe-core/src/tests/run_configuration.rs b/rust/lithe-core/src/tests/run_configuration.rs index c5abd2fd..b274f11a 100644 --- a/rust/lithe-core/src/tests/run_configuration.rs +++ b/rust/lithe-core/src/tests/run_configuration.rs @@ -222,7 +222,16 @@ fn run_configuration_generation_uses_a_maven_project_below_the_workspace() { "command": "runConfig.createLaunchPlan", "payload": { "root": root, - "configurationId": service["id"] + "configurationId": service["id"], + "mavenContext": { + "version": 1, + "reactorPath": "projects/demo", + "profiles": ["qa", "dev"], + "settingsPath": "/local/settings.xml", + "skipTests": true, + "mavenExecutablePath": "/local/apache-maven/bin/mvn", + "javaHomePath": "/local/jdk" + } } }) .to_string(), @@ -230,11 +239,74 @@ fn run_configuration_generation_uses_a_maven_project_below_the_workspace() { .unwrap(); assert_eq!(plan["ok"], true, "{plan}"); assert_eq!(plan["data"]["workingDirectory"], "projects/demo"); - assert!(plan["data"]["arguments"] + assert_eq!( + &plan["data"]["arguments"].as_array().unwrap()[..11], + [ + "-B", + "-ntp", + "-P", + "dev,qa", + "-s", + "/local/settings.xml", + "-pl", + "service", + "-DskipTests", + "-Dspring-boot.run.main-class=com.example.App", + "spring-boot:run" + ] + ); + assert!(!plan["data"]["arguments"] .as_array() .unwrap() - .windows(2) - .any(|arguments| arguments == ["-pl", "service"])); + .iter() + .any(|argument| argument == "-am")); + + fs::create_dir_all(root.join("custom-run/service")).unwrap(); + fs::write( + root.join(".lithe/run/configurations.json"), + serde_json::json!({ + "version": 2, + "configurations": [{ + "id": service["id"], + "cwd": "custom-run", + "extensions": {"maven": { + "profiles": ["release"], + "skipTests": false + }} + }] + }) + .to_string(), + ) + .unwrap(); + let overridden_plan: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "plan-explicit-profile", + "command": "runConfig.createLaunchPlan", + "payload": { + "root": root, + "configurationId": service["id"], + "mavenContext": { + "version": 1, + "reactorPath": "projects/demo", + "profiles": ["dev", "qa"], + "skipTests": true + } + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(overridden_plan["ok"], true, "{overridden_plan}"); + assert_eq!(overridden_plan["data"]["workingDirectory"], "custom-run"); + assert_eq!( + &overridden_plan["data"]["arguments"].as_array().unwrap()[..4], + ["-B", "-ntp", "-P", "release"] + ); + assert!(!overridden_plan["data"]["arguments"] + .as_array() + .unwrap() + .iter() + .any(|argument| argument == "-am" || argument == "-DskipTests")); let java_plan: Value = serde_json::from_str(&execute_json( &serde_json::json!({ @@ -1212,7 +1284,8 @@ fn run_configuration_mutations_are_shared_and_validated() { "workingDirectory": ".", "jvmArguments": "\"-Dlabel=hello world\" -Xmx2g", "programArguments": "--dev", - "mavenProfiles": ["dev"] + "mavenProfiles": ["dev"], + "mavenSkipTests": false } }) .to_string(), @@ -1225,12 +1298,43 @@ fn run_configuration_mutations_are_shared_and_validated() { updated_document["configurations"][0]["extensions"]["maven"]["jvmArguments"], serde_json::json!(["-Dlabel=hello world", "-Xmx2g"]) ); + assert_eq!( + updated_document["configurations"][0]["extensions"]["maven"]["skipTests"], + false + ); fs::write( root.join(".lithe/run/configurations.json"), updated["data"]["document"].as_str().unwrap(), ) .unwrap(); + let inherited: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "clear-inherited-options", + "command": "runConfig.updateOptions", + "payload": { + "root": root, + "scope": "project", + "configurationId": "current-file", + "jvmArguments": "-Xmx2g", + "programArguments": "--dev", + "mavenProfiles": ["dev"] + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(inherited["ok"], true, "{inherited}"); + let inherited_document: Value = + serde_json::from_str(inherited["data"]["document"].as_str().unwrap()).unwrap(); + assert!(inherited_document["configurations"][0]["cwd"].is_null()); + assert!(inherited_document["configurations"][0]["extensions"]["maven"]["skipTests"].is_null()); + fs::write( + root.join(".lithe/run/configurations.json"), + inherited["data"]["document"].as_str().unwrap(), + ) + .unwrap(); + let create = |name: &str, module: &str, main_class: &str| -> Value { serde_json::from_str(&execute_json( &serde_json::json!({ diff --git a/rust/lithe-core/src/tests/spring.rs b/rust/lithe-core/src/tests/spring.rs index 3b5cc7e8..6afdd4a2 100644 --- a/rust/lithe-core/src/tests/spring.rs +++ b/rust/lithe-core/src/tests/spring.rs @@ -347,6 +347,186 @@ public class RealConfig { fs::remove_dir_all(root).expect("Spring fixture should be removable"); } +/// Bean and component names must come from the exact annotation's own +/// argument list. A prefix decoy or a later neighbor cannot supply the name. +#[test] +fn spring_index_isolates_bean_and_component_names_from_neighbor_annotations() { + let root = temporary_root("spring-bean-name-isolation"); + let java = root.join("src/main/java/demo"); + fs::create_dir_all(&java).expect("Java fixture directory should be creatable"); + fs::write( + java.join("ClockConfig.java"), + r#"package demo; +@Configuration +public class ClockConfig { + @BeanFactory("decoy") @Bean("real") + public Clock clock() { return null; } + @Bean("foo(") @Bean("bar") + public Clock parenClock() { return null; } + @BeanFactory("decoyOnly") + public Clock decoyClock() { return null; } + @Bean + public Clock unnamedClock() { return null; } +} +"#, + ) + .expect("bean name fixture should be writable"); + fs::write( + java.join("Demo.java"), + "package demo;\n@Service(\"s\") @Component(\"c\")\npublic class Demo {}\n", + ) + .expect("component name fixture should be writable"); + fs::write( + java.join("Ordered.java"), + "package demo;\n@Component(\"c\") @Service(\"s\")\npublic class Ordered {}\n", + ) + .expect("reversed component fixture should be writable"); + + let paths = [ + "src/main/java/demo/ClockConfig.java", + "src/main/java/demo/Demo.java", + "src/main/java/demo/Ordered.java", + ]; + let response = execute_spring(&root, &paths, serde_json::json!({})); + assert_eq!(response["ok"], true, "{response}"); + + let beans = response["data"]["beans"].as_array().unwrap(); + let names = beans + .iter() + .map(|value| value["name"].as_str().unwrap()) + .collect::>(); + assert!(names.contains(&"real"), "{response}"); + assert!(names.contains(&"foo("), "{response}"); + assert!(names.contains(&"unnamedClock"), "{response}"); + assert!(names.contains(&"clockConfig"), "{response}"); + assert!(names.contains(&"s"), "{response}"); + assert!(names.contains(&"c"), "{response}"); + assert!(!names.contains(&"decoy"), "{response}"); + assert!(!names.contains(&"decoyOnly"), "{response}"); + assert!(!names.contains(&"decoyClock"), "{response}"); + assert!(!names.contains(&"bar"), "{response}"); + assert!(!names.contains(&") @Component("), "{response}"); + + let demo = beans + .iter() + .find(|value| value["typeName"] == "Demo") + .unwrap_or_else(|| panic!("missing Demo bean: {response}")); + assert_eq!(demo["name"], "s"); + let ordered = beans + .iter() + .find(|value| value["typeName"] == "Ordered") + .unwrap_or_else(|| panic!("missing Ordered bean: {response}")); + assert_eq!(ordered["name"], "c"); + let clock = beans + .iter() + .find(|value| value["name"] == "real" && value["kind"] == "beanMethod") + .unwrap_or_else(|| panic!("missing named Clock bean: {response}")); + assert_eq!(clock["typeName"], "Clock"); + let paren = beans + .iter() + .find(|value| value["name"] == "foo(" && value["kind"] == "beanMethod") + .unwrap_or_else(|| panic!("missing parenthesis Clock bean: {response}")); + assert_eq!(paren["typeName"], "Clock"); + + fs::remove_dir_all(root).expect("Spring fixture should be removable"); +} + +/// An empty `@Component("")` value is not a bean name. Indexing must fall back +/// to the default type name instead of recording an empty id. +#[test] +fn spring_index_falls_back_when_a_component_name_is_an_empty_string() { + let root = temporary_root("spring-empty-component-name"); + let java = root.join("src/main/java/demo"); + fs::create_dir_all(&java).expect("Java fixture directory should be creatable"); + fs::write( + java.join("EmptyName.java"), + "package demo;\n@Component(\"\")\npublic class EmptyName {}\n", + ) + .expect("empty component fixture should be writable"); + fs::write( + java.join("Neighbor.java"), + "package demo;\n@Component(\"\") @Service(\"s\")\npublic class Neighbor {}\n", + ) + .expect("empty-then-neighbor fixture should be writable"); + + let response = execute_spring( + &root, + &[ + "src/main/java/demo/EmptyName.java", + "src/main/java/demo/Neighbor.java", + ], + serde_json::json!({}), + ); + assert_eq!(response["ok"], true, "{response}"); + + let beans = response["data"]["beans"].as_array().unwrap(); + let empty_name = beans + .iter() + .find(|value| value["typeName"] == "EmptyName") + .unwrap_or_else(|| panic!("missing EmptyName bean: {response}")); + assert_eq!(empty_name["name"], "emptyName"); + assert!( + empty_name["id"].as_str().unwrap().ends_with(":emptyName"), + "{response}" + ); + let neighbor = beans + .iter() + .find(|value| value["typeName"] == "Neighbor") + .unwrap_or_else(|| panic!("missing Neighbor bean: {response}")); + assert_eq!(neighbor["name"], "neighbor"); + assert_ne!(empty_name["name"], ""); + assert_ne!(neighbor["name"], ""); + assert_ne!(neighbor["name"], "s"); + + fs::remove_dir_all(root).expect("Spring fixture should be removable"); +} + +/// Custom annotations that only share a Mapping prefix must not become +/// endpoints or class-level base routes; exact Spring Mapping names still do. +#[test] +fn spring_index_ignores_mapping_annotations_that_only_share_a_prefix() { + let root = temporary_root("spring-mapping-prefix"); + let java = root.join("src/main/java/demo"); + fs::create_dir_all(&java).expect("Java fixture directory should be creatable"); + fs::write( + java.join("DemoController.java"), + r#"package demo; +@RestController +@RequestMappingFoo("/base") +public class DemoController { + @GetMappingCustom("/x") + public String custom() { return ""; } + @PostMappingCustom("/post") + public String postCustom() { return ""; } + @PutMappingCustom("/put") + public String putCustom() { return ""; } + @DeleteMappingCustom("/delete") + public String deleteCustom() { return ""; } + @PatchMappingCustom("/patch") + public String patchCustom() { return ""; } + @GetMapping("/real") + public String real() { return ""; } +} +"#, + ) + .expect("controller fixture should be writable"); + + let response = execute_spring( + &root, + &["src/main/java/demo/DemoController.java"], + serde_json::json!({}), + ); + assert_eq!(response["ok"], true, "{response}"); + + let endpoints = response["data"]["endpoints"].as_array().unwrap(); + assert_eq!(endpoints.len(), 1, "{response}"); + assert_eq!(endpoints[0]["route"], "/real"); + assert_eq!(endpoints[0]["httpMethods"][0], "GET"); + assert_eq!(endpoints[0]["method"], "real"); + + fs::remove_dir_all(root).expect("Spring fixture should be removable"); +} + #[test] fn spring_dependency_metadata_cache_refresh_is_explicit() { let root = temporary_root("spring-metadata-cache"); diff --git a/scripts/verify-shared-contracts.sh b/scripts/verify-shared-contracts.sh index 394fa80c..cad96cac 100755 --- a/scripts/verify-shared-contracts.sh +++ b/scripts/verify-shared-contracts.sh @@ -17,8 +17,36 @@ plugin_fixture="shared/fixtures/plugins/official-v1.json" github_fixture="shared/fixtures/github/pull-request-v1.json" workbench_background_fixture="shared/fixtures/settings/workbench-background-v1.json" syntax_theme_fixture="shared/fixtures/editor-themes/lithe-v1.json" +maven_platform_fixture="shared/fixtures/maven/platform-contract-v1.json" +maven_portable_schema="shared/contracts/maven-portable-configuration-v1.schema.json" +maven_launch_context_schema="shared/contracts/maven-launch-context-v1.schema.json" macos_syntax_colors="macos/Sources/Lithe/Resources/SyntaxHighlighting/color-mappings.json" windows_lithe_theme="windows/tauri/src/extensions/themes/builtin/lithe.json" + +/usr/bin/ruby -rjson -e ' + portable = JSON.parse(File.read(ARGV.fetch(0))) + launch = JSON.parse(File.read(ARGV.fetch(1))) + fixture = JSON.parse(File.read(ARGV.fetch(2))) + + abort "Maven portable schema ID mismatch" unless portable.fetch("$id").end_with?("/maven-portable-configuration-v1.schema.json") + portable_fields = %w[customProfiles selectedProfiles skipTests version] + abort "Maven portable fields differ from v1" unless portable.fetch("properties").keys.sort == portable_fields + abort "Maven portable required fields differ from v1" unless portable.fetch("required").sort == portable_fields + + abort "Maven launch-context schema ID mismatch" unless launch.fetch("$id").end_with?("/maven-launch-context-v1.schema.json") + launch_fields = %w[javaHomePath mavenExecutablePath profiles reactorPath settingsPath skipTests version] + abort "Maven launch-context fields differ from v1" unless launch.fetch("properties").keys.sort == launch_fields.sort + abort "Maven launch-context required fields differ from v1" unless launch.fetch("required").sort == %w[profiles reactorPath skipTests version] + + abort "Maven platform fixture version must be 1" unless fixture.fetch("version") == 1 + phases = fixture.fetch("lifecyclePhases") + abort "Maven lifecycle phases differ from v1" unless phases == %w[clean validate compile test package verify install site deploy] + cases = fixture.fetch("storageIdentityCases") + abort "Maven storage fixture must cover both platforms" unless cases.map { |item| item.fetch("platform") }.sort == %w[macos windows] + abort "Maven storage fixture names must be unique" unless cases.map { |item| item.fetch("name") }.uniq.length == cases.length + abort "Maven storage identities must contain one separator" unless cases.all? { |item| item.fetch("expectedIdentity").count("\0") == 1 } +' "$maven_portable_schema" "$maven_launch_context_schema" "$maven_platform_fixture" + fixture_ids=$(/usr/bin/ruby -rjson -e 'puts JSON.parse(File.read(ARGV.fetch(0))).fetch("modules").map { |m| m.fetch("id") }.sort' "$module_fixture") swift_ids=$(rg '^[[:space:]]*static let .* = ModuleID\("dev\.lithe\.[^"]+"\)' macos/Sources/LitheModuleAPI/Lifecycle/ModuleTypes.swift \ | sed -E 's/.*ModuleID\("([^"]+)"\).*/\1/' \ diff --git a/shared/contracts/application-boundary.md b/shared/contracts/application-boundary.md index 1165c66e..06986513 100644 --- a/shared/contracts/application-boundary.md +++ b/shared/contracts/application-boundary.md @@ -306,3 +306,29 @@ Runtime consumption is declared by the detector from the actual command rather than inferred from the provider namespace. An automatically discovered runtime path is session-effective: validation and launch share it, but persistence still requires an explicit user selection. + +Maven tool-window execution uses `maven.launchPlan`; platform views do not +assemble Maven arguments. Portable profile and Skip Tests defaults conform to +[`maven-portable-configuration-v1.schema.json`](maven-portable-configuration-v1.schema.json). +The transient Core request conforms to +[`maven-launch-context-v1.schema.json`](maven-launch-context-v1.schema.json). +External `settings.xml`, Maven executable, and Maven JDK paths remain in a +machine-local store. They may be supplied transiently to Core for planning and +fingerprinting, but Core never opens `settings.xml` or serializes those paths +into the portable project context. + +The Java language-server startup consumes that same context. Core exposes the +selected `settings.xml` to JDT LS as +`java.configuration.maven.userSettings`, then applies the sorted Profile set +to the reactor and every recursively declared Maven module after JDT LS +reports `ServiceReady`. The Java session remains `initializing` until those +project updates all succeed; a rejected or timed-out update fails the session +instead of silently retaining the previous Maven model. + +Maven-backed Run and Debug launch planning consumes the current project Maven +context. A Run Configuration's explicit Profiles and toolchain paths take +precedence; explicit `cwd` and `extensions.maven.skipTests` values also take +precedence, including `skipTests: false`. Unset values inherit the project +settings. The shared Core applies the final Maven argument order for all three +entry points. Tool-window module launches add `-am`; Run and Debug retain their +existing `-pl ` behavior without implicitly building dependencies. diff --git a/shared/contracts/maven-launch-context-v1.schema.json b/shared/contracts/maven-launch-context-v1.schema.json new file mode 100644 index 00000000..f0bca7d0 --- /dev/null +++ b/shared/contracts/maven-launch-context-v1.schema.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://lithe.dev/contracts/maven-launch-context-v1.schema.json", + "title": "Lithe Maven Launch Context v1", + "type": "object", + "additionalProperties": false, + "required": ["version", "reactorPath", "profiles", "skipTests"], + "properties": { + "version": { "const": 1 }, + "reactorPath": { "type": "string", "minLength": 1 }, + "profiles": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "settingsPath": { "type": ["string", "null"] }, + "skipTests": { "type": "boolean" }, + "mavenExecutablePath": { "type": ["string", "null"] }, + "javaHomePath": { "type": ["string", "null"] } + } +} diff --git a/shared/contracts/maven-portable-configuration-v1.schema.json b/shared/contracts/maven-portable-configuration-v1.schema.json new file mode 100644 index 00000000..13c94584 --- /dev/null +++ b/shared/contracts/maven-portable-configuration-v1.schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://lithe.dev/contracts/maven-portable-configuration-v1.schema.json", + "title": "Lithe Maven Portable Configuration v1", + "type": "object", + "additionalProperties": false, + "required": ["version", "selectedProfiles", "customProfiles", "skipTests"], + "properties": { + "version": { "const": 1 }, + "selectedProfiles": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "customProfiles": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "skipTests": { "type": "boolean" } + } +} diff --git a/shared/contracts/run-configuration-v1.schema.json b/shared/contracts/run-configuration-v1.schema.json index 71999b76..6c09a68d 100644 --- a/shared/contracts/run-configuration-v1.schema.json +++ b/shared/contracts/run-configuration-v1.schema.json @@ -39,6 +39,7 @@ "jvmArguments": { "type": "array", "items": { "type": "string" } }, "programArguments": { "type": "array", "items": { "type": "string" } }, "mavenProfiles": { "type": "array", "items": { "type": "string" } }, + "mavenSkipTests": { "type": "boolean" }, "disabled": { "type": "boolean" } }, "additionalProperties": false diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 70347fed..7cb37cfd 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -80,6 +80,7 @@ stable error code and a user-facing message: | `history.rename` | Set or clear a user-visible label on a history entry | | `history.delete` | Delete one history entry and its snapshot | | `maven.scan` | Parse a Maven project descriptor and recursively return modules/profiles | +| `maven.launchPlan` | Produce a deterministic Maven invocation from a versioned project context | | `maven.diagnostics` | Parse stable Maven compiler diagnostics from build output | | `debug.createSession` | Create a transport-neutral DAP session and return its initialize frame | | `debug.launch` | Queue a launch or attach request, including during initialization | @@ -143,7 +144,7 @@ stable error code and a user-facing message: | `git.write` | Validate and execute shared Git mutations such as stage, commit, branch, checkout, remote sync, clone, and stash | | `git.diff` | Produce a structured working-tree, index, reference, or commit patch | | `git.apply` | Apply or check a patch in `stage`, `unstage`, `discard`, or Shelf restore mode | -| `git.history` | Return deterministic refs, commits, parent hashes, decorations, and pagination state | +| `git.history` | Return deterministic refs, recent local branches, commits, parent hashes, decorations, and pagination state | | `git.commit` | Return one structured commit by revision | | `git.commitFiles` | Return files changed by one commit | | `git.comparison` | Return files changed between a reference and the working tree | @@ -167,6 +168,13 @@ The core rejects absolute paths and `..` traversal for file commands. Native file dialogs, file watching, PTY/ConPTY, Java processes, and runtime discovery remain platform adapters. +`git.history.recentReferences` contains at most five existing local branches in +most-recently-used order. The current branch is first. Core derives checkout +history from the repository's HEAD reflog, de-duplicates branch names, ignores +detached or deleted references, and fills missing entries deterministically. +The remote HEAD target is preferred as the default branch, followed by `main`, +`master`, and the remaining local references in refname order. + The protocol version is currently `1`. Add a fixture under `shared/fixtures/` before changing a response shape or search rule. @@ -235,6 +243,7 @@ response retains the invocation trace and includes the failure as `git.write` accepts a typed mutation request. Its required `operation` values are `stage`, `unstage`, `discard`, `discardAll`, `stageAll`, `commit`, `cherryPick`, `revert`, `reset`, `createBranch`, `publishBranch`, `renameBranch`, `deleteBranch`, `merge`, `rebase`, +`checkoutAndRebase`, `fetch`, `pull`, `push`, `checkout`, `checkoutRevision`, `clone`, `stashPush`, `stashApply`, `stashPop`, `stashDrop`, `operationContinue`, `operationAbort`, and `operationSkip`. Optional fields are `paths`, `reference`, `referenceKind`, @@ -267,6 +276,17 @@ and checks out that branch at a detached HEAD when needed, then pushes it with an upstream. If the push fails, the local branch is intentionally retained so the user can fix credentials or connectivity and retry without losing commits. +`checkoutAndRebase` accepts a complete local or remote `reference` plus its +`referenceKind`. Core records the current local branch, rejects any dirty +working tree before switching, checks out the selected branch, and rebases it +onto the original branch. Tags and the current local branch are rejected. + +`pull` without a reference continues to use the current branch's configured +upstream. When `reference` is present, it must be a complete +`refs/remotes//` reference with `referenceKind: "remote"`; +Core safely splits it into structured remote and branch arguments and applies +the requested `ffOnly`, `merge`, or `rebase` strategy. + `operationContinue`, `operationAbort`, and `operationSkip` inspect Git metadata to select the active merge, rebase, cherry-pick, or revert instead of accepting an operation kind from the caller. Continue is rejected while conflicted paths @@ -555,6 +575,14 @@ provider ID, selected executable/arguments/environment, root URI, working directory, initialization options, optional runtime executable, `jdtlsLaunchResources`, cache directory, and `workspaceFingerprint`, plus initialize, post-initialize readiness, request, and shutdown deadlines. +Java callers may also provide the versioned `mavenContext` accepted by +`maven.launchPlan`. Core validates its reactor and recursively declared modules, +publishes `settingsPath` through +`java.configuration.maven.userSettings`, and, after `ServiceReady`, sends one +`java.project.updateSettings` command per Maven project with +`org.eclipse.m2e.core.selectedProfiles`. The session becomes `ready` only after +every command succeeds; a command error or timeout terminates the session with +`mavenContextFailed` or `mavenContextTimeout` at the `serviceReady` stage. `initializeTimeoutMilliseconds` bounds only the standard LSP handshake. For a provider such as JDT LS that has a later readiness signal, `serviceReadyIdleTimeoutMilliseconds` bounds time without changed work-done @@ -717,6 +745,26 @@ contains its workspace `relativePath`, and `hasWrapper`. Module paths are relative to the selected Maven root and use `/` separators. Malformed XML returns `parse_failed`. +`maven.launchPlan` accepts a workspace `root`, a versioned `context`, an +optional reactor-relative `module`, and an ordered `goals` array whose first +entry is a lifecycle or custom goal. Later entries may be ordinary Maven CLI +arguments such as `-Dname=value` or `-q`. They remain separate process arguments +and are never interpreted by a shell. Context version 1 contains the +workspace-relative `reactorPath`, +selected `profiles`, optional platform-local `settingsPath`, `skipTests`, and +optional Maven/JDK paths used only for the configuration fingerprint. The +response contains the `project-maven` toolchain reference, an argument array, +the workspace-relative reactor working directory, and a deterministic SHA-256 +configuration fingerprint. Profiles are sorted and de-duplicated. Module plans +from `maven.launchPlan` use `-pl -am`; Run and Debug plans use +`-pl ` without `-am`. Settings use `-s`; skipped tests use +`-DskipTests`. Explicit Run `cwd`, Profiles, and `extensions.maven.skipTests` +values override the project context, including `skipTests: false`. +The core never reads `settings.xml` and never copies its path into a portable +project document. Maven itself continues to read `.mvn/maven.config`; the plan +does not expand or duplicate that file's arguments. Fixtures are in +`shared/fixtures/maven/launch-plan-v1.json`. + `maven.diagnostics` accepts `{ "root": string, "output": string }` and returns `{ "issues": [] }`. Diagnostic paths may be absolute or workspace-relative; the response preserves the path text, uses one-based line and column values, @@ -774,7 +822,11 @@ per-configuration toolchain path overrides the corresponding project default. document transformations. They validate scope, paths, supported types, stable IDs, main classes, modules, and argument parsing, then return UTF-8 JSON in the `document` field. The platform adapter selects the target project or local -file and performs the atomic write. These commands never write files. +file and performs the atomic write. These commands never write files. An empty +`workingDirectory` removes the layer's `cwd` override. Optional +`mavenSkipTests` writes `extensions.maven.skipTests`; omission removes the +override so the project Maven context is inherited, while explicit `false` +continues to run tests even when the project default skips them. For project-scoped option updates, selected toolchain paths must resolve inside `root` and are persisted with `/`-separated project-relative paths. Local-scoped updates may carry host absolute paths. `runConfig.updateOptions` and @@ -814,7 +866,14 @@ shell scripts. `runConfig.createLaunchPlan` accepts `root`, `configurationId`, optional `currentFile` and `classPath`, optional `debugPort`, and optional -`localDocument`. It returns a toolchain +`localDocument`. Maven-backed Run and Debug callers may also supply the same +versioned `mavenContext` accepted by `maven.launchPlan`. Explicit profiles in +the resolved Run Configuration replace the context profiles; otherwise the +project profiles are inherited. Explicit `extensions.maven.skipTests` and +`cwd` values also replace the context values. Core applies the shared settings, +module, Skip Tests, and reactor-working-directory rules to the generated +framework or Java-main arguments without adding tool-window-only `-am`. It +returns a toolchain reference, argument array, project-relative working directory, and structured environment references. It does not return a shell command or platform executable path. All project paths use `/`, reject absolute paths and `..` diff --git a/shared/fixtures/git/history-response-v1.json b/shared/fixtures/git/history-response-v1.json new file mode 100644 index 00000000..44d376d1 --- /dev/null +++ b/shared/fixtures/git/history-response-v1.json @@ -0,0 +1,49 @@ +{ + "references": [ + { + "fullName": "refs/heads/feature/recent", + "shortName": "feature/recent", + "kind": "local", + "isCurrent": true, + "upstreamShortName": null + }, + { + "fullName": "refs/heads/main", + "shortName": "main", + "kind": "local", + "isCurrent": false, + "upstreamShortName": "origin/main" + } + ], + "recentReferences": [ + { + "fullName": "refs/heads/feature/recent", + "shortName": "feature/recent", + "kind": "local", + "isCurrent": true, + "upstreamShortName": null + }, + { + "fullName": "refs/heads/main", + "shortName": "main", + "kind": "local", + "isCurrent": false, + "upstreamShortName": "origin/main" + } + ], + "commits": [ + { + "hash": "0123456789abcdef0123456789abcdef01234567", + "shortHash": "0123456", + "parentHashes": [], + "authorName": "Lithe Test", + "authorEmail": "test@example.invalid", + "date": "2026/08/30 12:00", + "subject": "Initial commit", + "decorations": "HEAD -> feature/recent" + } + ], + "hasMore": false, + "userName": "Lithe Test", + "userEmail": "test@example.invalid" +} diff --git a/shared/fixtures/git/write.json b/shared/fixtures/git/write.json index e15f0e99..1740d6ba 100644 --- a/shared/fixtures/git/write.json +++ b/shared/fixtures/git/write.json @@ -29,6 +29,21 @@ "referenceKind": "local" } }, + { + "operation": "checkoutAndRebase", + "payload": { + "reference": "refs/remotes/origin/feature/core", + "referenceKind": "remote" + } + }, + { + "operation": "pull", + "payload": { + "reference": "refs/remotes/origin/feature/core", + "referenceKind": "remote", + "mode": "rebase" + } + }, { "operation": "stashPush", "payload": { @@ -52,6 +67,15 @@ "paths": ["../outside.txt"] }, "errorCode": "invalid_request" + }, + { + "operation": "pull", + "payload": { + "reference": "refs/heads/main", + "referenceKind": "local", + "mode": "merge" + }, + "errorCode": "invalid_request" } ] } diff --git a/shared/fixtures/maven/launch-plan-v1.json b/shared/fixtures/maven/launch-plan-v1.json new file mode 100644 index 00000000..3dfa65a2 --- /dev/null +++ b/shared/fixtures/maven/launch-plan-v1.json @@ -0,0 +1,86 @@ +{ + "version": 1, + "cases": [ + { + "name": "nested reactor module lifecycle with local settings", + "context": { + "version": 1, + "reactorPath": "projects/demo", + "profiles": ["qa", "dev", "dev"], + "settingsPath": "/Users/example/.m2/settings.xml", + "skipTests": true, + "mavenExecutablePath": "/opt/apache-maven/bin/mvn", + "javaHomePath": "/Library/Java/JavaVirtualMachines/example/Contents/Home" + }, + "module": "service-api", + "goals": ["verify"], + "expected": { + "version": 1, + "executable": { "toolchain": "project-maven" }, + "arguments": [ + "-B", + "-ntp", + "-P", + "dev,qa", + "-s", + "/Users/example/.m2/settings.xml", + "-pl", + "service-api", + "-am", + "-DskipTests", + "verify" + ], + "workingDirectory": "projects/demo", + "configurationFingerprint": "sha256:8e665a85f0c0a9fa5567cfc88d90cc1e794a34810b4617662c5610699ad22df6" + } + }, + { + "name": "root reactor custom goal preserves maven config behavior", + "context": { + "version": 1, + "reactorPath": ".", + "profiles": [], + "skipTests": false + }, + "module": null, + "goals": ["spring-boot:run"], + "expected": { + "version": 1, + "executable": { "toolchain": "project-maven" }, + "arguments": ["-B", "-ntp", "spring-boot:run"], + "workingDirectory": ".", + "configurationFingerprint": "sha256:225165d5264a20dba15802483efebc3a0d29abb16f36702d599324891e242511" + } + }, + { + "name": "custom goal keeps property and quiet arguments as argv", + "context": { + "version": 1, + "reactorPath": ".", + "profiles": [], + "skipTests": false + }, + "module": null, + "goals": [ + "help:evaluate", + "-Dexpression=fixture.config", + "-q", + "-DforceStdout" + ], + "expected": { + "version": 1, + "executable": { "toolchain": "project-maven" }, + "arguments": [ + "-B", + "-ntp", + "help:evaluate", + "-Dexpression=fixture.config", + "-q", + "-DforceStdout" + ], + "workingDirectory": ".", + "configurationFingerprint": "sha256:225165d5264a20dba15802483efebc3a0d29abb16f36702d599324891e242511" + } + } + ] +} diff --git a/shared/fixtures/maven/platform-contract-v1.json b/shared/fixtures/maven/platform-contract-v1.json new file mode 100644 index 00000000..afa7ca9b --- /dev/null +++ b/shared/fixtures/maven/platform-contract-v1.json @@ -0,0 +1,30 @@ +{ + "version": 1, + "lifecyclePhases": [ + "clean", + "validate", + "compile", + "test", + "package", + "verify", + "install", + "site", + "deploy" + ], + "storageIdentityCases": [ + { + "name": "macOS preserves workspace path case", + "platform": "macos", + "workspacePath": "/Users/Example/Projects/Lithe", + "reactorPath": "services/api", + "expectedIdentity": "/Users/Example/Projects/Lithe\u0000services/api" + }, + { + "name": "Windows folds workspace case and normalizes reactor separators", + "platform": "windows", + "workspacePath": "C:\\Users\\Example\\Projects\\Lithe", + "reactorPath": "services\\api", + "expectedIdentity": "c:\\users\\example\\projects\\lithe\u0000services/api" + } + ] +} diff --git a/windows/tauri/src-tauri/src/platform.rs b/windows/tauri/src-tauri/src/platform.rs index 2588249e..09bcb010 100644 --- a/windows/tauri/src-tauri/src/platform.rs +++ b/windows/tauri/src-tauri/src/platform.rs @@ -157,6 +157,13 @@ fn translate(command: &str, args: Value) -> Result<(String, Value), String> { payload.insert("pathspecs".into(), json!(["."])); "git.diff" } + "git_reference_worktree_diff" => { + let reference = take_reference(&mut payload)?; + payload.insert("reference".into(), json!(reference)); + payload.insert("pathspecs".into(), json!(["."])); + payload.insert("untracked".into(), json!(true)); + "git.diff" + } "git_stash_diff" => { let index = payload .remove("stashIndex") @@ -168,6 +175,16 @@ fn translate(command: &str, args: Value) -> Result<(String, Value), String> { } "git_create_branch" => { move_field(&mut payload, "branchName", "name"); + if !payload.contains_key("reference") { + if let Some(from_branch) = payload.remove("fromBranch") { + let branch = from_branch + .as_str() + .filter(|value| !value.trim().is_empty()) + .map(local_branch_reference) + .unwrap_or_else(|| "HEAD".to_string()); + payload.insert("reference".into(), json!(branch)); + } + } payload.insert("operation".into(), json!("createBranch")); payload.entry("reference").or_insert_with(|| json!("HEAD")); "git.write" @@ -179,20 +196,33 @@ fn translate(command: &str, args: Value) -> Result<(String, Value), String> { "git.write" } "git_checkout" => { - let branch = take_text(&mut payload, "branchName")?; - payload.insert("reference".into(), json!(local_branch_reference(&branch))); + let reference = take_reference(&mut payload)?; + let reference_kind = reference_kind(&reference); + payload.insert("reference".into(), json!(reference)); payload.insert("operation".into(), json!("checkout")); - payload.insert("referenceKind".into(), json!("local")); + payload + .entry("referenceKind") + .or_insert_with(|| json!(reference_kind)); + "git.write" + } + "git_checkout_and_rebase" => { + let reference = take_reference(&mut payload)?; + let reference_kind = reference_kind(&reference); + payload.insert("reference".into(), json!(reference)); + payload.insert("operation".into(), json!("checkoutAndRebase")); + payload + .entry("referenceKind") + .or_insert_with(|| json!(reference_kind)); "git.write" } "git_checkout_preflight" => { - let branch = take_text(&mut payload, "branchName")?; - payload.insert("reference".into(), json!(local_branch_reference(&branch))); + let reference = take_reference(&mut payload)?; + payload.insert("reference".into(), json!(reference)); "git.checkoutPreflight" } "git_merge" | "git_rebase" => { - let branch = take_text(&mut payload, "branchName")?; - payload.insert("reference".into(), json!(local_branch_reference(&branch))); + let reference = take_reference(&mut payload)?; + payload.insert("reference".into(), json!(reference)); payload.insert( "operation".into(), json!(if command == "git_merge" { @@ -204,8 +234,8 @@ fn translate(command: &str, args: Value) -> Result<(String, Value), String> { "git.write" } "git_integration_preflight" => { - let branch = take_text(&mut payload, "branchName")?; - payload.insert("reference".into(), json!(local_branch_reference(&branch))); + let reference = take_reference(&mut payload)?; + payload.insert("reference".into(), json!(reference)); "git.integrationPreflight" } "git_operation_state" => "git.operationState", @@ -490,6 +520,27 @@ fn local_branch_reference(branch: &str) -> String { } } +fn take_reference(payload: &mut Map) -> Result { + if let Some(reference) = payload.remove("reference") { + return reference + .as_str() + .map(str::to_string) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "Windows platform command requires reference".to_string()); + } + take_text(payload, "branchName").map(|branch| local_branch_reference(&branch)) +} + +fn reference_kind(reference: &str) -> &'static str { + if reference.starts_with("refs/remotes/") { + "remote" + } else if reference.starts_with("refs/tags/") { + "tag" + } else { + "local" + } +} + fn paths_from_file(payload: &mut Map) { if let Some(path) = payload.remove("filePath") { payload.insert("paths".into(), Value::Array(vec![path])); @@ -584,6 +635,68 @@ mod tests { ); } + #[test] + fn preserves_complete_references_for_git_log_actions() { + let (checkout_command, checkout_payload) = translate( + "git_checkout", + json!({ + "repoPath": "C:/work", + "reference": "refs/remotes/origin/feature/demo", + "referenceKind": "remote" + }), + ) + .unwrap(); + assert_eq!(checkout_command, "git.write"); + assert_eq!( + checkout_payload, + json!({ + "root": "C:/work", + "operation": "checkout", + "reference": "refs/remotes/origin/feature/demo", + "referenceKind": "remote" + }) + ); + + let (rebase_command, rebase_payload) = translate( + "git_checkout_and_rebase", + json!({ + "repoPath": "C:/work", + "reference": "refs/remotes/origin/feature/demo", + "referenceKind": "remote" + }), + ) + .unwrap(); + assert_eq!(rebase_command, "git.write"); + assert_eq!( + rebase_payload, + json!({ + "root": "C:/work", + "operation": "checkoutAndRebase", + "reference": "refs/remotes/origin/feature/demo", + "referenceKind": "remote" + }) + ); + + let (diff_command, diff_payload) = translate( + "git_reference_worktree_diff", + json!({ + "repoPath": "C:/work", + "reference": "refs/remotes/origin/feature/demo" + }), + ) + .unwrap(); + assert_eq!(diff_command, "git.diff"); + assert_eq!( + diff_payload, + json!({ + "root": "C:/work", + "reference": "refs/remotes/origin/feature/demo", + "pathspecs": ["."], + "untracked": true + }) + ); + } + #[test] fn translates_checkout_preflight_reference() { let (command, payload) = translate( @@ -630,6 +743,20 @@ mod tests { "reference": "refs/heads/main" }) ); + + let (_, remote_merge_payload) = translate( + "git_merge", + json!({ + "repoPath": "C:/work", + "reference": "refs/remotes/origin/feature/demo", + "referenceKind": "remote" + }), + ) + .unwrap(); + assert_eq!( + remote_merge_payload["reference"], + "refs/remotes/origin/feature/demo" + ); } #[test] diff --git a/windows/tauri/src/features/file-explorer/components/file-explorer-tree.tsx b/windows/tauri/src/features/file-explorer/components/file-explorer-tree.tsx index 4a5c61dd..c683cf56 100644 --- a/windows/tauri/src/features/file-explorer/components/file-explorer-tree.tsx +++ b/windows/tauri/src/features/file-explorer/components/file-explorer-tree.tsx @@ -17,6 +17,8 @@ import { useShallow } from "zustand/react/shallow"; import { useEventListener } from "usehooks-ts"; import { useFileClipboardStore } from "@/features/file-explorer/stores/file-explorer-clipboard.store"; import { useFileTreeStore } from "@/features/file-explorer/stores/file-explorer-tree.store"; +import { pasteIntoExplorerDirectory } from "@/features/file-explorer/lib/paste-into-explorer-directory"; +import { JavaClipboardPasteError } from "@/features/file-explorer/lib/paste-java-class-from-clipboard"; import { collectFileTreeSearchHits, filterFileTreeEntries, @@ -50,6 +52,7 @@ import { useTranslation } from "@/i18n/locale-provider"; import { Button } from "@/ui/button"; import Dialog from "@/ui/dialog"; import { EmptyState } from "@/ui/empty"; +import { toast } from "sonner"; import { DropdownMenu, DropdownMenuCheckboxItem, @@ -1161,8 +1164,33 @@ function FileExplorerTreeComponent({ const sep = current.path.includes("\\") ? "\\" : "/"; const targetDir = isDir ? current.path : current.path.split(sep).slice(0, -1).join(sep); if (targetDir) { - clipboardActions.paste(targetDir).then(() => { - onRefreshDirectory?.(targetDir, { force: true }); + void pasteIntoExplorerDirectory({ + targetDirectory: targetDir, + createFileInDirectory: onCreateNewFileInDirectory, + refreshDirectory: onRefreshDirectory, + onJavaClassCreated: (fileName) => { + toast.success(t("files.created", { name: fileName })); + }, + onJavaClassFailed: (error) => { + if (error instanceof JavaClipboardPasteError) { + if (error.code === "exists") { + toast.error(t("files.javaClassAlreadyExists", { name: error.fileName ?? "" })); + return; + } + if (error.code === "remote") { + toast.error(t("files.javaPasteRemoteUnsupported")); + return; + } + toast.error(t("files.createFailed", { name: error.fileName ?? "Java class" })); + return; + } + toast.error(t("files.createFailed", { name: "Java class" }), { + description: error instanceof Error ? error.message : undefined, + }); + }, + onNothingToPaste: () => { + toast.error(t("files.nothingToPaste")); + }, }); } return; diff --git a/windows/tauri/src/features/file-explorer/hooks/use-file-explorer-context-menu.tsx b/windows/tauri/src/features/file-explorer/hooks/use-file-explorer-context-menu.tsx index 636085d6..42b5ea6a 100644 --- a/windows/tauri/src/features/file-explorer/hooks/use-file-explorer-context-menu.tsx +++ b/windows/tauri/src/features/file-explorer/hooks/use-file-explorer-context-menu.tsx @@ -33,6 +33,8 @@ import { import { openLocalHistoryForPath } from "@/features/local-history/utils/open-local-history"; import { useFileClipboardStore } from "@/features/file-explorer/stores/file-explorer-clipboard.store"; import { useFileTreeStore } from "@/features/file-explorer/stores/file-explorer-tree.store"; +import { pasteIntoExplorerDirectory } from "@/features/file-explorer/lib/paste-into-explorer-directory"; +import { JavaClipboardPasteError } from "@/features/file-explorer/lib/paste-java-class-from-clipboard"; import type { ContextMenuState } from "@/features/file-system/types/app.types"; import { Button } from "@/ui/button"; import { Dropdown, type MenuItem } from "@/ui/dropdown"; @@ -120,7 +122,6 @@ export function useFileExplorerContextMenu({ ); const [propertiesDialog, setPropertiesDialog] = useState(null); const clipboardActions = useFileClipboardStore.getState().actions; - const clipboard = useFileClipboardStore((state) => state.clipboard); const createEnvTemplateFile = useCallback( async (sourcePath: string, targetFileName: string, options?: { overwrite?: boolean }) => { @@ -402,14 +403,39 @@ export function useFileExplorerContextMenu({ }, ); - if (clipboard && contextMenu.isDir) { + if (contextMenu.isDir) { items.push({ id: "paste", label: t("files.paste"), icon: , onClick: () => { - clipboardActions.paste(contextMenu.path).then(() => { - onRefreshDirectory?.(contextMenu.path, { force: true }); + void pasteIntoExplorerDirectory({ + targetDirectory: contextMenu.path, + createFileInDirectory: onCreateNewFileInDirectory, + refreshDirectory: onRefreshDirectory, + onJavaClassCreated: (fileName) => { + toast.success(t("files.created", { name: fileName })); + }, + onJavaClassFailed: (error) => { + if (error instanceof JavaClipboardPasteError) { + if (error.code === "exists") { + toast.error(t("files.javaClassAlreadyExists", { name: error.fileName ?? "" })); + return; + } + if (error.code === "remote") { + toast.error(t("files.javaPasteRemoteUnsupported")); + return; + } + toast.error(t("files.createFailed", { name: error.fileName ?? "Java class" })); + return; + } + toast.error(t("files.createFailed", { name: "Java class" }), { + description: error instanceof Error ? error.message : undefined, + }); + }, + onNothingToPaste: () => { + toast.error(t("files.nothingToPaste")); + }, }); }, }); @@ -457,7 +483,6 @@ export function useFileExplorerContextMenu({ return items; }, [ canRemoveWorkspaceRootPath, - clipboard, clipboardActions, contextMenu, createEnvTemplateFile, diff --git a/windows/tauri/src/features/file-explorer/lib/java-clipboard-class.test.ts b/windows/tauri/src/features/file-explorer/lib/java-clipboard-class.test.ts new file mode 100644 index 00000000..5ca47b6a --- /dev/null +++ b/windows/tauri/src/features/file-explorer/lib/java-clipboard-class.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from "bun:test"; +import { + javaTypeFileName, + parseJavaTypeClipboard, +} from "./java-clipboard-class"; + +describe("parseJavaTypeClipboard", () => { + test("parses a public class with package and annotations", () => { + const text = `package com.yupi.usercenter.controller; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class MappingPrefixDemoController { + @GetMapping("/real") + public String real() { return "real"; } +} +`; + const parsed = parseJavaTypeClipboard(text); + expect(parsed?.typeName).toBe("MappingPrefixDemoController"); + expect(parsed?.content).toContain("@GetMapping(\"/real\")"); + }); + + test("prefers the public type when a package-private decoy exists first", () => { + const text = `class Helper {} +public interface TeamApi {} +`; + expect(parseJavaTypeClipboard(text)?.typeName).toBe("TeamApi"); + }); + + test("falls back to the first top-level type without public", () => { + const text = `package demo;\nenum Status { OK }\n`; + expect(parseJavaTypeClipboard(text)?.typeName).toBe("Status"); + }); + + test("parses records and rejects non-type clipboard text", () => { + expect(parseJavaTypeClipboard("public record Point(int x, int y) {}")?.typeName).toBe( + "Point", + ); + expect(parseJavaTypeClipboard("just some notes")).toBeNull(); + expect(parseJavaTypeClipboard("")).toBeNull(); + }); + + test("ignores nested types at brace depth greater than zero", () => { + const text = `class Outer { + public static class Inner {} +} +`; + expect(parseJavaTypeClipboard(text)?.typeName).toBe("Outer"); + }); + + test("ignores type declarations inside comments", () => { + const text = `/* public class FakeInBlock {} */ +// public class FakeInLine {} +public class Real {} +`; + expect(parseJavaTypeClipboard(text)?.typeName).toBe("Real"); + }); + + test("parses annotation interfaces including public @interface", () => { + expect(parseJavaTypeClipboard("public @interface JsonAdapter {}")?.typeName).toBe( + "JsonAdapter", + ); + expect(parseJavaTypeClipboard("@interface Fast {}")?.typeName).toBe("Fast"); + }); + + test("prefers a public annotation interface over an earlier package-private class", () => { + const text = `class Helper {} +public @interface JsonAdapter {} +`; + expect(parseJavaTypeClipboard(text)?.typeName).toBe("JsonAdapter"); + }); +}); + +describe("javaTypeFileName", () => { + test("uses the type name as the java file name", () => { + expect(javaTypeFileName("MappingPrefixDemoController")).toBe( + "MappingPrefixDemoController.java", + ); + }); +}); diff --git a/windows/tauri/src/features/file-explorer/lib/java-clipboard-class.ts b/windows/tauri/src/features/file-explorer/lib/java-clipboard-class.ts new file mode 100644 index 00000000..df1af866 --- /dev/null +++ b/windows/tauri/src/features/file-explorer/lib/java-clipboard-class.ts @@ -0,0 +1,118 @@ +/** + * Detects an IDEA-style Java type declaration in clipboard text so pasting onto + * a package/folder can create `TypeName.java` with that content. + */ + +export interface ParsedJavaClipboardClass { + /** Top-level type name used for the `.java` file name. */ + typeName: string; + /** Clipboard text written into the new file unchanged. */ + content: string; +} + +const TYPE_KEYWORDS = "@interface|class|interface|enum|record"; +const MODIFIERS = + "(?:(?:public|protected|private|abstract|final|sealed|non-sealed|static)\\s+)*"; + +const TYPE_AT_CURSOR = new RegExp( + String.raw`^(public\s+)?${MODIFIERS}(?:${TYPE_KEYWORDS})\s+([A-Za-z_$][\w$]*)\b`, +); + +function isIdentifierChar(character: string): boolean { + return /[A-Za-z0-9_$]/.test(character); +} + +function skipLineComment(source: string, index: number): number { + const newline = source.indexOf("\n", index); + return newline === -1 ? source.length : newline + 1; +} + +function skipBlockComment(source: string, index: number): number { + const end = source.indexOf("*/", index + 2); + return end === -1 ? source.length : end + 2; +} + +function skipQuoted(source: string, index: number, quote: string): number { + let cursor = index + 1; + while (cursor < source.length) { + if (source[cursor] === "\\") { + cursor += 2; + continue; + } + if (source[cursor] === quote) return cursor + 1; + cursor += 1; + } + return source.length; +} + +function skipTextBlock(source: string, index: number): number { + const end = source.indexOf('"""', index + 3); + return end === -1 ? source.length : end + 3; +} + +/** + * Walks the source and records type declarations that sit outside comments + * and strings at brace depth 0, so nested and commented types cannot win. + */ +function scanTopLevelJavaTypes(source: string): { name: string; isPublic: boolean }[] { + const types: { name: string; isPublic: boolean }[] = []; + let index = 0; + let depth = 0; + + while (index < source.length) { + if (source.startsWith("//", index)) { + index = skipLineComment(source, index); + continue; + } + if (source.startsWith("/*", index)) { + index = skipBlockComment(source, index); + continue; + } + if (source.startsWith('"""', index)) { + index = skipTextBlock(source, index); + continue; + } + const current = source[index]; + if (current === '"' || current === "'") { + index = skipQuoted(source, index, current); + continue; + } + + if (depth === 0) { + const atTokenStart = index === 0 || !isIdentifierChar(source[index - 1] ?? ""); + if (atTokenStart) { + const match = source.slice(index).match(TYPE_AT_CURSOR); + if (match?.[2]) { + types.push({ name: match[2], isPublic: Boolean(match[1]) }); + index += match[0].length; + continue; + } + } + } + + if (current === "{") depth += 1; + else if (current === "}" && depth > 0) depth -= 1; + index += 1; + } + + return types; +} + +/** + * Returns the primary Java type in `text`, preferring a `public` type so the + * file name matches Java's one-public-type-per-file rule. + */ +export function parseJavaTypeClipboard(text: string): ParsedJavaClipboardClass | null { + const content = text.replace(/^\uFEFF/, ""); + if (!content.trim()) return null; + + const types = scanTopLevelJavaTypes(content); + const publicType = types.find((type) => type.isPublic); + const chosen = publicType ?? types[0]; + if (!chosen) return null; + return { typeName: chosen.name, content }; +} + +export function javaTypeFileName(typeName: string): string { + return `${typeName}.java`; +} diff --git a/windows/tauri/src/features/file-explorer/lib/paste-into-explorer-directory.ts b/windows/tauri/src/features/file-explorer/lib/paste-into-explorer-directory.ts new file mode 100644 index 00000000..ec3fe195 --- /dev/null +++ b/windows/tauri/src/features/file-explorer/lib/paste-into-explorer-directory.ts @@ -0,0 +1,50 @@ +import { useFileClipboardStore } from "@/features/file-explorer/stores/file-explorer-clipboard.store"; +import { + tryPasteJavaClassFromSystemClipboard, + type CreateFileInDirectory, +} from "./paste-java-class-from-clipboard"; + +/** + * Pastes in-app copied/cut files when present; otherwise tries IDEA-style Java + * class paste from the system clipboard into `targetDirectory`. + */ +export async function pasteIntoExplorerDirectory(options: { + targetDirectory: string; + createFileInDirectory?: CreateFileInDirectory; + refreshDirectory?: (path: string, options?: { force?: boolean }) => void; + onJavaClassCreated?: (fileName: string) => void; + onJavaClassFailed?: (error: unknown) => void; + onNothingToPaste?: () => void; +}): Promise<"files" | "java-class" | "nothing"> { + const { targetDirectory, createFileInDirectory, refreshDirectory } = options; + const clipboard = useFileClipboardStore.getState().clipboard; + const clipboardActions = useFileClipboardStore.getState().actions; + + if (clipboard) { + await clipboardActions.paste(targetDirectory); + refreshDirectory?.(targetDirectory, { force: true }); + return "files"; + } + + if (!createFileInDirectory) { + options.onNothingToPaste?.(); + return "nothing"; + } + + try { + const created = await tryPasteJavaClassFromSystemClipboard({ + targetDirectory, + createFileInDirectory, + }); + if (!created) { + options.onNothingToPaste?.(); + return "nothing"; + } + refreshDirectory?.(targetDirectory, { force: true }); + options.onJavaClassCreated?.(created.fileName); + return "java-class"; + } catch (error) { + options.onJavaClassFailed?.(error); + return "nothing"; + } +} diff --git a/windows/tauri/src/features/file-explorer/lib/paste-java-class-from-clipboard.test.ts b/windows/tauri/src/features/file-explorer/lib/paste-java-class-from-clipboard.test.ts new file mode 100644 index 00000000..d7e36974 --- /dev/null +++ b/windows/tauri/src/features/file-explorer/lib/paste-java-class-from-clipboard.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test"; +import { + JavaClipboardPasteError, + assertLocalJavaPasteDirectory, + requireCreatedFilePath, +} from "./paste-java-class-from-clipboard"; + +describe("assertLocalJavaPasteDirectory", () => { + test("rejects remote targets before any file is created", () => { + expect(() => assertLocalJavaPasteDirectory("remote://conn/src/main/java")).toThrow( + JavaClipboardPasteError, + ); + try { + assertLocalJavaPasteDirectory("remote://conn/src/main/java"); + } catch (error) { + expect(error).toBeInstanceOf(JavaClipboardPasteError); + expect((error as JavaClipboardPasteError).code).toBe("remote"); + } + }); + + test("allows local directories", () => { + expect(() => assertLocalJavaPasteDirectory("D:\\project\\src\\main\\java")).not.toThrow(); + }); +}); + +describe("requireCreatedFilePath", () => { + test("fails when createFileInDirectory does not return a path", () => { + expect(() => requireCreatedFilePath(undefined, "Demo.java")).toThrow(JavaClipboardPasteError); + try { + requireCreatedFilePath(undefined, "Demo.java"); + } catch (error) { + expect(error).toBeInstanceOf(JavaClipboardPasteError); + expect((error as JavaClipboardPasteError).code).toBe("create-failed"); + expect((error as JavaClipboardPasteError).fileName).toBe("Demo.java"); + } + }); + + test("returns the created path", () => { + expect(requireCreatedFilePath("D:\\project\\Demo.java", "Demo.java")).toBe( + "D:\\project\\Demo.java", + ); + }); +}); diff --git a/windows/tauri/src/features/file-explorer/lib/paste-java-class-from-clipboard.ts b/windows/tauri/src/features/file-explorer/lib/paste-java-class-from-clipboard.ts new file mode 100644 index 00000000..a7ed9d14 --- /dev/null +++ b/windows/tauri/src/features/file-explorer/lib/paste-java-class-from-clipboard.ts @@ -0,0 +1,101 @@ +import { readClipboardText } from "@/utils/clipboard"; +import { writeFile } from "@/features/file-system/controllers/platform"; +import { useBufferStore } from "@/features/editor/stores/buffer.store"; +import { isRemotePath } from "@/features/remote/utils/remote-path"; +import { getBaseName, joinPath } from "@/utils/path-helpers"; +import { javaTypeFileName, parseJavaTypeClipboard } from "./java-clipboard-class"; + +export type CreateFileInDirectory = ( + directoryPath: string, + fileName: string, +) => void | string | Promise; + +export type JavaClipboardPasteErrorCode = "exists" | "remote" | "create-failed"; + +export class JavaClipboardPasteError extends Error { + readonly code: JavaClipboardPasteErrorCode; + readonly fileName?: string; + + constructor(code: JavaClipboardPasteErrorCode, fileName?: string) { + super(code); + this.name = "JavaClipboardPasteError"; + this.code = code; + this.fileName = fileName; + } +} + +export function assertLocalJavaPasteDirectory(directoryPath: string): void { + if (isRemotePath(directoryPath)) { + throw new JavaClipboardPasteError("remote"); + } +} + +export function requireCreatedFilePath( + createdPath: string | undefined, + fileName: string, +): string { + if (!createdPath) { + throw new JavaClipboardPasteError("create-failed", fileName); + } + return createdPath; +} + +async function pathLooksOccupied(path: string): Promise { + try { + const { exists } = await import("@tauri-apps/plugin-fs"); + return await exists(path); + } catch { + try { + const { readFile } = await import("@/features/file-system/controllers/platform"); + await readFile(path); + return true; + } catch { + return false; + } + } +} + +/** + * When the in-app file clipboard is empty, paste system text that looks like a + * Java type as `TypeName.java` into `targetDirectory` (IDEA package paste). + */ +export async function tryPasteJavaClassFromSystemClipboard(options: { + targetDirectory: string; + createFileInDirectory: CreateFileInDirectory; +}): Promise<{ path: string; fileName: string } | null> { + let text: string; + try { + text = await readClipboardText(); + } catch { + return null; + } + + const parsed = parseJavaTypeClipboard(text); + if (!parsed) return null; + + assertLocalJavaPasteDirectory(options.targetDirectory); + + const fileName = javaTypeFileName(parsed.typeName); + const destination = joinPath(options.targetDirectory, fileName); + if (await pathLooksOccupied(destination)) { + throw new JavaClipboardPasteError("exists", fileName); + } + + const result = await Promise.resolve( + options.createFileInDirectory(options.targetDirectory, fileName), + ); + const createdPath = requireCreatedFilePath( + typeof result === "string" ? result : undefined, + fileName, + ); + + await writeFile(createdPath, parsed.content); + + const bufferStore = useBufferStore.getState(); + const createdBuffer = bufferStore.buffers.find((buffer) => buffer.path === createdPath); + if (createdBuffer) { + bufferStore.actions.updateBufferContent(createdBuffer.id, parsed.content, false); + } + + return { path: createdPath, fileName: getBaseName(createdPath) || fileName }; +} diff --git a/windows/tauri/src/features/git/api/git-branches-api.ts b/windows/tauri/src/features/git/api/git-branches-api.ts index 670fe7b6..deacd3f8 100644 --- a/windows/tauri/src/features/git/api/git-branches-api.ts +++ b/windows/tauri/src/features/git/api/git-branches-api.ts @@ -6,6 +6,7 @@ import { resolveRepositoryPath, resolveRepositoryPathOrThrow, } from "./git-repo-api"; +import type { GitReference } from "../types/git.types"; interface CheckoutResult { success: boolean; @@ -51,13 +52,26 @@ export const getBranches = async (repoPath: string): Promise => { export const checkoutBranch = async ( repoPath: string, branchName: string, +): Promise => { + const shortName = branchName.replace(/^refs\/heads\//, ""); + return checkoutReference(repoPath, { + fullName: branchName.startsWith("refs/heads/") ? branchName : `refs/heads/${branchName}`, + shortName, + kind: "local", + isCurrent: false, + }); +}; + +export const checkoutReference = async ( + repoPath: string, + reference: GitReference, ): Promise => { try { const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); const preflight = await tauriInvoke("git_checkout_preflight", { repoPath: resolvedRepoPath, - branchName, + reference: reference.fullName, }); if (preflight.blocked) { return { @@ -69,7 +83,8 @@ export const checkoutBranch = async ( const result = await tauriInvoke("git_checkout", { repoPath: resolvedRepoPath, - branchName, + reference: reference.fullName, + referenceKind: reference.kind, }); if (result.success) { emitGitChanged({ @@ -92,14 +107,18 @@ export const checkoutBranch = async ( export const createBranch = async ( repoPath: string, branchName: string, - fromBranch?: string, + from?: string | GitReference, ): Promise => { try { const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); await tauriInvoke("git_create_branch", { repoPath: resolvedRepoPath, branchName, - fromBranch, + ...(typeof from === "string" + ? { fromBranch: from } + : from + ? { reference: from.fullName, referenceKind: from.kind } + : {}), }); emitGitChanged({ repoPath: resolvedRepoPath, diff --git a/windows/tauri/src/features/git/api/git-diff-api.ts b/windows/tauri/src/features/git/api/git-diff-api.ts index dc4e68ee..6be3f41d 100644 --- a/windows/tauri/src/features/git/api/git-diff-api.ts +++ b/windows/tauri/src/features/git/api/git-diff-api.ts @@ -353,6 +353,26 @@ export const getRefDiff = async ( } }; +export const getReferenceWorkingTreeDiff = async ( + repoPath: string, + reference: string, +): Promise => { + try { + const resolvedRepoPath = await resolveRepositoryPath(repoPath); + if (!resolvedRepoPath) return null; + return await runGitRead(resolvedRepoPath, `reference-worktree-diff:${reference}`, () => + tauriInvoke("git_reference_worktree_diff", { + repoPath: resolvedRepoPath, + reference, + }), + ); + } catch (error) { + if (isNotGitRepositoryError(error)) return null; + console.error("Failed to compare reference with working tree:", error); + throw error; + } +}; + export const getStashDiff = async ( repoPath: string, stashIndex: number, diff --git a/windows/tauri/src/features/git/api/git-integration-api.test.ts b/windows/tauri/src/features/git/api/git-integration-api.test.ts index 2fc94359..c9b9ddbc 100644 --- a/windows/tauri/src/features/git/api/git-integration-api.test.ts +++ b/windows/tauri/src/features/git/api/git-integration-api.test.ts @@ -7,9 +7,14 @@ const emitGitChanged = spyOn(gitEvents, "emitGitChanged"); mock.module("@/platform/tauri-core", () => ({ invoke })); -const { getConflictMarkerPaths, getOperationState, mergeBranch, rebaseOntoBranch } = await import( - "./git-integration-api" -); +const { + checkoutAndRebase, + getConflictMarkerPaths, + getOperationState, + mergeBranch, + pullRemoteReference, + rebaseOntoBranch, +} = await import("./git-integration-api"); const operationState = ( kind: GitOperationState["kind"], @@ -28,6 +33,35 @@ beforeEach(() => { }); describe("Git integration state", () => { + test("preserves complete remote references for composite operations", async () => { + invoke.mockImplementation(async (command: string) => { + if (command === "git_discover_repo") return "C:/repo"; + return null; + }); + const reference = { + fullName: "refs/remotes/origin/feature/demo", + shortName: "origin/feature/demo", + kind: "remote" as const, + isCurrent: false, + }; + + await expect(checkoutAndRebase("C:/repo", reference)).resolves.toEqual({ status: "clean" }); + await expect(pullRemoteReference("C:/repo", reference, "merge")).resolves.toEqual({ + status: "clean", + }); + expect(invoke).toHaveBeenCalledWith("git_checkout_and_rebase", { + repoPath: "C:/repo", + reference: reference.fullName, + referenceKind: "remote", + }); + expect(invoke).toHaveBeenCalledWith("git_pull", { + repoPath: "C:/repo", + reference: reference.fullName, + referenceKind: "remote", + mode: "merge", + }); + }); + test("reports a stopped rebase even when no conflicted paths remain", async () => { invoke.mockImplementation(async (command: string) => { if (command === "git_discover_repo") return "C:/repo"; diff --git a/windows/tauri/src/features/git/api/git-integration-api.ts b/windows/tauri/src/features/git/api/git-integration-api.ts index 2fea725e..d692d570 100644 --- a/windows/tauri/src/features/git/api/git-integration-api.ts +++ b/windows/tauri/src/features/git/api/git-integration-api.ts @@ -1,7 +1,7 @@ import { invoke as tauriInvoke } from "@/platform/tauri-core"; import { emitGitChanged } from "../events/git-events"; import { resolveRepositoryPathOrThrow } from "./git-repo-api"; -import type { GitOperationState } from "../types/git.types"; +import type { GitOperationState, GitReference, PullStrategy } from "../types/git.types"; type IntegrationOperation = "merge" | "rebase"; @@ -54,12 +54,17 @@ export const getConflictMarkerPaths = async (repoPath: string): Promise => { const command = operation === "merge" ? "git_merge" : "git_rebase"; try { - await tauriInvoke(command, { repoPath, branchName }); + await tauriInvoke(command, { + repoPath, + ...(typeof reference === "string" + ? { branchName: reference } + : { reference: reference.fullName, referenceKind: reference.kind }), + }); notifyOperationChanged(repoPath, `${operation}-completed`); return { status: "clean" }; } catch (error) { @@ -83,7 +88,7 @@ const runIntegration = async ( const startIntegration = async ( repoPath: string, - branchName: string, + reference: string | GitReference, operation: IntegrationOperation, ): Promise => { const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); @@ -92,7 +97,13 @@ const startIntegration = async ( try { preflight = await tauriInvoke( "git_integration_preflight", - { repoPath: resolvedRepoPath, branchName, operation }, + { + repoPath: resolvedRepoPath, + operation, + ...(typeof reference === "string" + ? { branchName: reference } + : { reference: reference.fullName, referenceKind: reference.kind }), + }, ); } catch { // A failed preflight must not block the operation itself; Git will still @@ -107,14 +118,84 @@ const startIntegration = async ( }; } - return runIntegration(resolvedRepoPath, branchName, operation); + return runIntegration(resolvedRepoPath, reference, operation); }; -export const mergeBranch = (repoPath: string, branchName: string) => - startIntegration(repoPath, branchName, "merge"); +export const mergeBranch = (repoPath: string, reference: string | GitReference) => + startIntegration(repoPath, reference, "merge"); -export const rebaseOntoBranch = (repoPath: string, branchName: string) => - startIntegration(repoPath, branchName, "rebase"); +export const rebaseOntoBranch = (repoPath: string, reference: string | GitReference) => + startIntegration(repoPath, reference, "rebase"); + +export const checkoutAndRebase = async ( + repoPath: string, + reference: GitReference, +): Promise => { + const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); + try { + await tauriInvoke("git_checkout_and_rebase", { + repoPath: resolvedRepoPath, + reference: reference.fullName, + referenceKind: reference.kind, + }); + notifyOperationChanged(resolvedRepoPath, "checkout-and-rebase-completed"); + return { status: "clean" }; + } catch (error) { + notifyOperationChanged(resolvedRepoPath, "checkout-and-rebase-rejected"); + const state = await getOperationState(resolvedRepoPath).catch(() => null); + if (state?.kind === "rebase") { + return state.conflictedPaths.length + ? { status: "conflicts", conflictedPaths: state.conflictedPaths } + : { status: "stopped" }; + } + return { status: "error", message: errorMessage(error) }; + } +}; + +export const pullRemoteReference = async ( + repoPath: string, + reference: GitReference, + strategy: Extract, +): Promise => { + const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); + let preflight: IntegrationPreflightResult | null = null; + try { + preflight = await tauriInvoke("git_integration_preflight", { + repoPath: resolvedRepoPath, + operation: strategy, + reference: reference.fullName, + referenceKind: reference.kind, + }); + } catch { + // Git remains the final authority if a read-only preflight is unavailable. + } + if (preflight && preflight.blockingPaths.length > 0) { + return { + status: "blocked", + blockingPaths: preflight.blockingPaths, + blocksEntirely: preflight.blocksEntirely, + }; + } + try { + await tauriInvoke("git_pull", { + repoPath: resolvedRepoPath, + reference: reference.fullName, + referenceKind: reference.kind, + mode: strategy, + }); + notifyOperationChanged(resolvedRepoPath, `pull-${strategy}-completed`); + return { status: "clean" }; + } catch (error) { + notifyOperationChanged(resolvedRepoPath, `pull-${strategy}-rejected`); + const state = await getOperationState(resolvedRepoPath).catch(() => null); + if (state?.kind === strategy) { + return state.conflictedPaths.length + ? { status: "conflicts", conflictedPaths: state.conflictedPaths } + : { status: "stopped" }; + } + return { status: "error", message: errorMessage(error) }; + } +}; const resolveOperation = async ( repoPath: string, diff --git a/windows/tauri/src/features/git/components/git-view.tsx b/windows/tauri/src/features/git/components/git-view.tsx index e6e15aa3..61503fb7 100644 --- a/windows/tauri/src/features/git/components/git-view.tsx +++ b/windows/tauri/src/features/git/components/git-view.tsx @@ -7,7 +7,7 @@ import { DotsThreeIcon as MoreHorizontal, FolderSimpleStarIcon as FolderSimpleStar, GitBranchIcon as GitBranch, - ArrowClockwiseIcon as RefreshCw, + RefreshIcon as RefreshCw, TrashIcon as Trash2, UploadIcon as Upload, } from "@/ui/icons"; diff --git a/windows/tauri/src/features/git/components/log/git-log-tool-window.tsx b/windows/tauri/src/features/git/components/log/git-log-tool-window.tsx index 71867bdf..74943aa4 100644 --- a/windows/tauri/src/features/git/components/log/git-log-tool-window.tsx +++ b/windows/tauri/src/features/git/components/log/git-log-tool-window.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/ui/button"; +import { showConfirmDialog, showPromptDialog } from "@/ui/dialog"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/ui/resizable"; import { tryWriteClipboardText } from "@/utils/clipboard"; import { useTranslation } from "@/i18n/locale-provider"; @@ -8,9 +9,18 @@ import { useProjectStore } from "@/features/window/stores/project.store"; import { useUIState } from "@/features/window/stores/ui-state.store"; import { useGitLogController } from "../../hooks/use-git-log-controller"; import { useGitDiffActions } from "../../hooks/use-git-diff-actions"; +import { checkoutReference, createBranch } from "../../api/git-branches-api"; +import { createStash, getStashes, popStash } from "../../api/git-stash-api"; +import { + checkoutAndRebase, + mergeBranch, + pullRemoteReference, + rebaseOntoBranch, + type IntegrationOutcome, +} from "../../api/git-integration-api"; import { useGitLogPreferencesStore } from "../../stores/git-log-preferences.store"; import { useRepositoryStore } from "../../stores/git-repository.store"; -import type { GitCommit, GitFile } from "../../types/git.types"; +import type { GitCommit, GitFile, GitReference } from "../../types/git.types"; import type { WorkingTreeDiffEntry, WorkingTreeDiffScope, @@ -18,7 +28,7 @@ import type { import { GitCommitInspector } from "./git-commit-inspector"; import { GitCommitTable } from "./git-commit-table"; import { GitLogTitleBar } from "./git-log-title-bar"; -import { GitReferenceTree } from "./git-reference-tree"; +import { GitReferenceTree, type GitReferenceAction } from "./git-reference-tree"; export function GitLogToolWindow() { const { t } = useTranslation(); @@ -37,6 +47,7 @@ export function GitLogToolWindow() { loadMore, } = useGitLogController(repoPath); const [selectedCommit, setSelectedCommit] = useState(null); + const [isReferenceOperating, setIsReferenceOperating] = useState(false); const mainPanelLayout = useGitLogPreferencesStore.use.mainPanelLayout(); const { setMainPanelLayout } = useGitLogPreferencesStore.use.actions(); const currentReference = useMemo( @@ -56,7 +67,13 @@ export function GitLogToolWindow() { [], ); const emptyGitFileByPath = useMemo(() => new Map(), []); - const { isLoadingCommitDiff, isLoadingBranchDiff, viewCommitDiff, viewBranchDiff } = + const { + isLoadingCommitDiff, + isLoadingBranchDiff, + viewCommitDiff, + viewBranchDiff, + viewReferenceWorkingTreeDiff, + } = useGitDiffActions({ activeRepoPath: repoPath, gitFileByPath: emptyGitFileByPath, @@ -65,6 +82,102 @@ export function GitLogToolWindow() { currentBranch: currentReference?.shortName, }); + const reportIntegration = (outcome: IntegrationOutcome, success: string) => { + if (outcome.status === "clean") toast.success(success); + else if (outcome.status === "conflicts") { + toast.warning(t("git.log.operationConflicts", { count: outcome.conflictedPaths.length })); + } else if (outcome.status === "stopped") toast.warning(t("git.log.operationStopped")); + else if (outcome.status === "blocked") { + toast.error(t("git.log.operationBlocked", { paths: outcome.blockingPaths.join(", ") })); + } else toast.error(outcome.message); + }; + + const runReferenceAction = async ( + reference: GitReference, + action: GitReferenceAction, + ) => { + if (!repoPath || isReferenceOperating) return; + if (action === "compareWithCurrent") { + await viewBranchDiff(reference.fullName); + return; + } + if (action === "showWorkingTreeDiff") { + await viewReferenceWorkingTreeDiff(reference.fullName); + return; + } + if (action === "createBranch") { + const name = await showPromptDialog(t("git.log.branchNamePrompt"), { + title: t("git.log.createBranchFromTitle", { reference: reference.shortName }), + }); + if (!name?.trim()) return; + setIsReferenceOperating(true); + const created = await createBranch(repoPath, name.trim(), reference); + setIsReferenceOperating(false); + created ? toast.success(t("git.log.branchCreated", { name: name.trim() })) : toast.error(t("git.log.branchCreateFailed")); + if (created) await refresh(); + return; + } + + const confirmed = await showConfirmDialog( + t("git.log.confirmAction", { + action: t(`git.log.action.${action}`), + reference: reference.shortName, + }), + { title: t(`git.log.action.${action}`) }, + ); + if (!confirmed) return; + setIsReferenceOperating(true); + try { + if (action === "checkout") { + const result = await checkoutReference(repoPath, reference); + result.success ? toast.success(result.message) : toast.error(result.message); + } else { + const outcome = + action === "checkoutAndRebase" + ? await checkoutAndRebase(repoPath, reference) + : action === "rebaseCurrent" + ? await rebaseOntoBranch(repoPath, reference) + : action === "mergeCurrent" + ? await mergeBranch(repoPath, reference) + : await pullRemoteReference( + repoPath, + reference, + action === "pullRebase" ? "rebase" : "merge", + ); + if (outcome.status === "blocked" && (action === "pullRebase" || action === "pullMerge")) { + const save = await showConfirmDialog( + t("git.log.operationBlocked", { paths: outcome.blockingPaths.join(", ") }), + { title: t("git.stashChanges") }, + ); + if (save) { + const before = await getStashes(repoPath); + if (!await createStash(repoPath, "Lithe auto-stash before pull", true)) { + toast.error(t("git.stashFailed")); + } else { + const retry = await pullRemoteReference(repoPath, reference, action === "pullRebase" ? "rebase" : "merge"); + reportIntegration( + retry, + t("git.log.actionSucceeded", { + action: t(`git.log.action.${action}`), + reference: reference.shortName, + }), + ); + if (retry.status === "clean") { + const after = await getStashes(repoPath); + if (after.length > before.length) await popStash(repoPath, after[0].index); + } + } + } + } else { + reportIntegration(outcome, t("git.log.actionSucceeded", { action: t(`git.log.action.${action}`), reference: reference.shortName })); + } + } + await refresh(); + } finally { + setIsReferenceOperating(false); + } + }; + useEffect(() => { setSelectedCommit((current) => { if (current && commitByHash.has(current.hash)) return commitByHash.get(current.hash) ?? null; @@ -162,6 +275,8 @@ export function GitLogToolWindow() { setSelectedCommit(null); selectReference(reference); }} + onAction={(reference, action) => void runReferenceAction(reference, action)} + isOperating={isReferenceOperating} /> diff --git a/windows/tauri/src/features/git/components/log/git-reference-tree.test.ts b/windows/tauri/src/features/git/components/log/git-reference-tree.test.ts new file mode 100644 index 00000000..f1b92a59 --- /dev/null +++ b/windows/tauri/src/features/git/components/log/git-reference-tree.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test"; +import type { GitReference } from "../../types/git.types"; +import { getGitReferenceActions } from "./git-reference-tree"; + +const reference = ( + kind: GitReference["kind"], + isCurrent = false, +): GitReference => ({ + fullName: + kind === "local" + ? "refs/heads/main" + : kind === "remote" + ? "refs/remotes/origin/feature" + : "refs/tags/v1.0.0", + shortName: kind === "remote" ? "origin/feature" : kind === "tag" ? "v1.0.0" : "main", + kind, + isCurrent, +}); + +describe("Git reference context actions", () => { + test("offers remote integration and pull actions", () => { + expect(getGitReferenceActions(reference("remote"))).toEqual([ + "checkout", + "createBranch", + "showWorkingTreeDiff", + "compareWithCurrent", + "checkoutAndRebase", + "rebaseCurrent", + "mergeCurrent", + "pullRebase", + "pullMerge", + ]); + }); + + test("keeps tags to applicable non-integration actions", () => { + expect(getGitReferenceActions(reference("tag"))).toEqual([ + "checkout", + "createBranch", + "showWorkingTreeDiff", + "compareWithCurrent", + ]); + }); + + test("does not offer self operations for the current branch", () => { + expect(getGitReferenceActions(reference("local", true))).toEqual([ + "createBranch", + "showWorkingTreeDiff", + ]); + }); +}); diff --git a/windows/tauri/src/features/git/components/log/git-reference-tree.tsx b/windows/tauri/src/features/git/components/log/git-reference-tree.tsx index fc76b9f5..1813dd6a 100644 --- a/windows/tauri/src/features/git/components/log/git-reference-tree.tsx +++ b/windows/tauri/src/features/git/components/log/git-reference-tree.tsx @@ -9,6 +9,13 @@ import { import { useMemo } from "react"; import { cn } from "@/utils/cn"; import { useTranslation } from "@/i18n/locale-provider"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@/ui/context-menu"; import { useGitLogPreferencesStore } from "../../stores/git-log-preferences.store"; import type { GitReference, GitReferenceKind } from "../../types/git.types"; import { buildGitReferenceTree, type GitReferenceTreeNode } from "../../utils/git-reference-tree"; @@ -19,6 +26,28 @@ const SECTION_KEYS: Array<{ kind: GitReferenceKind; titleKey: string }> = [ { kind: "tag", titleKey: "git.log.tags" }, ]; +export type GitReferenceAction = + | "checkout" + | "createBranch" + | "checkoutAndRebase" + | "compareWithCurrent" + | "showWorkingTreeDiff" + | "rebaseCurrent" + | "mergeCurrent" + | "pullRebase" + | "pullMerge"; + +export function getGitReferenceActions(reference: GitReference): GitReferenceAction[] { + const actions: GitReferenceAction[] = ["createBranch", "showWorkingTreeDiff"]; + if (!reference.isCurrent) actions.unshift("checkout"); + if (!reference.isCurrent) actions.push("compareWithCurrent"); + if (reference.kind !== "tag" && !reference.isCurrent) { + actions.push("checkoutAndRebase", "rebaseCurrent", "mergeCurrent"); + } + if (reference.kind === "remote") actions.push("pullRebase", "pullMerge"); + return actions; +} + function ReferenceIcon({ kind }: { kind: GitReferenceKind }) { if (kind === "tag") return ; if (kind === "remote") return ; @@ -33,6 +62,8 @@ function ReferenceNode({ collapsedGroups, onToggleGroup, onSelect, + onAction, + isOperating, }: { node: GitReferenceTreeNode; kind: GitReferenceKind; @@ -41,15 +72,16 @@ function ReferenceNode({ collapsedGroups: Set; onToggleGroup: (id: string) => void; onSelect: (reference: GitReference) => void; + onAction: (reference: GitReference, action: GitReferenceAction) => void; + isOperating: boolean; }) { const { t } = useTranslation(); const isGroup = node.children.length > 0; const isCollapsed = collapsedGroups.has(node.id); const left = 10 + depth * 14; - return ( - <> -
{node.name} -
+ + ); + const reference = node.reference; + const actions = reference ? new Set(getGitReferenceActions(reference)) : null; + + return ( + <> + {reference ? ( + + onSelect(reference)}> + {row} + + + {actions?.has("checkout") ? ( + onAction(reference, "checkout")}> + {t("git.log.action.checkout")} + + ) : null} + onAction(reference, "createBranch")}> + {t("git.log.action.createBranch")} + + {actions?.has("checkoutAndRebase") ? ( + onAction(reference, "checkoutAndRebase")}> + {t("git.log.action.checkoutAndRebase")} + + ) : null} + + {actions?.has("compareWithCurrent") ? ( + onAction(reference, "compareWithCurrent")}> + {t("git.log.action.compareWithCurrent")} + + ) : null} + onAction(reference, "showWorkingTreeDiff")}> + {t("git.log.action.showWorkingTreeDiff")} + + {actions?.has("rebaseCurrent") ? ( + <> + + onAction(reference, "rebaseCurrent")}> + {t("git.log.action.rebaseCurrent")} + + onAction(reference, "mergeCurrent")}> + {t("git.log.action.mergeCurrent")} + + + ) : null} + {reference.kind === "remote" ? ( + <> + + onAction(reference, "pullRebase")}> + {t("git.log.action.pullRebase")} + + onAction(reference, "pullMerge")}> + {t("git.log.action.pullMerge")} + + + ) : null} + + + ) : row} {!isCollapsed && node.children.map((child) => ( ))} @@ -106,10 +199,14 @@ export function GitReferenceTree({ references, selectedReference, onSelect, + onAction, + isOperating = false, }: { references: GitReference[]; selectedReference: GitReference | null; onSelect: (reference: GitReference | null) => void; + onAction: (reference: GitReference, action: GitReferenceAction) => void; + isOperating?: boolean; }) { const { t } = useTranslation(); const collapsedSectionIds = useGitLogPreferencesStore.use.collapsedReferenceSections(); @@ -179,6 +276,8 @@ export function GitReferenceTree({ collapsedGroups={collapsedGroups} onToggleGroup={toggleReferenceGroup} onSelect={onSelect} + onAction={onAction} + isOperating={isOperating} /> )) ) : ( diff --git a/windows/tauri/src/features/git/hooks/use-git-diff-actions.ts b/windows/tauri/src/features/git/hooks/use-git-diff-actions.ts index 00e03139..79a384f9 100644 --- a/windows/tauri/src/features/git/hooks/use-git-diff-actions.ts +++ b/windows/tauri/src/features/git/hooks/use-git-diff-actions.ts @@ -3,7 +3,13 @@ import { activateMainEditorPane } from "@/features/editor/stores/buffer-pane-syn import { useBufferStore } from "@/features/editor/stores/buffer.store"; import { useTranslation } from "@/i18n/locale-provider"; import { showAlertDialog } from "@/ui/dialog"; -import { getCommitDiff, getFileDiff, getRefDiff, getStashDiff } from "../api/git-diff-api"; +import { + getCommitDiff, + getFileDiff, + getReferenceWorkingTreeDiff, + getRefDiff, + getStashDiff, +} from "../api/git-diff-api"; import { loadWorkingTreeDiffsProgressively, type WorkingTreeDiffEntry, @@ -375,6 +381,42 @@ export function useGitDiffActions({ [activeRepoPath, currentBranch, onBranchDiffOpened], ); + const viewReferenceWorkingTreeDiff = useCallback( + async (reference: string) => { + if (!activeRepoPath) return; + const title = `${reference}..WORKTREE`; + setIsLoadingBranchDiff(true); + try { + const diffs = await getReferenceWorkingTreeDiff(activeRepoPath, reference); + if (!diffs?.length) { + await showAlertDialog( + t("git.diff.noChangesBetween", { base: reference, target: "WORKTREE" }), + t("git.diff.title"), + ); + return; + } + openDiffBuffer( + `diff://reference/${encodeURIComponent(reference)}/working-tree`, + `${title} (${diffs.length} files)`, + createMultiFileDiff({ + title, + repoPath: activeRepoPath, + commitHash: title, + diffs, + }), + ); + } catch (error) { + await showAlertDialog( + t("git.diff.getWorkingTreeDiffFailed", { error: String(error) }), + t("git.diff.title"), + ); + } finally { + setIsLoadingBranchDiff(false); + } + }, + [activeRepoPath, t], + ); + return { isLoadingCommitDiff, isLoadingBranchDiff, @@ -385,5 +427,6 @@ export function useGitDiffActions({ viewStashDiff, viewTagComparison, viewBranchDiff, + viewReferenceWorkingTreeDiff, }; } diff --git a/windows/tauri/src/features/git/hooks/use-git-log-controller.ts b/windows/tauri/src/features/git/hooks/use-git-log-controller.ts index f5d0b9bd..8d03eb76 100644 --- a/windows/tauri/src/features/git/hooks/use-git-log-controller.ts +++ b/windows/tauri/src/features/git/hooks/use-git-log-controller.ts @@ -9,7 +9,12 @@ type GitLogLoadState = "idle" | "loading" | "ready" | "failed"; const COMMITS_PER_PAGE = 50; const MAX_COMMITS = 5_000; -const EMPTY_HISTORY: GitHistorySnapshot = { references: [], commits: [], hasMore: false }; +const EMPTY_HISTORY: GitHistorySnapshot = { + references: [], + recentReferences: [], + commits: [], + hasMore: false, +}; export function useGitLogController(repoPath: string | null) { const { t } = useTranslation(); diff --git a/windows/tauri/src/features/git/stores/git.store.test.ts b/windows/tauri/src/features/git/stores/git.store.test.ts index 03e76195..3f9875d6 100644 --- a/windows/tauri/src/features/git/stores/git.store.test.ts +++ b/windows/tauri/src/features/git/stores/git.store.test.ts @@ -47,7 +47,12 @@ describe("Git history pagination", () => { test("requests a larger cumulative snapshot instead of an ignored offset", async () => { const store = createGitStore(); loadInitialHistory(store, "C:/repo", commits(50)); - getGitHistory.mockResolvedValue({ references: [], commits: commits(100), hasMore: true }); + getGitHistory.mockResolvedValue({ + references: [], + recentReferences: [], + commits: commits(100), + hasMore: true, + }); await store.getState().actions.loadMoreCommits("C:/repo"); @@ -59,7 +64,12 @@ describe("Git history pagination", () => { test("uses the shared core hasMore flag at the end of history", async () => { const store = createGitStore(); loadInitialHistory(store, "C:/repo", commits(50)); - getGitHistory.mockResolvedValue({ references: [], commits: commits(73), hasMore: false }); + getGitHistory.mockResolvedValue({ + references: [], + recentReferences: [], + commits: commits(73), + hasMore: false, + }); await store.getState().actions.loadMoreCommits("C:/repo"); @@ -93,7 +103,12 @@ describe("Git history pagination", () => { const pending = store.getState().actions.loadMoreCommits("C:/repo-a"); store.getState().actions.prepareRepositoryLoad("C:/repo-b"); - resolveHistory({ references: [], commits: commits(100), hasMore: true }); + resolveHistory({ + references: [], + recentReferences: [], + commits: commits(100), + hasMore: true, + }); await pending; expect(store.getState().currentRepoPath).toBe("C:/repo-b"); diff --git a/windows/tauri/src/features/git/types/git.types.ts b/windows/tauri/src/features/git/types/git.types.ts index bb5b539a..a214e36d 100644 --- a/windows/tauri/src/features/git/types/git.types.ts +++ b/windows/tauri/src/features/git/types/git.types.ts @@ -35,6 +35,7 @@ export interface GitReference { export interface GitHistorySnapshot { references: GitReference[]; + recentReferences: GitReference[]; commits: GitCommit[]; hasMore: boolean; } diff --git a/windows/tauri/src/i18n/locale.ts b/windows/tauri/src/i18n/locale.ts index cfceb128..bf878864 100644 --- a/windows/tauri/src/i18n/locale.ts +++ b/windows/tauri/src/i18n/locale.ts @@ -2418,6 +2418,24 @@ const catalogs = { "git.log.none": "None", "git.log.expand": "Expand {name}", "git.log.collapse": "Collapse {name}", + "git.log.action.checkout": "Checkout", + "git.log.action.createBranch": "New Branch from Reference…", + "git.log.action.checkoutAndRebase": "Checkout and Rebase onto Current Branch", + "git.log.action.compareWithCurrent": "Compare with Current Branch", + "git.log.action.showWorkingTreeDiff": "Show Diff with Working Tree", + "git.log.action.rebaseCurrent": "Rebase Current Branch onto Reference", + "git.log.action.mergeCurrent": "Merge Reference into Current Branch", + "git.log.action.pullRebase": "Pull Remote Branch with Rebase", + "git.log.action.pullMerge": "Pull Remote Branch with Merge", + "git.log.branchNamePrompt": "Enter the new branch name.", + "git.log.createBranchFromTitle": "New Branch from {reference}", + "git.log.branchCreated": "Created branch {name}", + "git.log.branchCreateFailed": "Failed to create branch", + "git.log.confirmAction": "{action} for {reference}?", + "git.log.actionSucceeded": "{action} completed for {reference}", + "git.log.operationConflicts": "Git stopped with {count} conflicted file(s).", + "git.log.operationStopped": "Git stopped before completing. Use the conflict controls to continue or abort.", + "git.log.operationBlocked": "Local changes block this operation: {paths}", "git.log.filter": "Filter Git log", "git.log.clearFilter": "Clear Git log filter", "git.log.filterField": "Git log filter field", @@ -2536,6 +2554,9 @@ const catalogs = { "files.copy": "Copy", "files.cut": "Cut", "files.paste": "Paste", + "files.nothingToPaste": "Nothing to paste", + "files.javaClassAlreadyExists": "{name} already exists", + "files.javaPasteRemoteUnsupported": "Pasting a Java class onto a remote folder is not supported yet", "files.rename": "Rename", "files.reveal": "Reveal in File Explorer", "files.delete": "Delete", @@ -6105,6 +6126,24 @@ const catalogs = { "git.log.none": "无", "git.log.expand": "展开 {name}", "git.log.collapse": "折叠 {name}", + "git.log.action.checkout": "检出", + "git.log.action.createBranch": "从引用新建分支…", + "git.log.action.checkoutAndRebase": "检出并变基到当前分支", + "git.log.action.compareWithCurrent": "与当前分支比较", + "git.log.action.showWorkingTreeDiff": "显示与工作树的差异", + "git.log.action.rebaseCurrent": "将当前分支变基到此引用", + "git.log.action.mergeCurrent": "将此引用合并到当前分支", + "git.log.action.pullRebase": "使用变基拉入远程分支", + "git.log.action.pullMerge": "使用合并拉入远程分支", + "git.log.branchNamePrompt": "输入新分支名称。", + "git.log.createBranchFromTitle": "从 {reference} 新建分支", + "git.log.branchCreated": "已创建分支 {name}", + "git.log.branchCreateFailed": "创建分支失败", + "git.log.confirmAction": "确定要对 {reference} 执行“{action}”吗?", + "git.log.actionSucceeded": "已对 {reference} 完成“{action}”", + "git.log.operationConflicts": "Git 已停止,有 {count} 个冲突文件。", + "git.log.operationStopped": "Git 在完成前停止,请使用冲突操作继续或中止。", + "git.log.operationBlocked": "本地更改阻止了此操作:{paths}", "git.log.filter": "筛选 Git 日志", "git.log.clearFilter": "清除 Git 日志筛选", "git.log.filterField": "Git 日志筛选字段", @@ -6223,6 +6262,9 @@ const catalogs = { "files.copy": "复制", "files.cut": "剪切", "files.paste": "粘贴", + "files.nothingToPaste": "剪贴板中没有可粘贴的内容", + "files.javaClassAlreadyExists": "{name} 已存在", + "files.javaPasteRemoteUnsupported": "暂不支持将 Java 类粘贴到远程文件夹", "files.rename": "重命名", "files.reveal": "在资源管理器中显示", "files.delete": "删除", diff --git a/windows/tauri/src/platform/core-result-adapter.history.test.ts b/windows/tauri/src/platform/core-result-adapter.history.test.ts index 7c6b46e3..1efc21bf 100644 --- a/windows/tauri/src/platform/core-result-adapter.history.test.ts +++ b/windows/tauri/src/platform/core-result-adapter.history.test.ts @@ -17,6 +17,15 @@ describe("git history result adaptation", () => { upstreamShortName: "origin/main", }, ], + recentReferences: [ + { + fullName: "refs/heads/main", + shortName: "main", + kind: "local", + isCurrent: true, + upstreamShortName: "origin/main", + }, + ], commits: [ { hash: "abc123", @@ -43,6 +52,15 @@ describe("git history result adaptation", () => { upstreamShortName: "origin/main", }, ], + recentReferences: [ + { + fullName: "refs/heads/main", + shortName: "main", + kind: "local", + isCurrent: true, + upstreamShortName: "origin/main", + }, + ], commits: [ { hash: "abc123", @@ -62,6 +80,7 @@ describe("git history result adaptation", () => { test("defaults missing history fields to an exhausted empty snapshot", () => { expect(adaptCoreResult("git_log", undefined, {})).toEqual({ references: [], + recentReferences: [], commits: [], hasMore: false, }); diff --git a/windows/tauri/src/platform/core-result-adapter.ts b/windows/tauri/src/platform/core-result-adapter.ts index 41347506..da826a30 100644 --- a/windows/tauri/src/platform/core-result-adapter.ts +++ b/windows/tauri/src/platform/core-result-adapter.ts @@ -139,6 +139,15 @@ export function adaptCoreResult( upstreamShortName: reference.upstreamShortName ?? undefined, })) : [], + recentReferences: Array.isArray(data.recentReferences) + ? data.recentReferences.map((reference: JsonRecord) => ({ + fullName: reference.fullName, + shortName: reference.shortName, + kind: reference.kind, + isCurrent: Boolean(reference.isCurrent), + upstreamShortName: reference.upstreamShortName ?? undefined, + })) + : [], commits: Array.isArray(data.commits) ? data.commits.map((commit: JsonRecord) => ({ hash: commit.hash, From c41a6b3f7226c6bbd0aaa8b665997f7906c8dc78 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Mon, 31 Aug 2026 14:34:01 +0800 Subject: [PATCH 61/66] fix(macos): isolate IDEA icon resolution on main actor --- macos/Sources/Lithe/Theme/LitheIcons.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/macos/Sources/Lithe/Theme/LitheIcons.swift b/macos/Sources/Lithe/Theme/LitheIcons.swift index ecddf0b3..586fbb64 100644 --- a/macos/Sources/Lithe/Theme/LitheIcons.swift +++ b/macos/Sources/Lithe/Theme/LitheIcons.swift @@ -547,6 +547,7 @@ struct LitheIcon: View { /// A small SwiftUI bridge for the imported IntelliJ SVG catalog. /// `fallbackSystemImage` keeps the UI usable in an unbundled debug preview. +@MainActor struct LitheIDEAIcon: View { @Environment(\.colorScheme) private var colorScheme let resourcePath: String @@ -578,6 +579,7 @@ struct LitheIDEAIcon: View { } } + @MainActor private var resolvedImage: NSImage? { if colorScheme == .dark, let darkImage = LitheIcons.ideaImage( From b839df6f6adde2cfcf19ffac49a7263e5c9660b6 Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Sat, 29 Aug 2026 17:13:50 -0700 Subject: [PATCH 62/66] feat(windows): add unified Maven tool window --- windows/tauri/src-tauri/Cargo.lock | 1 + windows/tauri/src-tauri/Cargo.toml | 1 + windows/tauri/src-tauri/src/main.rs | 3 + windows/tauri/src-tauri/src/maven.rs | 211 +++++ windows/tauri/src-tauri/src/run.rs | 2 +- .../src/features/editor/lsp/lsp-client.ts | 2 + .../lsp/resolve-editor-lsp-launch.test.ts | 41 + .../editor/lsp/resolve-editor-lsp-launch.ts | 15 +- .../file-system/stores/file-system.store.ts | 12 +- .../keymaps/commands/view-command-actions.ts | 10 + .../components/bottom-pane/bottom-pane.tsx | 21 + .../components/sidebar/main-sidebar.tsx | 10 + .../sidebar/sidebar-pane-selector.tsx | 23 + .../features/layout/config/item-order.test.ts | 3 +- .../src/features/layout/config/item-order.ts | 2 + .../features/maven/api/maven-core-api.test.ts | 57 ++ .../src/features/maven/api/maven-core-api.ts | 61 ++ .../src/features/maven/api/maven-host-api.ts | 42 + .../features/maven/components/maven-pane.tsx | 730 ++++++++++++++++++ .../maven/hooks/use-maven-process-events.ts | 36 + .../features/maven/stores/maven.store.test.ts | 282 +++++++ .../src/features/maven/stores/maven.store.ts | 600 ++++++++++++++ .../src/features/maven/types/maven.types.ts | 92 +++ .../src/features/run/api/run-core-api.test.ts | 28 +- .../src/features/run/api/run-core-api.ts | 9 +- .../src/features/run/stores/run.store.ts | 28 +- .../stores/ui-state/types/ui-state.types.ts | 1 + windows/tauri/src/i18n/locale.ts | 56 ++ .../tauri/src/platform/lsp-core-adapter.ts | 1 + windows/tauri/src/platform/tauri-core.ts | 2 + 30 files changed, 2360 insertions(+), 22 deletions(-) create mode 100644 windows/tauri/src-tauri/src/maven.rs create mode 100644 windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.test.ts create mode 100644 windows/tauri/src/features/maven/api/maven-core-api.test.ts create mode 100644 windows/tauri/src/features/maven/api/maven-core-api.ts create mode 100644 windows/tauri/src/features/maven/api/maven-host-api.ts create mode 100644 windows/tauri/src/features/maven/components/maven-pane.tsx create mode 100644 windows/tauri/src/features/maven/hooks/use-maven-process-events.ts create mode 100644 windows/tauri/src/features/maven/stores/maven.store.test.ts create mode 100644 windows/tauri/src/features/maven/stores/maven.store.ts create mode 100644 windows/tauri/src/features/maven/types/maven.types.ts diff --git a/windows/tauri/src-tauri/Cargo.lock b/windows/tauri/src-tauri/Cargo.lock index 625757a5..55481255 100644 --- a/windows/tauri/src-tauri/Cargo.lock +++ b/windows/tauri/src-tauri/Cargo.lock @@ -2620,6 +2620,7 @@ dependencies = [ "regex", "serde", "serde_json", + "sha2", "tauri", "tauri-build", "tauri-plugin-clipboard-manager", diff --git a/windows/tauri/src-tauri/Cargo.toml b/windows/tauri/src-tauri/Cargo.toml index c0b46ba4..9ebac1fb 100644 --- a/windows/tauri/src-tauri/Cargo.toml +++ b/windows/tauri/src-tauri/Cargo.toml @@ -17,6 +17,7 @@ keyring = { version = "3.6.3", features = ["windows-native"] } serde = { version = "1", features = ["derive"] } serde_json = "1" regex = "1" +sha2 = "0.10" tauri = { version = "2", features = ["common-controls-v6", "protocol-asset"] } tauri-plugin-clipboard-manager = "2" tauri-plugin-deep-link = "2" diff --git a/windows/tauri/src-tauri/src/main.rs b/windows/tauri/src-tauri/src/main.rs index a75d921c..41a43c60 100644 --- a/windows/tauri/src-tauri/src/main.rs +++ b/windows/tauri/src-tauri/src/main.rs @@ -5,6 +5,7 @@ mod file_events; mod host; mod logging; mod lsp; +mod maven; mod memory; mod platform; mod run; @@ -118,6 +119,8 @@ fn main() { host::create_app_window, lsp::lsp_resolve_java_launch, lsp::lsp_rebuild_java_index, + maven::maven_load_configuration, + maven::maven_write_configuration, run::run_list_java_sources, run::run_write_generated, run::run_write_documents, diff --git a/windows/tauri/src-tauri/src/maven.rs b/windows/tauri/src-tauri/src/maven.rs new file mode 100644 index 00000000..1ec63006 --- /dev/null +++ b/windows/tauri/src-tauri/src/maven.rs @@ -0,0 +1,211 @@ +//! Windows persistence for Maven project and machine-local configuration. +//! +//! Portable selections stay below the workspace `.lithe` directory. Maven, +//! JDK, and settings paths are stored only in the application data directory. + +use crate::run::atomic_write; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::fs; +use std::path::{Path, PathBuf}; +use tauri::{AppHandle, Manager}; + +const MAVEN_CONFIGURATION_VERSION: u32 = 1; + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MavenPortableConfiguration { + pub version: u32, + #[serde(default)] + pub selected_profiles: Vec, + #[serde(default)] + pub custom_profiles: Vec, + #[serde(default)] + pub skip_tests: bool, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MavenLocalConfiguration { + pub version: u32, + #[serde(default)] + pub settings_path: Option, + #[serde(default)] + pub maven_executable_path: Option, + #[serde(default)] + pub java_home_path: Option, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MavenStoredConfiguration { + pub portable: Option, + pub local: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WriteMavenConfigurationArgs { + pub root: PathBuf, + pub reactor_path: String, + pub configuration: MavenStoredConfiguration, +} + +#[tauri::command] +pub fn maven_load_configuration( + app: AppHandle, + root: PathBuf, + reactor_path: String, +) -> Result { + let root = existing_directory(&root)?; + let portable = read_optional::(&portable_path(&root))?; + let local = read_optional::(&local_path(&app, &root, &reactor_path)?)?; + validate_versions(portable.as_ref(), local.as_ref())?; + Ok(MavenStoredConfiguration { portable, local }) +} + +#[tauri::command] +pub fn maven_write_configuration( + app: AppHandle, + args: WriteMavenConfigurationArgs, +) -> Result<(), String> { + let root = existing_directory(&args.root)?; + validate_versions( + args.configuration.portable.as_ref(), + args.configuration.local.as_ref(), + )?; + write_optional(&portable_path(&root), args.configuration.portable.as_ref())?; + write_optional( + &local_path(&app, &root, &args.reactor_path)?, + args.configuration.local.as_ref(), + ) +} + +fn validate_versions( + portable: Option<&MavenPortableConfiguration>, + local: Option<&MavenLocalConfiguration>, +) -> Result<(), String> { + if portable.is_some_and(|value| value.version != MAVEN_CONFIGURATION_VERSION) + || local.is_some_and(|value| value.version != MAVEN_CONFIGURATION_VERSION) + { + return Err( + "The Maven configuration was created by an unsupported version of Lithe.".into(), + ); + } + Ok(()) +} + +fn portable_path(root: &Path) -> PathBuf { + root.join(".lithe").join("maven").join("config.json") +} + +fn local_path(app: &AppHandle, root: &Path, reactor_path: &str) -> Result { + let app_data = app + .path() + .app_data_dir() + .map_err(|error| error.to_string())?; + let mut digest = Sha256::new(); + digest.update(root.to_string_lossy().to_lowercase().as_bytes()); + digest.update([0]); + digest.update(reactor_path.replace('\\', "/").as_bytes()); + Ok(app_data + .join("maven") + .join(format!("{:x}.json", digest.finalize()))) +} + +fn existing_directory(path: &Path) -> Result { + let root = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + if !root.is_dir() { + return Err("The project directory is unavailable.".into()); + } + Ok(root) +} + +fn read_optional(path: &Path) -> Result, String> { + if !path.is_file() { + return Ok(None); + } + let contents = fs::read(path).map_err(|_| { + format!( + "Unable to read Maven configuration {}.", + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("file") + ) + })?; + serde_json::from_slice(&contents).map(Some).map_err(|_| { + format!( + "The Maven configuration in {} is invalid.", + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("file") + ) + }) +} + +fn write_optional(path: &Path, value: Option<&T>) -> Result<(), String> { + let Some(value) = value else { + if path.is_file() { + fs::remove_file(path).map_err(|error| error.to_string())?; + } + return Ok(()); + }; + let parent = path + .parent() + .ok_or_else(|| "Maven configuration path has no parent directory.".to_string())?; + fs::create_dir_all(parent).map_err(|error| error.to_string())?; + let mut contents = serde_json::to_string_pretty(value).map_err(|error| error.to_string())?; + contents.push('\n'); + atomic_write(path, contents.as_bytes()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + fn temp_directory() -> PathBuf { + static NEXT_DIRECTORY_ID: AtomicU64 = AtomicU64::new(1); + let id = NEXT_DIRECTORY_ID.fetch_add(1, Ordering::Relaxed); + let path = + std::env::temp_dir().join(format!("lithe-maven-config-{}-{id}", std::process::id())); + fs::create_dir_all(&path).expect("temp directory"); + path + } + + #[test] + fn portable_configuration_round_trips_without_local_paths() { + let root = temp_directory(); + let path = portable_path(&root); + let portable = MavenPortableConfiguration { + version: 1, + selected_profiles: vec!["dev".into(), "qa".into()], + custom_profiles: vec!["qa".into()], + skip_tests: true, + }; + write_optional(&path, Some(&portable)).expect("write portable configuration"); + let loaded = read_optional::(&path) + .expect("read portable configuration") + .expect("portable configuration"); + + assert_eq!(loaded.selected_profiles, ["dev", "qa"]); + assert!(loaded.skip_tests); + let text = fs::read_to_string(&path).expect("portable text"); + assert!(!text.contains("settingsPath")); + assert!(!text.contains("mavenExecutablePath")); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn rejects_unsupported_configuration_versions() { + let portable = MavenPortableConfiguration { + version: 2, + selected_profiles: Vec::new(), + custom_profiles: Vec::new(), + skip_tests: false, + }; + assert!(validate_versions(Some(&portable), None) + .unwrap_err() + .contains("unsupported version")); + } +} diff --git a/windows/tauri/src-tauri/src/run.rs b/windows/tauri/src-tauri/src/run.rs index 323e0b20..98b05329 100644 --- a/windows/tauri/src-tauri/src/run.rs +++ b/windows/tauri/src-tauri/src/run.rs @@ -440,7 +440,7 @@ fn validate_write_target(root: &Path, target: &Path) -> Result<(), String> { Ok(()) } -fn atomic_write(path: &Path, contents: &[u8]) -> Result<(), String> { +pub(crate) fn atomic_write(path: &Path, contents: &[u8]) -> Result<(), String> { if path.exists() { if let Ok(existing) = fs::read(path) { if existing == contents { diff --git a/windows/tauri/src/features/editor/lsp/lsp-client.ts b/windows/tauri/src/features/editor/lsp/lsp-client.ts index 89f07cbe..91d95cc6 100644 --- a/windows/tauri/src/features/editor/lsp/lsp-client.ts +++ b/windows/tauri/src/features/editor/lsp/lsp-client.ts @@ -605,6 +605,7 @@ export class LspClient { cacheDirectory: launch.cacheDirectory || null, environment: launch.environment || null, workspaceFingerprint: launch.workspaceFingerprint || null, + mavenContext: launch.mavenContext || null, }); if (representativeFilePath) { @@ -833,6 +834,7 @@ export class LspClient { cacheDirectory: launch.cacheDirectory || null, environment: launch.environment || null, workspaceFingerprint: launch.workspaceFingerprint || null, + mavenContext: launch.mavenContext || null, attachmentId, }); if (!isCurrentAttachment()) { diff --git a/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.test.ts b/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.test.ts new file mode 100644 index 00000000..df24672f --- /dev/null +++ b/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.test.ts @@ -0,0 +1,41 @@ +import { expect, mock, test } from "bun:test"; + +const resolveJavaLspLaunch = mock(async () => ({ + providerId: "java", + languageId: "java", + executablePath: "C:/Lithe/jdtls/bin/jdtls.bat", + arguments: [], + runtimeExecutablePath: "C:/Lithe/jdk/bin/java.exe", + cacheDirectory: "C:/Users/example/AppData/Local/Lithe/jdtls", + environment: { JAVA_HOME: "C:/Lithe/jdk" }, + workspaceFingerprint: "workspace-fingerprint", +})); +const mavenLaunchContextForWorkspace = mock(async () => ({ + version: 1 as const, + reactorPath: ".", + profiles: ["dev"], + settingsPath: "C:/Users/example/.m2/settings.xml", + skipTests: true, + mavenExecutablePath: "D:/Tools/apache-maven", + javaHomePath: "C:/Java/jdk-21", +})); + +mock.module("./java-lsp-host-api", () => ({ resolveJavaLspLaunch })); +mock.module("@/features/maven/stores/maven.store", () => ({ + mavenLaunchContextForWorkspace, +})); + +const { resolveEditorLspLaunch } = await import("./resolve-editor-lsp-launch"); + +test("forwards the current Maven context to the Java language server", async () => { + const launch = await resolveEditorLspLaunch("D:/work/src/App.java", "D:/work"); + + expect(mavenLaunchContextForWorkspace).toHaveBeenCalledWith("D:/work", ["src/App.java"]); + expect(launch?.mavenContext).toEqual( + expect.objectContaining({ + profiles: ["dev"], + settingsPath: "C:/Users/example/.m2/settings.xml", + skipTests: true, + }), + ); +}); diff --git a/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.ts b/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.ts index 99be53ae..0c150c23 100644 --- a/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.ts +++ b/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.ts @@ -1,9 +1,9 @@ import type { BackendLanguageToolConfigSet } from "@/extensions/registry/extension-store-runtime"; import { isJavaSourcePath, JAVA_LANGUAGE_ID, JAVA_PROVIDER_ID } from "./built-in-language-support"; -import { - resolveJavaLspLaunch, - type JdtlsLaunchResources, -} from "./java-lsp-host-api"; +import { resolveJavaLspLaunch, type JdtlsLaunchResources } from "./java-lsp-host-api"; +import type { MavenLaunchContext } from "@/features/maven/types/maven.types"; +import { mavenLaunchContextForWorkspace } from "@/features/maven/stores/maven.store"; +import { getRelativePath } from "@/utils/path-helpers"; export interface EditorLspLaunch { providerId: string; @@ -18,6 +18,7 @@ export interface EditorLspLaunch { environment?: Record; /** Workspace structure digest forwarded to the Rust core. */ workspaceFingerprint?: string | null; + mavenContext?: MavenLaunchContext | null; } export async function resolveEditorLspLaunch( @@ -25,7 +26,10 @@ export async function resolveEditorLspLaunch( workspacePath: string, ): Promise { if (isJavaSourcePath(filePath)) { - const launch = await resolveJavaLspLaunch(workspacePath); + const [launch, mavenContext] = await Promise.all([ + resolveJavaLspLaunch(workspacePath), + mavenLaunchContextForWorkspace(workspacePath, [getRelativePath(filePath, workspacePath)]), + ]); const environment: Record = {}; if (launch.environment.JAVA_HOME) { environment.JAVA_HOME = launch.environment.JAVA_HOME; @@ -40,6 +44,7 @@ export async function resolveEditorLspLaunch( cacheDirectory: launch.cacheDirectory, environment, workspaceFingerprint: launch.workspaceFingerprint, + mavenContext, }; } diff --git a/windows/tauri/src/features/file-system/stores/file-system.store.ts b/windows/tauri/src/features/file-system/stores/file-system.store.ts index 4d24dc74..f95213eb 100644 --- a/windows/tauri/src/features/file-system/stores/file-system.store.ts +++ b/windows/tauri/src/features/file-system/stores/file-system.store.ts @@ -524,10 +524,11 @@ const initializeLocalWorkspaceInBackground = ( } const [{ getRelativePath, pathStartsWithRoot }, { resolveJavaWorkspacePolicy }, - { getJavaWorkspaceLanguageServerOwner }] = await Promise.all([ + { getJavaWorkspaceLanguageServerOwner }, { loadMavenProjectForWorkspace }] = await Promise.all([ import("@/utils/path-helpers"), import("@/platform/java-workspace-policy"), import("@/features/editor/lsp/java-workspace-language-server"), + import("@/features/maven/stores/maven.store"), ]); const workspaceFiles = projectFiles.filter( (entry) => !entry.isDir && pathStartsWithRoot(entry.path, path), @@ -535,6 +536,15 @@ const initializeLocalWorkspaceInBackground = ( const relativeToAbsolute = new Map( workspaceFiles.map((entry) => [getRelativePath(entry.path, path), entry.path]), ); + await loadMavenProjectForWorkspace(path, [...relativeToAbsolute.keys()], workspaceId); + if ( + activationVersion !== workspaceServiceActivationVersion || + workspaceRuntimeRegistry.getActiveWorkspaceId() !== workspaceId || + get().rootFolderPath !== path + ) { + operation.cancelled("workspace-activation-superseded"); + return; + } const policy = await resolveJavaWorkspacePolicy([...relativeToAbsolute.keys()]); const javaFile = policy.representativeJavaPath ? relativeToAbsolute.get(policy.representativeJavaPath) diff --git a/windows/tauri/src/features/keymaps/commands/view-command-actions.ts b/windows/tauri/src/features/keymaps/commands/view-command-actions.ts index d3888564..828797e0 100644 --- a/windows/tauri/src/features/keymaps/commands/view-command-actions.ts +++ b/windows/tauri/src/features/keymaps/commands/view-command-actions.ts @@ -37,6 +37,16 @@ export function toggleRunPane(): void { } } +export function toggleMavenPane(): void { + const state = useUIState.getState(); + if (state.isBottomPaneVisible && state.bottomPaneActiveTab === "maven") { + state.setIsBottomPaneVisible(false); + } else { + state.setBottomPaneActiveTab("maven"); + state.setIsBottomPaneVisible(true); + } +} + export function toggleTerminalPane(): void { const state = useUIState.getState(); if (state.isBottomPaneVisible && state.bottomPaneActiveTab === "terminal") { diff --git a/windows/tauri/src/features/layout/components/bottom-pane/bottom-pane.tsx b/windows/tauri/src/features/layout/components/bottom-pane/bottom-pane.tsx index 1a3dde8c..765a3b7a 100644 --- a/windows/tauri/src/features/layout/components/bottom-pane/bottom-pane.tsx +++ b/windows/tauri/src/features/layout/components/bottom-pane/bottom-pane.tsx @@ -7,6 +7,8 @@ import RunPane from "@/features/run/components/run-pane"; import { useBufferStore } from "@/features/editor/stores/buffer.store"; import { useTranslation } from "@/i18n/locale-provider"; import { GitLogToolWindow } from "@/features/git/components/log/git-log-tool-window"; +import MavenPane from "@/features/maven/components/maven-pane"; +import { useMavenStore } from "@/features/maven/stores/maven.store"; import { BOTTOM_PANE_ID } from "@/features/panes/constants/pane"; import { usePaneStore } from "@/features/panes/stores/pane.store"; import { activateBufferInPaneAndSync } from "@/features/panes/utils/pane-activation"; @@ -28,6 +30,8 @@ const BottomPane = () => { const { t } = useTranslation(); const isBottomPaneVisible = useUIState((state) => state.isBottomPaneVisible); const bottomPaneActiveTab = useUIState((state) => state.bottomPaneActiveTab); + const mavenProjectStatus = useMavenStore((state) => state.projectStatus); + const mavenProject = useMavenStore((state) => state.project); const rootFolderPath = useProjectStore((state) => state.rootFolderPath); const terminalEnabled = useSettingsStore((state) => state.settings.coreFeatures.terminal); const debuggerEnabled = useSettingsStore((state) => state.settings.coreFeatures.debugger); @@ -64,6 +68,17 @@ const BottomPane = () => { } }, [bottomPaneActiveTab, isBottomPaneVisible]); + useEffect(() => { + if ( + isBottomPaneVisible && + bottomPaneActiveTab === "maven" && + mavenProjectStatus === "ready" && + !mavenProject + ) { + useUIState.getState().setIsBottomPaneVisible(false); + } + }, [bottomPaneActiveTab, isBottomPaneVisible, mavenProject, mavenProjectStatus]); + useEffect(() => { if ( isBottomPaneVisible && @@ -276,6 +291,12 @@ const BottomPane = () => { )} + {bottomPaneActiveTab === "maven" && ( +
+ +
+ )} + {bottomPaneActiveTab === "diagnostics" && (
state.openSettingsDialog); const isBottomPaneVisible = useUIState((state) => state.isBottomPaneVisible); const bottomPaneActiveTab = useUIState((state) => state.bottomPaneActiveTab); + const mavenProject = useMavenStore((state) => state.project); + const mavenProjectStatus = useMavenStore((state) => state.projectStatus); const configuredActivityRailWidth = useSettingsStore((state) => state.settings.activityRailWidth); const askWhereToOpenProjects = useSettingsStore((state) => state.settings.askWhereToOpenProjects); const openFoldersInNewWindow = useSettingsStore((state) => state.settings.openFoldersInNewWindow); @@ -666,6 +670,12 @@ export const SidebarActivityRail = memo(({ expanded = false }: SidebarActivityRa isDiagnosticsActive={isBottomPaneVisible && bottomPaneActiveTab === "diagnostics"} onRunClick={() => toggleRunPane()} isRunActive={isBottomPaneVisible && bottomPaneActiveTab === "run"} + onMavenClick={ + mavenProject || mavenProjectStatus === "failed" + ? () => toggleMavenPane() + : undefined + } + isMavenActive={isBottomPaneVisible && bottomPaneActiveTab === "maven"} compact={!expanded} showLabels={expanded} orientation="vertical" diff --git a/windows/tauri/src/features/layout/components/sidebar/sidebar-pane-selector.tsx b/windows/tauri/src/features/layout/components/sidebar/sidebar-pane-selector.tsx index 58188a02..19af6b24 100644 --- a/windows/tauri/src/features/layout/components/sidebar/sidebar-pane-selector.tsx +++ b/windows/tauri/src/features/layout/components/sidebar/sidebar-pane-selector.tsx @@ -19,6 +19,7 @@ import { GitGraphIcon, FilesIcon, MagnifyingGlassIcon, + PackageIcon, TerminalWindowIcon, WarningIcon, } from "@/ui/icons"; @@ -70,6 +71,8 @@ interface SidebarPaneSelectorProps { isDiagnosticsActive?: boolean; onRunClick?: () => void; isRunActive?: boolean; + onMavenClick?: () => void; + isMavenActive?: boolean; compact?: boolean; showLabels?: boolean; orientation?: "horizontal" | "vertical"; @@ -92,6 +95,8 @@ export const SidebarPaneSelector = ({ isDiagnosticsActive = false, onRunClick, isRunActive = false, + onMavenClick, + isMavenActive = false, compact = false, showLabels = false, orientation = "horizontal", @@ -233,6 +238,22 @@ export const SidebarPaneSelector = ({ } satisfies SidebarPaneItem, ] : []), + ...(onMavenClick + ? [ + { + id: "maven", + label: showLabels ? t("workbench.maven") : undefined, + icon: , + isActive: isMavenActive, + onClick: onMavenClick, + ariaLabel: t("workbench.maven"), + tooltip: { + content: t("workbench.maven"), + side: tooltipSide, + }, + } satisfies SidebarPaneItem, + ] + : []), ...(onSettingsClick ? [ { @@ -267,8 +288,10 @@ export const SidebarPaneSelector = ({ onDiagnosticsClick, isDiagnosticsActive, onRunClick, + onMavenClick, onSettingsClick, isRunActive, + isMavenActive, onViewChange, showLabels, t, diff --git a/windows/tauri/src/features/layout/config/item-order.test.ts b/windows/tauri/src/features/layout/config/item-order.test.ts index cee9ec6c..bb4b2674 100644 --- a/windows/tauri/src/features/layout/config/item-order.test.ts +++ b/windows/tauri/src/features/layout/config/item-order.test.ts @@ -27,8 +27,9 @@ describe("sidebar activity order", () => { expect([...SIDEBAR_ACTIVITY_ITEM_IDS]).not.toContain("database"); }); - test("places Run, Terminal, Diagnostics, Git Log, then Settings", () => { + test("places Maven, Run, Terminal, Diagnostics, Git Log, then Settings", () => { expect([...SIDEBAR_BOTTOM_ACTIVITY_ITEM_IDS]).toEqual([ + "maven", "run", "terminal", "diagnostics", diff --git a/windows/tauri/src/features/layout/config/item-order.ts b/windows/tauri/src/features/layout/config/item-order.ts index 98f1cf44..6afde5cc 100644 --- a/windows/tauri/src/features/layout/config/item-order.ts +++ b/windows/tauri/src/features/layout/config/item-order.ts @@ -2,6 +2,7 @@ export const SIDEBAR_ACTIVITY_ITEM_IDS = [ "files", "git", "search", + "maven", "run", "terminal", "diagnostics", @@ -9,6 +10,7 @@ export const SIDEBAR_ACTIVITY_ITEM_IDS = [ "settings", ] as const; export const SIDEBAR_BOTTOM_ACTIVITY_ITEM_IDS = [ + "maven", "run", "terminal", "diagnostics", diff --git a/windows/tauri/src/features/maven/api/maven-core-api.test.ts b/windows/tauri/src/features/maven/api/maven-core-api.test.ts new file mode 100644 index 00000000..8c6774c1 --- /dev/null +++ b/windows/tauri/src/features/maven/api/maven-core-api.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; + +const executeCore = mock(async () => ({ + id: "request", + ok: true as const, + data: null, +})); + +mock.module("@/core/lithe-core-client", () => ({ executeCore })); + +const { createMavenLaunchPlan, scanMavenProject } = await import("./maven-core-api"); + +beforeEach(() => { + executeCore.mockClear(); +}); + +describe("Maven Core API", () => { + test("scans with the visible workspace-relative paths", async () => { + await scanMavenProject("D:/work", ["reactor/pom.xml", "reactor/app/src/App.java"]); + + expect(executeCore).toHaveBeenCalledWith( + expect.objectContaining({ + command: "maven.scan", + payload: { + root: "D:/work", + paths: ["reactor/pom.xml", "reactor/app/src/App.java"], + }, + }), + ); + }); + + test("forwards the complete context without assembling Maven arguments", async () => { + const context = { + version: 1 as const, + reactorPath: "reactor", + profiles: ["dev", "qa"], + settingsPath: "C:/Users/example/.m2/settings.xml", + skipTests: true, + mavenExecutablePath: "D:/Tools/apache-maven", + javaHomePath: "C:/Java/jdk-21", + }; + + await createMavenLaunchPlan("D:/work", context, ["verify"], "app"); + + expect(executeCore).toHaveBeenCalledWith( + expect.objectContaining({ + command: "maven.launchPlan", + payload: { + root: "D:/work", + context, + module: "app", + goals: ["verify"], + }, + }), + ); + }); +}); diff --git a/windows/tauri/src/features/maven/api/maven-core-api.ts b/windows/tauri/src/features/maven/api/maven-core-api.ts new file mode 100644 index 00000000..7b909fb0 --- /dev/null +++ b/windows/tauri/src/features/maven/api/maven-core-api.ts @@ -0,0 +1,61 @@ +import { executeCore } from "@/core/lithe-core-client"; +import type { + MavenDiagnostic, + MavenLaunchContext, + MavenLaunchPlan, + MavenProject, +} from "../types/maven.types"; + +let requestSequence = 0; + +function nextRequestId(prefix: string): string { + requestSequence += 1; + return `${prefix}-${Date.now()}-${requestSequence}`; +} + +async function mavenCore( + command: string, + payload: unknown, + timeoutMilliseconds = 30_000, +): Promise { + const response = await executeCore({ + id: nextRequestId(command), + operationId: nextRequestId(`${command}-op`), + timeoutMilliseconds, + command, + payload, + }); + if (!response.ok) { + const error = new Error(response.error.message) as Error & { code?: string; details?: string }; + error.code = response.error.code; + error.details = response.error.details; + throw error; + } + return response.data; +} + +export function scanMavenProject(root: string, paths: string[] = []) { + return mavenCore("maven.scan", { root, paths }, 60_000); +} + +export function createMavenLaunchPlan( + root: string, + context: MavenLaunchContext, + goals: string[], + module?: string | null, +) { + return mavenCore("maven.launchPlan", { + root, + context, + module: module ?? null, + goals, + }); +} + +export async function parseMavenDiagnostics(root: string, output: string) { + const result = await mavenCore<{ issues: MavenDiagnostic[] }>("maven.diagnostics", { + root, + output, + }); + return result.issues ?? []; +} diff --git a/windows/tauri/src/features/maven/api/maven-host-api.ts b/windows/tauri/src/features/maven/api/maven-host-api.ts new file mode 100644 index 00000000..e4eb900f --- /dev/null +++ b/windows/tauri/src/features/maven/api/maven-host-api.ts @@ -0,0 +1,42 @@ +import { invoke } from "@/platform/tauri-core"; +import { resolveRunLaunch, startRunProcess, stopRunProcess } from "@/features/run/api/run-host-api"; +import type { + MavenLaunchContext, + MavenLaunchPlan, + MavenStoredConfiguration, +} from "../types/maven.types"; + +export function loadMavenConfiguration(root: string, reactorPath: string) { + return invoke("maven_load_configuration", { + root, + reactorPath, + }); +} + +export function writeMavenConfiguration( + root: string, + reactorPath: string, + configuration: MavenStoredConfiguration, +) { + return invoke("maven_write_configuration", { + args: { root, reactorPath, configuration }, + }); +} + +export async function resolveMavenLaunch( + root: string, + context: MavenLaunchContext, + plan: MavenLaunchPlan, +) { + return resolveRunLaunch({ + root, + executable: plan.executable, + workingDirectory: plan.workingDirectory, + javaHomePath: "", + mavenExecutablePath: context.mavenExecutablePath ?? "", + mavenJavaHomePath: context.javaHomePath ?? "", + environment: {}, + }); +} + +export { startRunProcess as startMavenProcess, stopRunProcess as stopMavenProcess }; diff --git a/windows/tauri/src/features/maven/components/maven-pane.tsx b/windows/tauri/src/features/maven/components/maven-pane.tsx new file mode 100644 index 00000000..5e1aa3a8 --- /dev/null +++ b/windows/tauri/src/features/maven/components/maven-pane.tsx @@ -0,0 +1,730 @@ +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { open } from "@tauri-apps/plugin-dialog"; +import { getJavaWorkspaceLanguageServerOwner } from "@/features/editor/lsp/java-workspace-language-server"; +import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; +import { RunOutputText } from "@/features/run/components/run-output-text"; +import { useUIState } from "@/features/window/stores/ui-state.store"; +import { useTranslation } from "@/i18n/locale-provider"; +import { Button } from "@/ui/button"; +import { Checkbox } from "@/ui/checkbox"; +import Dialog from "@/ui/dialog"; +import Input from "@/ui/input"; +import { + ArrowClockwiseIcon, + ArrowCounterClockwiseIcon, + ArrowsInIcon, + CaretDownIcon, + CaretRightIcon, + FolderIcon, + GearIcon, + MinusIcon, + PackageIcon, + PlayIcon, + PlusIcon, + SlidersHorizontalIcon, + StopIcon, + TerminalIcon, + TrashIcon, + WarningIcon, +} from "@/ui/icons"; +import { ScrollArea } from "@/ui/scroll-area"; +import { Spinner } from "@/ui/spinner"; +import Tooltip from "@/ui/tooltip"; +import { joinPath } from "@/utils/path-helpers"; +import { cn } from "@/utils/cn"; +import { ensureMavenProcessListeners } from "../hooks/use-maven-process-events"; +import { availableMavenProfiles, useMavenStore } from "../stores/maven.store"; +import { + MAVEN_LIFECYCLE_PHASES, + type MavenLifecyclePhase, + type MavenModule, + type MavenSettings, +} from "../types/maven.types"; + +interface TreeNodeProps { + id: string; + title: string; + subtitle?: string; + icon?: ReactNode; + selected?: boolean; + expanded: boolean; + onToggle: (id: string) => void; + onSelect?: () => void; + children?: ReactNode; +} + +function TreeNode({ + id, + title, + subtitle, + icon, + selected, + expanded, + onToggle, + onSelect, + children, +}: TreeNodeProps) { + return ( +
+
+ + +
+ {expanded && children ? ( +
{children}
+ ) : null} +
+ ); +} + +function MavenSettingsDialog({ + initial, + error, + onClose, + onSave, +}: { + initial: MavenSettings; + error: string | null; + onClose: () => void; + onSave: (settings: MavenSettings) => void; +}) { + const { t } = useTranslation(); + const [draft, setDraft] = useState(initial); + + const choosePath = async (field: keyof MavenSettings, directory: boolean) => { + const selected = await open({ + directory, + multiple: false, + ...(field === "settingsPath" + ? { filters: [{ name: "Maven settings", extensions: ["xml"] }] } + : {}), + }); + if (typeof selected === "string") setDraft((current) => ({ ...current, [field]: selected })); + }; + + const fields: Array<{ + id: string; + field: keyof MavenSettings; + label: string; + directory: boolean; + }> = [ + { id: "maven-settings-xml", field: "settingsPath", label: "settings.xml", directory: false }, + { + id: "maven-executable", + field: "mavenExecutablePath", + label: t("maven.mavenExecutable"), + directory: true, + }, + { + id: "maven-jdk-home", + field: "javaHomePath", + label: t("maven.javaHome"), + directory: true, + }, + ]; + + return ( + + {error ? ( + {error} + ) : ( + + )} + + + + } + > +
+ {fields.map(({ id, field, label, directory }) => ( + + ))} +
+
+ ); +} + +export default function MavenPane() { + const { t } = useTranslation(); + const root = useMavenStore((state) => state.root); + const visiblePaths = useMavenStore((state) => state.visiblePaths); + const projectStatus = useMavenStore((state) => state.projectStatus); + const projectError = useMavenStore((state) => state.projectError); + const project = useMavenStore((state) => state.project); + const selectedProfiles = useMavenStore((state) => state.selectedProfiles); + const customProfiles = useMavenStore((state) => state.customProfiles); + const skipTests = useMavenStore((state) => state.skipTests); + const settingsPath = useMavenStore((state) => state.settingsPath); + const mavenExecutablePath = useMavenStore((state) => state.mavenExecutablePath); + const javaHomePath = useMavenStore((state) => state.javaHomePath); + const configurationSaveError = useMavenStore((state) => state.configurationSaveError); + const reloadRequired = useMavenStore((state) => state.reloadRequired); + const taskStatus = useMavenStore((state) => state.taskStatus); + const taskError = useMavenStore((state) => state.taskError); + const runningTitle = useMavenStore((state) => state.runningTitle); + const output = useMavenStore((state) => state.output); + const issues = useMavenStore((state) => state.issues); + const lastExitCode = useMavenStore((state) => state.lastExitCode); + const actions = useMavenStore((state) => state.actions); + const handleFileSelect = useFileSystemStore((state) => state.handleFileSelect); + const setIsBottomPaneVisible = useUIState((state) => state.setIsBottomPaneVisible); + const [selectedModule, setSelectedModule] = useState(null); + const [selectedPhase, setSelectedPhase] = useState("compile"); + const [expanded, setExpanded] = useState>(new Set()); + const [goalDialogOpen, setGoalDialogOpen] = useState(false); + const [settingsDialogOpen, setSettingsDialogOpen] = useState(false); + const [profileDialogOpen, setProfileDialogOpen] = useState(false); + const [customGoal, setCustomGoal] = useState(""); + const [customProfile, setCustomProfile] = useState(""); + const [reloadError, setReloadError] = useState(null); + + const profiles = useMemo( + () => availableMavenProfiles({ project, customProfiles }), + [customProfiles, project], + ); + const isRunning = taskStatus === "running" || taskStatus === "stopping"; + + useEffect(() => { + void ensureMavenProcessListeners(); + }, []); + + useEffect(() => { + if (!project) return; + const initial = new Set([`project:${project.relativePath}`]); + if (profiles.length > 0) initial.add("profiles"); + setExpanded(initial); + setSelectedModule(null); + setSelectedPhase("compile"); + }, [project?.relativePath]); + + const toggleExpanded = (id: string) => { + setExpanded((current) => { + const next = new Set(current); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const runPhase = (phase: MavenLifecyclePhase, module: MavenModule | null) => { + setSelectedModule(module?.relativePath ?? null); + setSelectedPhase(phase); + const target = module?.artifactId ?? project?.artifactId ?? t("maven.project"); + void actions.runGoals([phase], module?.relativePath ?? null, `${phase} · ${target}`); + }; + + const runSelected = () => { + const module = findMavenModule(project?.modules ?? [], selectedModule); + runPhase(selectedPhase, module); + }; + + const runCustomGoal = () => { + const goals = customGoal.trim().split(/\s+/).filter(Boolean); + if (goals.length === 0) return; + const module = findMavenModule(project?.modules ?? [], selectedModule); + const target = module?.artifactId ?? project?.artifactId ?? t("maven.project"); + setGoalDialogOpen(false); + void actions.runGoals(goals, module?.relativePath ?? null, `${customGoal.trim()} · ${target}`); + }; + + const reloadJava = async () => { + if (!root) return; + setReloadError(null); + try { + const files = await useFileSystemStore.getState().getAllProjectFiles(); + const javaFile = files + .filter((entry) => !entry.isDir && entry.path.toLowerCase().endsWith(".java")) + .map((entry) => entry.path) + .sort()[0]; + const owner = getJavaWorkspaceLanguageServerOwner(); + await owner.stop(root); + if (javaFile) await owner.prewarm(root, javaFile); + actions.acknowledgeReload(); + } catch (error) { + setReloadError(error instanceof Error ? error.message : t("maven.reloadFailed")); + } + }; + + const reloadProjects = async () => { + if (!root) return; + await actions.loadProject(root, visiblePaths); + if (useMavenStore.getState().project) await reloadJava(); + }; + + const openIssue = (path: string, line: number, column?: number | null) => { + if (!root || !path) return; + const target = /^(?:[A-Za-z]:[\\/]|[\\/]{2}|\/)/.test(path) ? path : joinPath(root, path); + void handleFileSelect(target, false, line, column ?? undefined, undefined, false); + }; + + const renderLifecycle = (ownerId: string, module: MavenModule | null) => { + const id = `${ownerId}:lifecycle`; + return ( + } + expanded={expanded.has(id)} + onToggle={toggleExpanded} + > + {MAVEN_LIFECYCLE_PHASES.map((phase) => { + const selected = + selectedModule === (module?.relativePath ?? null) && selectedPhase === phase; + return ( + + ); + })} + + ); + }; + + const renderModule = (module: MavenModule): ReactNode => { + const id = `module:${module.relativePath}`; + return ( + setSelectedModule(module.relativePath)} + > + {renderLifecycle(id, module)} + {module.modules.map(renderModule)} + + ); + }; + + return ( +
+
+ +
+ {t("maven.title")} + {project ? ` · ${project.artifactId}` : ""} +
+ {projectStatus === "loading" ? : null} + {runningTitle ? ( + + {runningTitle} + + ) : null} + {!isRunning && lastExitCode != null ? ( + + {lastExitCode === 0 ? t("run.succeeded") : t("run.failed")} + + ) : null} + + + + + + + + + + + + + + + + + + + + + + + + +
+ + {reloadRequired || configurationSaveError || reloadError || taskError ? ( +
+ + + {configurationSaveError ?? reloadError ?? taskError ?? t("maven.configurationChanged")} + + {reloadRequired || reloadError ? ( + + ) : null} +
+ ) : null} + + {projectStatus === "failed" ? ( +
+ +
{t("maven.loadFailed")}
+
{projectError}
+ +
+ ) : project ? ( +
+ +
+ {profiles.length > 0 ? ( + } + expanded={expanded.has("profiles")} + onToggle={toggleExpanded} + > +
+ + + + + + +
+ {profiles.map((profile) => ( + + ))} +
+ ) : null} + setSelectedModule(null)} + > + {renderLifecycle(`project:${project.relativePath}`, null)} + {project.modules.map(renderModule)} + +
+
+
+
+ {t("maven.buildOutput")} + {issues.length > 0 ? {issues.length} : null} +
+ {issues.length > 0 ? ( + +
+ {issues.map((issue, index) => ( + + ))} +
+
+ ) : null} + +
+ +
+
+
+
+ ) : ( +
+ {projectStatus === "loading" ? t("maven.scanning") : t("maven.notDetected")} +
+ )} + + {goalDialogOpen ? ( + setGoalDialogOpen(false)} + footer={ + <> + + + + } + > + setCustomGoal(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") runCustomGoal(); + }} + /> + + ) : null} + {profileDialogOpen ? ( + setProfileDialogOpen(false)} + footer={ + <> + + + + } + > + setCustomProfile(event.target.value)} + /> + + ) : null} + {settingsDialogOpen ? ( + setSettingsDialogOpen(false)} + onSave={actions.updateLocalConfiguration} + /> + ) : null} +
+ ); +} + +function findMavenModule( + modules: readonly MavenModule[], + relativePath: string | null, +): MavenModule | null { + if (!relativePath) return null; + for (const module of modules) { + if (module.relativePath === relativePath) return module; + const nested = findMavenModule(module.modules, relativePath); + if (nested) return nested; + } + return null; +} diff --git a/windows/tauri/src/features/maven/hooks/use-maven-process-events.ts b/windows/tauri/src/features/maven/hooks/use-maven-process-events.ts new file mode 100644 index 00000000..252e3c3b --- /dev/null +++ b/windows/tauri/src/features/maven/hooks/use-maven-process-events.ts @@ -0,0 +1,36 @@ +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { mavenStoreForSession, releaseMavenSessionWorkspace } from "../stores/maven.store"; + +interface RunOutputEvent { + sessionId: string; + chunk: string; +} + +interface RunExitEvent { + sessionId: string; + exitCode: number; +} + +let outputUnlisten: UnlistenFn | undefined; +let exitUnlisten: UnlistenFn | undefined; + +export async function ensureMavenProcessListeners(): Promise { + if (!outputUnlisten) { + outputUnlisten = await listen("run-output", (event) => { + if (!event.payload.sessionId.startsWith("maven:")) return; + mavenStoreForSession(event.payload.sessionId) + .getState() + .actions.appendOutput(event.payload.sessionId, event.payload.chunk); + }); + } + if (!exitUnlisten) { + exitUnlisten = await listen("run-exit", (event) => { + const sessionId = event.payload.sessionId; + if (!sessionId.startsWith("maven:")) return; + mavenStoreForSession(sessionId) + .getState() + .actions.finishProcess(sessionId, event.payload.exitCode); + releaseMavenSessionWorkspace(sessionId); + }); + } +} diff --git a/windows/tauri/src/features/maven/stores/maven.store.test.ts b/windows/tauri/src/features/maven/stores/maven.store.test.ts new file mode 100644 index 00000000..bb17d0fb --- /dev/null +++ b/windows/tauri/src/features/maven/stores/maven.store.test.ts @@ -0,0 +1,282 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import type { + MavenDiagnostic, + MavenLaunchPlan, + MavenProject, + MavenStoredConfiguration, +} from "../types/maven.types"; +import { createMavenStore, mavenLaunchContext, type MavenStoreDependencies } from "./maven.store"; + +type Deferred = { + promise: Promise; + resolve: (value: T) => void; +}; + +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +const project: MavenProject = { + relativePath: "reactor", + groupId: "dev.lithe", + artifactId: "demo", + version: "1.0.0", + packaging: "pom", + hasWrapper: true, + profiles: [ + { id: "default", isActiveByDefault: true }, + { id: "dev", isActiveByDefault: false }, + ], + modules: [], +}; + +const launchPlan: MavenLaunchPlan = { + version: 1, + executable: { toolchain: "project-maven" }, + arguments: ["-B", "compile"], + workingDirectory: "reactor", + configurationFingerprint: "fixture-fingerprint", +}; + +const scanMavenProject = mock(async (_root: string, _paths?: string[]) => project); +const createMavenLaunchPlan = mock(async () => launchPlan); +const parseMavenDiagnostics = mock( + async (_root: string, _output: string): Promise => [], +); +const loadMavenConfiguration = mock(async () => ({})); +const writeMavenConfiguration = mock( + async ( + _root: string, + _reactorPath: string, + _configuration: MavenStoredConfiguration, + ): Promise => undefined, +); +const resolveMavenLaunch = mock(async () => ({ + executable: "D:/Tools/apache-maven/bin/mvn.cmd", + workingDirectory: "D:/work/reactor", + environment: {}, +})); +const startMavenProcess = mock(async () => undefined); +const stopMavenProcess = mock(async () => undefined); + +const dependencies = { + createMavenLaunchPlan, + loadMavenConfiguration, + parseMavenDiagnostics, + resolveMavenLaunch, + scanMavenProject, + startMavenProcess, + stopMavenProcess, + writeMavenConfiguration, +} satisfies MavenStoreDependencies; + +beforeEach(() => { + scanMavenProject.mockReset(); + scanMavenProject.mockResolvedValue(project); + loadMavenConfiguration.mockReset(); + loadMavenConfiguration.mockResolvedValue({}); + writeMavenConfiguration.mockClear(); + createMavenLaunchPlan.mockReset(); + createMavenLaunchPlan.mockResolvedValue(launchPlan); + parseMavenDiagnostics.mockReset(); + parseMavenDiagnostics.mockResolvedValue([]); + resolveMavenLaunch.mockClear(); + startMavenProcess.mockClear(); + stopMavenProcess.mockClear(); +}); + +describe("Maven workspace state", () => { + test("restores portable selections and machine-local paths into one launch context", async () => { + loadMavenConfiguration.mockResolvedValue({ + portable: { + version: 1, + selectedProfiles: ["qa", "dev"], + customProfiles: ["qa"], + skipTests: true, + }, + local: { + version: 1, + settingsPath: "C:/Users/example/.m2/settings.xml", + mavenExecutablePath: "D:/Tools/apache-maven", + javaHomePath: "C:/Java/jdk-21", + }, + }); + const store = createMavenStore("workspace", dependencies); + + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + + expect(mavenLaunchContext(store.getState())).toEqual({ + version: 1, + reactorPath: "reactor", + profiles: ["dev", "qa"], + settingsPath: "C:/Users/example/.m2/settings.xml", + skipTests: true, + mavenExecutablePath: "D:/Tools/apache-maven", + javaHomePath: "C:/Java/jdk-21", + }); + }); + + test("persists portable and local values in separate documents", async () => { + const store = createMavenStore("workspace", dependencies); + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + const writeStarted = deferred(); + writeMavenConfiguration.mockImplementationOnce(async () => { + writeStarted.resolve(undefined); + }); + + store.getState().actions.updateLocalConfiguration({ + settingsPath: "C:/Users/example/.m2/settings.xml", + mavenExecutablePath: "D:/Tools/apache-maven", + javaHomePath: "C:/Java/jdk-21", + }); + await writeStarted.promise; + + const calls = writeMavenConfiguration.mock.calls; + const configuration = calls[calls.length - 1]?.[2]; + expect(configuration?.portable).toEqual({ + version: 1, + selectedProfiles: ["default"], + customProfiles: [], + skipTests: false, + }); + expect(configuration?.portable).not.toHaveProperty("settingsPath"); + expect(configuration?.local).toEqual({ + version: 1, + settingsPath: "C:/Users/example/.m2/settings.xml", + mavenExecutablePath: "D:/Tools/apache-maven", + javaHomePath: "C:/Java/jdk-21", + }); + }); + + test("serializes rapid configuration writes so the newest value wins", async () => { + const firstStarted = deferred(); + const firstWrite = deferred(); + const secondStarted = deferred(); + writeMavenConfiguration + .mockImplementationOnce(async () => { + firstStarted.resolve(undefined); + await firstWrite.promise; + }) + .mockImplementationOnce(async () => { + secondStarted.resolve(undefined); + }); + const store = createMavenStore("workspace", dependencies); + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + + store.getState().actions.setSkipTests(true); + store.getState().actions.setSkipTests(false); + try { + await firstStarted.promise; + expect(writeMavenConfiguration).toHaveBeenCalledTimes(1); + + firstWrite.resolve(undefined); + await secondStarted.promise; + + expect(writeMavenConfiguration).toHaveBeenCalledTimes(2); + expect(writeMavenConfiguration.mock.calls[1]?.[2].portable?.skipTests).toBe(false); + } finally { + firstWrite.resolve(undefined); + } + }); + + test("waits for a pending configuration write before reloading", async () => { + const firstStarted = deferred(); + const firstWrite = deferred(); + const reloadScanStarted = deferred(); + const reloadConfigurationStarted = deferred(); + writeMavenConfiguration.mockImplementationOnce(async () => { + firstStarted.resolve(undefined); + await firstWrite.promise; + }); + const store = createMavenStore("workspace", dependencies); + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + scanMavenProject.mockImplementationOnce(async () => { + reloadScanStarted.resolve(undefined); + return project; + }); + loadMavenConfiguration.mockImplementationOnce(async () => { + reloadConfigurationStarted.resolve(undefined); + return {}; + }); + let reload: Promise | undefined; + + try { + store.getState().actions.setSkipTests(true); + await firstStarted.promise; + reload = store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + await reloadScanStarted.promise; + + expect(loadMavenConfiguration).toHaveBeenCalledTimes(1); + firstWrite.resolve(undefined); + await reloadConfigurationStarted.promise; + await reload; + + expect(loadMavenConfiguration).toHaveBeenCalledTimes(2); + } finally { + firstWrite.resolve(undefined); + await reload; + } + }); + + test("does not let an older scan replace a newer workspace", async () => { + const firstScan = deferred(); + const secondScan = deferred(); + scanMavenProject + .mockImplementationOnce(() => firstScan.promise) + .mockImplementationOnce(() => secondScan.promise); + const store = createMavenStore("workspace", dependencies); + + const first = store.getState().actions.loadProject("D:/first", ["pom.xml"]); + const second = store.getState().actions.loadProject("D:/second", ["pom.xml"]); + secondScan.resolve({ ...project, artifactId: "second" }); + await second; + firstScan.resolve({ ...project, artifactId: "first" }); + await first; + + expect(store.getState().root).toBe("D:/second"); + expect(store.getState().project?.artifactId).toBe("second"); + }); + + test("cancels a pending launch without starting a stale process", async () => { + const pendingPlan = deferred(); + createMavenLaunchPlan.mockImplementationOnce(() => pendingPlan.promise); + const store = createMavenStore("workspace", dependencies); + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + + const run = store.getState().actions.runGoals(["compile"], null, "compile"); + await Promise.resolve(); + await store.getState().actions.stop(); + pendingPlan.resolve(launchPlan); + await run; + + expect(startMavenProcess).not.toHaveBeenCalled(); + expect(store.getState().taskStatus).toBe("idle"); + expect(store.getState().activeSessionId).toBeNull(); + }); + + test("does not let diagnostics from a completed task replace a newer run", async () => { + const pendingDiagnostics = + deferred>(); + parseMavenDiagnostics.mockImplementationOnce(() => pendingDiagnostics.promise); + const store = createMavenStore("workspace", dependencies); + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + await store.getState().actions.runGoals(["compile"], null, "compile"); + const completedSession = store.getState().activeSessionId; + expect(completedSession).not.toBeNull(); + + store.getState().actions.finishProcess(completedSession!, 1); + await store.getState().actions.runGoals(["test"], null, "test"); + pendingDiagnostics.resolve([ + { path: "src/Old.java", line: 3, severity: "error", message: "old task" }, + ]); + await pendingDiagnostics.promise; + await Promise.resolve(); + + expect(store.getState().issues).toEqual([]); + expect(store.getState().runningTitle).toBe("test"); + }); +}); diff --git a/windows/tauri/src/features/maven/stores/maven.store.ts b/windows/tauri/src/features/maven/stores/maven.store.ts new file mode 100644 index 00000000..39387650 --- /dev/null +++ b/windows/tauri/src/features/maven/stores/maven.store.ts @@ -0,0 +1,600 @@ +import { createStore } from "zustand/vanilla"; +import { createWorkspaceScopedStore } from "@/features/workspace/stores/create-workspace-scoped-store"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { + createMavenLaunchPlan, + parseMavenDiagnostics, + scanMavenProject, +} from "../api/maven-core-api"; +import { + loadMavenConfiguration, + resolveMavenLaunch, + startMavenProcess, + stopMavenProcess, + writeMavenConfiguration, +} from "../api/maven-host-api"; +import type { + MavenDiagnostic, + MavenLaunchContext, + MavenLocalConfiguration, + MavenPortableConfiguration, + MavenProfile, + MavenProject, + MavenProjectStatus, + MavenSettings, + MavenStoredConfiguration, + MavenTaskStatus, +} from "../types/maven.types"; + +const MAXIMUM_OUTPUT_CHARACTERS = 500_000; +const mavenSessionWorkspaces = new Map(); + +interface MavenProjectLoad { + task: Promise; + hasVisiblePaths: boolean; +} + +const mavenProjectLoads = new Map(); + +export interface MavenStoreDependencies { + createMavenLaunchPlan: typeof createMavenLaunchPlan; + loadMavenConfiguration: typeof loadMavenConfiguration; + parseMavenDiagnostics: typeof parseMavenDiagnostics; + resolveMavenLaunch: typeof resolveMavenLaunch; + scanMavenProject: typeof scanMavenProject; + startMavenProcess: typeof startMavenProcess; + stopMavenProcess: typeof stopMavenProcess; + writeMavenConfiguration: typeof writeMavenConfiguration; +} + +const defaultMavenStoreDependencies: MavenStoreDependencies = { + createMavenLaunchPlan, + loadMavenConfiguration, + parseMavenDiagnostics, + resolveMavenLaunch, + scanMavenProject, + startMavenProcess, + stopMavenProcess, + writeMavenConfiguration, +}; + +export interface MavenState { + root: string | null; + visiblePaths: string[]; + projectStatus: MavenProjectStatus; + projectError: string | null; + project: MavenProject | null; + selectedProfiles: string[]; + customProfiles: string[]; + skipTests: boolean; + settingsPath: string; + mavenExecutablePath: string; + javaHomePath: string; + configurationSaveError: string | null; + reloadRequired: boolean; + taskStatus: MavenTaskStatus; + taskError: string | null; + activeSessionId: string | null; + runningTitle: string | null; + output: string; + issues: MavenDiagnostic[]; + lastExitCode: number | null; + actions: { + loadProject: (root: string, visiblePaths?: string[]) => Promise; + setSelectedProfiles: (profiles: string[]) => void; + addCustomProfile: (profile: string) => boolean; + restoreDefaultProfiles: () => void; + setSkipTests: (enabled: boolean) => void; + updateLocalConfiguration: (settings: MavenSettings) => void; + acknowledgeReload: () => void; + runGoals: (goals: string[], module: string | null, title: string) => Promise; + stop: () => Promise; + clearOutput: () => void; + appendOutput: (sessionId: string, chunk: string) => void; + finishProcess: (sessionId: string, exitCode: number) => void; + }; +} + +function normalizedProfile(value: string): string | null { + const profile = value.trim(); + const hasControlCharacter = [...profile].some((character) => { + const code = character.charCodeAt(0); + return code <= 0x1f || code === 0x7f; + }); + if (!profile || profile.includes(",") || hasControlCharacter) return null; + return profile; +} + +function normalizedProfiles(values: readonly string[]): string[] { + return [ + ...new Set(values.map(normalizedProfile).filter((value): value is string => !!value)), + ].sort(); +} + +function normalizedPath(value: string | null | undefined): string { + return value?.trim() ?? ""; +} + +export function availableMavenProfiles(state: Pick) { + const profiles = new Map(); + for (const profile of state.project?.profiles ?? []) profiles.set(profile.id, profile); + for (const id of state.customProfiles) { + if (!profiles.has(id)) profiles.set(id, { id, isActiveByDefault: false }); + } + return [...profiles.values()]; +} + +export function mavenLaunchContext(state: MavenState): MavenLaunchContext | null { + if (!state.project) return null; + return { + version: 1, + reactorPath: state.project.relativePath, + profiles: normalizedProfiles(state.selectedProfiles), + settingsPath: state.settingsPath || null, + skipTests: state.skipTests, + mavenExecutablePath: state.mavenExecutablePath || null, + javaHomePath: state.javaHomePath || null, + }; +} + +function storedConfiguration(state: MavenState): MavenStoredConfiguration { + const portable: MavenPortableConfiguration = { + version: 1, + selectedProfiles: normalizedProfiles(state.selectedProfiles), + customProfiles: normalizedProfiles(state.customProfiles), + skipTests: state.skipTests, + }; + const local: MavenLocalConfiguration = { + version: 1, + settingsPath: state.settingsPath || null, + mavenExecutablePath: state.mavenExecutablePath || null, + javaHomePath: state.javaHomePath || null, + }; + return { portable, local }; +} + +function displayArguments(arguments_: readonly string[]): string { + return arguments_ + .map((argument, index) => + index > 0 && arguments_[index - 1] === "-s" ? "" : argument, + ) + .join(" "); +} + +function trimOutput(output: string): string { + const normalized = output.replace(/\r/g, ""); + return normalized.length > MAXIMUM_OUTPUT_CHARACTERS + ? normalized.slice(normalized.length - MAXIMUM_OUTPUT_CHARACTERS) + : normalized; +} + +export const createMavenStore = ( + workspaceId = workspaceRuntimeRegistry.getActiveWorkspaceId(), + dependencies: MavenStoreDependencies = defaultMavenStoreDependencies, +) => { + let projectLoadRevision = 0; + let configurationRevision = 0; + let launchRevision = 0; + let diagnosticsRevision = 0; + let configurationWriteTask = Promise.resolve(); + + return createStore()((set, get) => { + const persistConfiguration = () => { + const state = get(); + if (!state.root || !state.project) return; + const revision = ++configurationRevision; + const configuration = storedConfiguration(state); + const root = state.root; + const reactorPath = state.project.relativePath; + configurationWriteTask = configurationWriteTask + .catch(() => undefined) + .then(() => dependencies.writeMavenConfiguration(root, reactorPath, configuration)); + void configurationWriteTask + .then(() => { + if (configurationRevision === revision) set({ configurationSaveError: null }); + }) + .catch((error) => { + if (configurationRevision !== revision) return; + set({ + configurationSaveError: + error instanceof Error ? error.message : "Unable to save Maven configuration.", + }); + }); + }; + + const configurationDidChange = () => { + set({ reloadRequired: true, configurationSaveError: null }); + persistConfiguration(); + }; + + return { + root: null, + visiblePaths: [], + projectStatus: "idle", + projectError: null, + project: null, + selectedProfiles: [], + customProfiles: [], + skipTests: false, + settingsPath: "", + mavenExecutablePath: "", + javaHomePath: "", + configurationSaveError: null, + reloadRequired: false, + taskStatus: "idle", + taskError: null, + activeSessionId: null, + runningTitle: null, + output: "", + issues: [], + lastExitCode: null, + actions: { + loadProject: async (root, visiblePaths = []) => { + const revision = ++projectLoadRevision; + configurationRevision += 1; + const previous = get(); + if (previous.root && previous.root !== root && previous.activeSessionId) { + launchRevision += 1; + diagnosticsRevision += 1; + await dependencies.stopMavenProcess(previous.activeSessionId).catch(() => undefined); + releaseMavenSessionWorkspace(previous.activeSessionId); + } + set({ + root, + visiblePaths: [...visiblePaths], + projectStatus: "loading", + projectError: null, + configurationSaveError: null, + ...(previous.root && previous.root !== root + ? { + project: null, + taskStatus: "idle" as const, + taskError: null, + activeSessionId: null, + runningTitle: null, + output: "", + issues: [], + lastExitCode: null, + } + : {}), + }); + try { + const project = await dependencies.scanMavenProject(root, visiblePaths); + if (projectLoadRevision !== revision || get().root !== root) return; + if (!project) { + set({ + projectStatus: "ready", + project: null, + selectedProfiles: [], + customProfiles: [], + skipTests: false, + settingsPath: "", + mavenExecutablePath: "", + javaHomePath: "", + reloadRequired: false, + }); + return; + } + await configurationWriteTask.catch(() => undefined); + if (projectLoadRevision !== revision || get().root !== root) return; + const stored = await dependencies.loadMavenConfiguration(root, project.relativePath); + if (projectLoadRevision !== revision || get().root !== root) return; + const customProfiles = normalizedProfiles(stored.portable?.customProfiles ?? []); + const knownProfiles = new Set([ + ...project.profiles.map((profile) => profile.id), + ...customProfiles, + ]); + const defaultProfiles = project.profiles + .filter((profile) => profile.isActiveByDefault) + .map((profile) => profile.id); + const selectedProfiles = normalizedProfiles( + stored.portable?.selectedProfiles ?? defaultProfiles, + ).filter((profile) => knownProfiles.has(profile)); + set({ + projectStatus: "ready", + projectError: null, + project, + selectedProfiles, + customProfiles, + skipTests: stored.portable?.skipTests ?? false, + settingsPath: normalizedPath(stored.local?.settingsPath), + mavenExecutablePath: normalizedPath(stored.local?.mavenExecutablePath), + javaHomePath: normalizedPath(stored.local?.javaHomePath), + reloadRequired: false, + }); + } catch (error) { + if (projectLoadRevision !== revision || get().root !== root) return; + set({ + projectStatus: "failed", + projectError: + error instanceof Error ? error.message : "Unable to scan the Maven project.", + project: null, + selectedProfiles: [], + customProfiles: [], + skipTests: false, + settingsPath: "", + mavenExecutablePath: "", + javaHomePath: "", + }); + } + }, + + setSelectedProfiles: (profiles) => { + const knownProfiles = new Set(availableMavenProfiles(get()).map((profile) => profile.id)); + const selectedProfiles = normalizedProfiles(profiles).filter((profile) => + knownProfiles.has(profile), + ); + if (selectedProfiles.join("\0") === get().selectedProfiles.join("\0")) return; + set({ selectedProfiles }); + configurationDidChange(); + }, + + addCustomProfile: (value) => { + const profile = normalizedProfile(value); + if (!profile) return false; + const state = get(); + set({ + customProfiles: normalizedProfiles([...state.customProfiles, profile]), + selectedProfiles: normalizedProfiles([...state.selectedProfiles, profile]), + }); + configurationDidChange(); + return true; + }, + + restoreDefaultProfiles: () => { + const defaults = normalizedProfiles( + get() + .project?.profiles.filter((profile) => profile.isActiveByDefault) + .map((profile) => profile.id) ?? [], + ); + if (defaults.join("\0") === get().selectedProfiles.join("\0")) return; + set({ selectedProfiles: defaults }); + configurationDidChange(); + }, + + setSkipTests: (enabled) => { + if (get().skipTests === enabled) return; + set({ skipTests: enabled }); + configurationDidChange(); + }, + + updateLocalConfiguration: (settings) => { + const next = { + settingsPath: normalizedPath(settings.settingsPath), + mavenExecutablePath: normalizedPath(settings.mavenExecutablePath), + javaHomePath: normalizedPath(settings.javaHomePath), + }; + const state = get(); + if ( + next.settingsPath === state.settingsPath && + next.mavenExecutablePath === state.mavenExecutablePath && + next.javaHomePath === state.javaHomePath + ) { + return; + } + set(next); + configurationDidChange(); + }, + + acknowledgeReload: () => set({ reloadRequired: false }), + + runGoals: async (goals, module, title) => { + const state = get(); + const context = mavenLaunchContext(state); + if (!state.root || !context || goals.length === 0) return; + const revision = ++launchRevision; + diagnosticsRevision += 1; + const previousSessionId = state.activeSessionId; + if (previousSessionId) { + await dependencies.stopMavenProcess(previousSessionId).catch(() => undefined); + releaseMavenSessionWorkspace(previousSessionId); + } + const sessionId = `maven:${crypto.randomUUID()}`; + bindMavenSessionWorkspace(sessionId, workspaceId); + set({ + taskStatus: "running", + taskError: null, + activeSessionId: sessionId, + runningTitle: title, + output: "", + issues: [], + lastExitCode: null, + }); + try { + const plan = await dependencies.createMavenLaunchPlan( + state.root, + context, + goals, + module, + ); + const resolved = await dependencies.resolveMavenLaunch(state.root, context, plan); + if (launchRevision !== revision || get().activeSessionId !== sessionId) { + releaseMavenSessionWorkspace(sessionId); + return; + } + const executableName = resolved.executable.split(/[\\/]/).pop() ?? "mvn"; + set({ output: `$ ${executableName} ${displayArguments(plan.arguments)}\n\n` }); + await dependencies.startMavenProcess({ + sessionId, + executable: resolved.executable, + arguments: plan.arguments, + workingDirectory: resolved.workingDirectory, + environment: resolved.environment, + }); + if (launchRevision !== revision || get().activeSessionId !== sessionId) { + await dependencies.stopMavenProcess(sessionId).catch(() => undefined); + releaseMavenSessionWorkspace(sessionId); + } + } catch (error) { + if (launchRevision !== revision || get().activeSessionId !== sessionId) { + releaseMavenSessionWorkspace(sessionId); + return; + } + const message = + error instanceof Error ? error.message : "Unable to start the Maven task."; + set({ + taskStatus: "failed", + taskError: message, + activeSessionId: null, + runningTitle: null, + lastExitCode: 1, + output: trimOutput(`${get().output}${message}\n`), + issues: [{ path: "", line: 1, column: null, severity: "error", message }], + }); + releaseMavenSessionWorkspace(sessionId); + } + }, + + stop: async () => { + launchRevision += 1; + diagnosticsRevision += 1; + const sessionId = get().activeSessionId; + if (!sessionId) return; + set({ taskStatus: "stopping" }); + try { + await dependencies.stopMavenProcess(sessionId); + if (get().activeSessionId === sessionId) { + set({ + taskStatus: "idle", + taskError: null, + activeSessionId: null, + runningTitle: null, + lastExitCode: null, + }); + } + } catch (error) { + if (get().activeSessionId === sessionId) { + set({ + taskStatus: "running", + taskError: + error instanceof Error ? error.message : "Unable to stop the Maven task.", + }); + } + } finally { + releaseMavenSessionWorkspace(sessionId); + } + }, + + clearOutput: () => { + diagnosticsRevision += 1; + set({ output: "", issues: [], lastExitCode: null }); + }, + + appendOutput: (sessionId, chunk) => { + if (get().activeSessionId !== sessionId) return; + set({ output: trimOutput(get().output + chunk) }); + }, + + finishProcess: (sessionId, exitCode) => { + const state = get(); + if (state.activeSessionId !== sessionId || !state.root) return; + const root = state.root; + const output = state.output; + const revision = ++diagnosticsRevision; + set({ + taskStatus: exitCode === 0 ? "idle" : "failed", + taskError: exitCode === 0 ? null : `Maven exited with code ${exitCode}.`, + activeSessionId: null, + runningTitle: null, + lastExitCode: exitCode, + }); + void dependencies + .parseMavenDiagnostics(root, output) + .then((issues) => { + if (diagnosticsRevision === revision && get().root === root) set({ issues }); + }) + .catch((error) => { + if (diagnosticsRevision !== revision || get().root !== root) return; + set({ + taskError: + error instanceof Error + ? error.message + : "Unable to parse Maven build diagnostics.", + }); + }); + }, + }, + }; + }); +}; + +export const useMavenStore = createWorkspaceScopedStore("maven", createMavenStore); + +function workspaceRootKey(root: string): string { + const normalized = root.replace(/\\/g, "/").replace(/\/$/, ""); + return /^(?:[A-Za-z]:\/|\/\/)/.test(normalized) ? normalized.toLowerCase() : normalized; +} + +function mavenProjectLoadKey(root: string, workspaceId: string): string { + return `${workspaceId}\0${workspaceRootKey(root)}`; +} + +export function loadMavenProjectForWorkspace( + root: string, + visiblePaths: string[] = [], + workspaceId = workspaceRuntimeRegistry.getActiveWorkspaceId(), +): Promise { + const key = mavenProjectLoadKey(root, workspaceId); + const existing = mavenProjectLoads.get(key); + if (existing) { + if (visiblePaths.length === 0 || existing.hasVisiblePaths) return existing.task; + return existing.task.then(() => loadMavenProjectForWorkspace(root, visiblePaths, workspaceId)); + } + const task = useMavenStore + .getStore(workspaceId) + .getState() + .actions.loadProject(root, visiblePaths) + .finally(() => { + if (mavenProjectLoads.get(key)?.task === task) mavenProjectLoads.delete(key); + }); + mavenProjectLoads.set(key, { task, hasVisiblePaths: visiblePaths.length > 0 }); + return task; +} + +export async function mavenLaunchContextForWorkspace( + root: string, + visiblePaths: string[] = [], + workspaceId = workspaceRuntimeRegistry.getActiveWorkspaceId(), +): Promise { + const key = mavenProjectLoadKey(root, workspaceId); + const pending = mavenProjectLoads.get(key); + if (pending) await pending.task; + let state = useMavenStore.getStore(workspaceId).getState(); + const rootKey = workspaceRootKey(root); + if ( + state.root === null || + workspaceRootKey(state.root) !== rootKey || + state.projectStatus === "idle" || + (state.project === null && visiblePaths.length > 0) + ) { + await loadMavenProjectForWorkspace(root, visiblePaths, workspaceId); + state = useMavenStore.getStore(workspaceId).getState(); + } + return state.root && workspaceRootKey(state.root) === rootKey ? mavenLaunchContext(state) : null; +} + +export function currentMavenLaunchContext( + root: string, + workspaceId = workspaceRuntimeRegistry.getActiveWorkspaceId(), +): MavenLaunchContext | null { + const state = useMavenStore.getStore(workspaceId).getState(); + return state.root && workspaceRootKey(state.root) === workspaceRootKey(root) + ? mavenLaunchContext(state) + : null; +} + +export function bindMavenSessionWorkspace(sessionId: string, workspaceId?: string): void { + mavenSessionWorkspaces.set( + sessionId, + workspaceId ?? workspaceRuntimeRegistry.getActiveWorkspaceId(), + ); +} + +export function mavenStoreForSession(sessionId: string) { + const workspaceId = mavenSessionWorkspaces.get(sessionId); + return workspaceId ? useMavenStore.getStore(workspaceId) : useMavenStore; +} + +export function releaseMavenSessionWorkspace(sessionId: string): void { + mavenSessionWorkspaces.delete(sessionId); +} diff --git a/windows/tauri/src/features/maven/types/maven.types.ts b/windows/tauri/src/features/maven/types/maven.types.ts new file mode 100644 index 00000000..f2f6076b --- /dev/null +++ b/windows/tauri/src/features/maven/types/maven.types.ts @@ -0,0 +1,92 @@ +export type MavenProjectStatus = "idle" | "loading" | "ready" | "failed"; +export type MavenTaskStatus = "idle" | "running" | "stopping" | "failed"; + +export interface MavenProfile { + id: string; + isActiveByDefault: boolean; +} + +export interface MavenModule { + relativePath: string; + groupId?: string | null; + artifactId: string; + version?: string | null; + packaging: string; + modules: MavenModule[]; +} + +export interface MavenProject { + relativePath: string; + groupId?: string | null; + artifactId: string; + version?: string | null; + packaging: string; + modules: MavenModule[]; + profiles: MavenProfile[]; + hasWrapper: boolean; +} + +export interface MavenLaunchContext { + version: 1; + reactorPath: string; + profiles: string[]; + settingsPath?: string | null; + skipTests: boolean; + mavenExecutablePath?: string | null; + javaHomePath?: string | null; +} + +export interface MavenLaunchPlan { + version: 1; + executable: { toolchain: "project-maven" }; + arguments: string[]; + workingDirectory: string; + configurationFingerprint: string; +} + +export interface MavenDiagnostic { + path: string; + line: number; + column?: number | null; + severity: "error" | "warning"; + message: string; +} + +export interface MavenPortableConfiguration { + version: 1; + selectedProfiles: string[]; + customProfiles: string[]; + skipTests: boolean; +} + +export interface MavenLocalConfiguration { + version: 1; + settingsPath?: string | null; + mavenExecutablePath?: string | null; + javaHomePath?: string | null; +} + +export interface MavenStoredConfiguration { + portable?: MavenPortableConfiguration | null; + local?: MavenLocalConfiguration | null; +} + +export interface MavenSettings { + settingsPath: string; + mavenExecutablePath: string; + javaHomePath: string; +} + +export const MAVEN_LIFECYCLE_PHASES = [ + "clean", + "validate", + "compile", + "test", + "package", + "verify", + "install", + "site", + "deploy", +] as const; + +export type MavenLifecyclePhase = (typeof MAVEN_LIFECYCLE_PHASES)[number]; diff --git a/windows/tauri/src/features/run/api/run-core-api.test.ts b/windows/tauri/src/features/run/api/run-core-api.test.ts index dd5066e4..aa4fb848 100644 --- a/windows/tauri/src/features/run/api/run-core-api.test.ts +++ b/windows/tauri/src/features/run/api/run-core-api.test.ts @@ -8,7 +8,7 @@ const executeCore = mock(async () => ({ mock.module("@/core/lithe-core-client", () => ({ executeCore })); -const { saveRunConfigurationEditorChanges } = await import("./run-core-api"); +const { createLaunchPlan, saveRunConfigurationEditorChanges } = await import("./run-core-api"); const emptyToolchain = { javaHomePath: "", @@ -22,6 +22,32 @@ beforeEach(() => { }); describe("saveRunConfigurationEditorChanges", () => { + test("forwards the shared Maven context when creating a launch plan", async () => { + const mavenContext = { + version: 1 as const, + reactorPath: "reactor", + profiles: ["dev"], + settingsPath: "C:/Users/example/.m2/settings.xml", + skipTests: true, + mavenExecutablePath: "D:/Tools/apache-maven", + javaHomePath: "C:/Java/jdk-21", + }; + + await createLaunchPlan("D:/fixture/project", "spring", undefined, mavenContext); + + expect(executeCore).toHaveBeenCalledWith( + expect.objectContaining({ + command: "runConfig.createLaunchPlan", + payload: { + root: "D:/fixture/project", + configurationId: "spring", + currentFile: undefined, + mavenContext, + }, + }), + ); + }); + test("sends project-relative working directory and toolchain paths in project scope", async () => { await saveRunConfigurationEditorChanges( "D:/fixture/project", diff --git a/windows/tauri/src/features/run/api/run-core-api.ts b/windows/tauri/src/features/run/api/run-core-api.ts index 5a6c9cf1..c38a9e8d 100644 --- a/windows/tauri/src/features/run/api/run-core-api.ts +++ b/windows/tauri/src/features/run/api/run-core-api.ts @@ -8,6 +8,7 @@ import type { RunOptions, RunSaveScope, } from "../types/run.types"; +import type { MavenLaunchContext } from "@/features/maven/types/maven.types"; import { projectScopedPath } from "../utils/run-configuration"; let requestSequence = 0; @@ -56,11 +57,17 @@ export function resolveRunConfiguration( return runCore("runConfig.resolve", { root, toolchainCandidates }); } -export function createLaunchPlan(root: string, configurationId: string, currentFile?: string) { +export function createLaunchPlan( + root: string, + configurationId: string, + currentFile?: string, + mavenContext?: MavenLaunchContext | null, +) { return runCore("runConfig.createLaunchPlan", { root, configurationId, currentFile, + mavenContext: mavenContext ?? null, }); } diff --git a/windows/tauri/src/features/run/stores/run.store.ts b/windows/tauri/src/features/run/stores/run.store.ts index 9d6677f1..3f3cbeec 100644 --- a/windows/tauri/src/features/run/stores/run.store.ts +++ b/windows/tauri/src/features/run/stores/run.store.ts @@ -1,6 +1,7 @@ import { createStore } from "zustand/vanilla"; import { createWorkspaceScopedStore } from "@/features/workspace/stores/create-workspace-scoped-store"; import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { currentMavenLaunchContext } from "@/features/maven/stores/maven.store"; import { createLaunchPlan, generateRunConfiguration, @@ -48,7 +49,11 @@ import { selectedToolchainCandidates, } from "../utils/run-configuration"; import { editorSaveFailureMessage, runEditorSaveWorkflow } from "../services/run-editor-save"; -import { createOutputStamper, trimRunOutput, type OutputStamper } from "../utils/output-timestamper"; +import { + createOutputStamper, + trimRunOutput, + type OutputStamper, +} from "../utils/output-timestamper"; const MAXIMUM_OUTPUT_CHARACTERS = 500_000; const sessionWorkspaces = new Map(); @@ -179,14 +184,11 @@ async function resolveConfigurations(root: string): Promise runtimeExecutablePaths: automaticRuntimePaths, }), ); - const globalToolchain = mapCoreToolchain( - preliminary.toolchain, - preliminary.localToolchains, - ); + const globalToolchain = mapCoreToolchain(preliminary.toolchain, preliminary.localToolchains); const hasSelectedToolchain = Boolean( globalToolchain.javaHomePath || - globalToolchain.mavenExecutablePath || - Object.values(globalToolchain.runtimeExecutablePaths).some(Boolean), + globalToolchain.mavenExecutablePath || + Object.values(globalToolchain.runtimeExecutablePaths).some(Boolean), ); const discovered = hasSelectedToolchain ? await discoverRunToolchains(root, globalToolchain) @@ -257,7 +259,7 @@ function readyRunState( }; } -export const createRunStore = () => +export const createRunStore = (workspaceId = workspaceRuntimeRegistry.getActiveWorkspaceId()) => createStore()((set, get) => ({ root: null, status: "missing", @@ -400,18 +402,20 @@ export const createRunStore = () => } const sessionId = configuration.execution === "service" ? configuration.id : PRIMARY_SESSION_ID; - bindRunSessionWorkspace(sessionId); + bindRunSessionWorkspace(sessionId, workspaceId); resetOutputStamper(sessionId); await stopRunProcess(sessionId).catch(() => undefined); try { - const plan = await createLaunchPlan(root, configuration.id, currentFile); + const mavenContext = currentMavenLaunchContext(root, workspaceId); + const plan = await createLaunchPlan(root, configuration.id, currentFile, mavenContext); const resolved = await resolveRunLaunch({ root, executable: plan.executable, workingDirectory: plan.workingDirectory, javaHomePath: configuration.javaHomePath, - mavenExecutablePath: configuration.mavenExecutablePath, - mavenJavaHomePath: configuration.mavenJavaHomePath, + mavenExecutablePath: + configuration.mavenExecutablePath || mavenContext?.mavenExecutablePath || "", + mavenJavaHomePath: configuration.mavenJavaHomePath || mavenContext?.javaHomePath || "", runtimeExecutablePaths: state.effectiveRuntimeExecutablePaths, environment: mergeLaunchEnvironment(configuration.env, plan), }); diff --git a/windows/tauri/src/features/window/stores/ui-state/types/ui-state.types.ts b/windows/tauri/src/features/window/stores/ui-state/types/ui-state.types.ts index 22fa6749..d049a451 100644 --- a/windows/tauri/src/features/window/stores/ui-state/types/ui-state.types.ts +++ b/windows/tauri/src/features/window/stores/ui-state/types/ui-state.types.ts @@ -18,6 +18,7 @@ export type BottomPaneTab = | "references" | "buffers" | "run" + | "maven" | "gitLog"; export interface QuickEditSelection { diff --git a/windows/tauri/src/i18n/locale.ts b/windows/tauri/src/i18n/locale.ts index bf878864..56da5f9d 100644 --- a/windows/tauri/src/i18n/locale.ts +++ b/windows/tauri/src/i18n/locale.ts @@ -1414,6 +1414,34 @@ const catalogs = { "run.newCustomAction": "New custom action", "run.runCell": "Run cell", "run.runChunk": "Run chunk", + "maven.title": "Maven", + "maven.project": "Project", + "maven.lifecycle": "Lifecycle", + "maven.profiles": "Profiles", + "maven.settings": "Maven Settings", + "maven.automatic": "Automatic", + "maven.mavenExecutable": "Maven home or executable", + "maven.javaHome": "Maven JDK Home", + "maven.stop": "Stop Maven task", + "maven.runSelected": "Run selected lifecycle phase", + "maven.executeGoal": "Execute Maven goal", + "maven.reloadProjects": "Reload Maven projects", + "maven.skipTests": "Skip tests", + "maven.collapseAll": "Collapse all", + "maven.clearOutput": "Clear build output", + "maven.configurationChanged": "Maven configuration changed", + "maven.reloadJdt": "Reload JDT LS", + "maven.reloadFailed": "Unable to reload the Java language server.", + "maven.loadFailed": "Unable to load Maven project", + "maven.buildOutput": "Build Output", + "maven.processOutput": "Process output", + "maven.emptyOutput": "Run a Maven lifecycle phase to see output.", + "maven.scanning": "Scanning Maven project...", + "maven.notDetected": "No Maven project detected", + "maven.addProfile": "Add Maven profile", + "maven.restoreProfiles": "Restore default profiles", + "maven.add": "Add", + "maven.profileId": "Profile ID", "runActions.editRunAction": "Edit run action", "runActions.newRunAction": "New run action", "runActions.saveChanges": "Save changes", @@ -5198,6 +5226,34 @@ const catalogs = { "run.newCustomAction": "新建自定义操作", "run.runCell": "运行单元", "run.runChunk": "运行代码块", + "maven.title": "Maven", + "maven.project": "项目", + "maven.lifecycle": "生命周期", + "maven.profiles": "Profiles", + "maven.settings": "Maven 设置", + "maven.automatic": "自动检测", + "maven.mavenExecutable": "Maven 主目录 / 可执行文件", + "maven.javaHome": "Maven JDK 主目录", + "maven.stop": "停止 Maven 任务", + "maven.runSelected": "运行选中的生命周期阶段", + "maven.executeGoal": "执行 Maven Goal", + "maven.reloadProjects": "重新加载 Maven 项目", + "maven.skipTests": "跳过测试", + "maven.collapseAll": "全部折叠", + "maven.clearOutput": "清除构建输出", + "maven.configurationChanged": "Maven 配置已更改", + "maven.reloadJdt": "重新加载 JDT LS", + "maven.reloadFailed": "无法重新加载 Java 语言服务器。", + "maven.loadFailed": "无法加载 Maven 项目", + "maven.buildOutput": "构建输出", + "maven.processOutput": "进程输出", + "maven.emptyOutput": "运行 Maven 生命周期阶段后将在这里显示输出。", + "maven.scanning": "正在扫描 Maven 项目...", + "maven.notDetected": "未检测到 Maven 项目", + "maven.addProfile": "添加 Maven Profile", + "maven.restoreProfiles": "恢复默认 Profiles", + "maven.add": "添加", + "maven.profileId": "Profile ID", "runActions.editRunAction": "编辑运行操作", "runActions.newRunAction": "新建运行操作", "runActions.saveChanges": "保存更改", diff --git a/windows/tauri/src/platform/lsp-core-adapter.ts b/windows/tauri/src/platform/lsp-core-adapter.ts index a1ae8a72..9e920f6a 100644 --- a/windows/tauri/src/platform/lsp-core-adapter.ts +++ b/windows/tauri/src/platform/lsp-core-adapter.ts @@ -689,6 +689,7 @@ async function createSession(args: JsonRecord, key: string): Promise { jdtlsLaunchResources: args.jdtlsLaunchResources ?? null, cacheDirectory: args.cacheDirectory ?? null, workspaceFingerprint: args.workspaceFingerprint ?? null, + mavenContext: args.mavenContext ?? null, initializeTimeoutMilliseconds: INITIALIZE_TIMEOUT_MS, }, operationId, diff --git a/windows/tauri/src/platform/tauri-core.ts b/windows/tauri/src/platform/tauri-core.ts index 3e9291f2..e74d192e 100644 --- a/windows/tauri/src/platform/tauri-core.ts +++ b/windows/tauri/src/platform/tauri-core.ts @@ -38,6 +38,8 @@ const nativeCommands = new Set([ "list_shells", "lsp_rebuild_java_index", "lsp_resolve_java_launch", + "maven_load_configuration", + "maven_write_configuration", "move_file", "open_log_directory", "open_file_external", From 9f0a3cbcd17498382a12970560e7240ee65c26d7 Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Sat, 29 Aug 2026 21:31:21 -0700 Subject: [PATCH 63/66] fix(windows): align Maven behavior across platforms --- windows/tauri/src-tauri/src/maven.rs | 45 +++++++++++++++++-- .../features/maven/components/maven-pane.tsx | 3 ++ .../features/maven/stores/maven.store.test.ts | 34 +++++++++++++- .../src/features/maven/stores/maven.store.ts | 27 ++++++++++- .../features/maven/types/maven.types.test.ts | 9 ++++ .../src/features/maven/types/maven.types.ts | 2 +- .../src/features/run/api/run-core-api.test.ts | 30 +++++++++++++ .../src/features/run/api/run-core-api.ts | 6 ++- .../components/run-configuration-editor.tsx | 27 +++++++++++ .../src/features/run/stores/run.store.ts | 16 +++---- .../tauri/src/features/run/types/run.types.ts | 4 ++ .../run/utils/run-configuration.test.ts | 7 ++- .../features/run/utils/run-configuration.ts | 1 + windows/tauri/src/i18n/locale.ts | 13 ++++++ 14 files changed, 206 insertions(+), 18 deletions(-) create mode 100644 windows/tauri/src/features/maven/types/maven.types.test.ts diff --git a/windows/tauri/src-tauri/src/maven.rs b/windows/tauri/src-tauri/src/maven.rs index 1ec63006..4187845f 100644 --- a/windows/tauri/src-tauri/src/maven.rs +++ b/windows/tauri/src-tauri/src/maven.rs @@ -105,14 +105,21 @@ fn local_path(app: &AppHandle, root: &Path, reactor_path: &str) -> Result String { + format!( + "{}\0{}", + workspace_path.to_lowercase(), + reactor_path.replace('\\', "/") + ) +} + fn existing_directory(path: &Path) -> Result { let root = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); if !root.is_dir() { @@ -208,4 +215,36 @@ mod tests { .unwrap_err() .contains("unsupported version")); } + + #[test] + fn windows_storage_identity_matches_shared_contract() { + let fixture: serde_json::Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../shared/fixtures/maven/platform-contract-v1.json" + ))) + .expect("Maven platform contract fixture"); + let cases = fixture["storageIdentityCases"] + .as_array() + .expect("storage identity cases"); + let windows_cases: Vec<_> = cases + .iter() + .filter(|item| item["platform"] == "windows") + .collect(); + + assert!( + !windows_cases.is_empty(), + "Windows fixture case is required" + ); + for item in windows_cases { + assert_eq!( + storage_identity( + item["workspacePath"].as_str().expect("workspace path"), + item["reactorPath"].as_str().expect("reactor path"), + ), + item["expectedIdentity"] + .as_str() + .expect("expected identity") + ); + } + } } diff --git a/windows/tauri/src/features/maven/components/maven-pane.tsx b/windows/tauri/src/features/maven/components/maven-pane.tsx index 5e1aa3a8..be2242ea 100644 --- a/windows/tauri/src/features/maven/components/maven-pane.tsx +++ b/windows/tauri/src/features/maven/components/maven-pane.tsx @@ -396,6 +396,9 @@ export default function MavenPane() { {runningTitle} ) : null} + {taskStatus === "cancelled" ? ( + {t("maven.cancelled")} + ) : null} {!isRunning && lastExitCode != null ? ( { await run; expect(startMavenProcess).not.toHaveBeenCalled(); - expect(store.getState().taskStatus).toBe("idle"); + expect(store.getState().taskStatus).toBe("cancelled"); expect(store.getState().activeSessionId).toBeNull(); + expect(store.getState().output).toBe("Maven task cancelled.\n"); + + store.getState().actions.clearOutput(); + + expect(store.getState().taskStatus).toBe("idle"); + expect(store.getState().output).toBe(""); + }); + + test("keeps cancellation when process exit arrives before stop completes", async () => { + const stopFinished = deferred(); + stopMavenProcess.mockImplementationOnce(() => stopFinished.promise); + const store = createMavenStore("workspace", dependencies); + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + await store.getState().actions.runGoals(["compile"], null, "compile"); + const sessionId = store.getState().activeSessionId; + expect(sessionId).not.toBeNull(); + + const stop = store.getState().actions.stop(); + try { + expect(store.getState().taskStatus).toBe("stopping"); + store.getState().actions.finishProcess(sessionId!, 143); + expect(store.getState().taskStatus).toBe("cancelled"); + + stopFinished.resolve(undefined); + await stop; + + expect(store.getState().output.match(/Maven task cancelled\./g)).toHaveLength(1); + expect(store.getState().lastExitCode).toBeNull(); + } finally { + stopFinished.resolve(undefined); + await stop; + } }); test("does not let diagnostics from a completed task replace a newer run", async () => { diff --git a/windows/tauri/src/features/maven/stores/maven.store.ts b/windows/tauri/src/features/maven/stores/maven.store.ts index 39387650..efb81b9c 100644 --- a/windows/tauri/src/features/maven/stores/maven.store.ts +++ b/windows/tauri/src/features/maven/stores/maven.store.ts @@ -168,6 +168,11 @@ function trimOutput(output: string): string { : normalized; } +function cancelledOutput(output: string): string { + const separator = output && !output.endsWith("\n") ? "\n" : ""; + return trimOutput(`${output}${separator}Maven task cancelled.\n`); +} + export const createMavenStore = ( workspaceId = workspaceRuntimeRegistry.getActiveWorkspaceId(), dependencies: MavenStoreDependencies = defaultMavenStoreDependencies, @@ -455,11 +460,12 @@ export const createMavenStore = ( await dependencies.stopMavenProcess(sessionId); if (get().activeSessionId === sessionId) { set({ - taskStatus: "idle", + taskStatus: "cancelled", taskError: null, activeSessionId: null, runningTitle: null, lastExitCode: null, + output: cancelledOutput(get().output), }); } } catch (error) { @@ -477,7 +483,12 @@ export const createMavenStore = ( clearOutput: () => { diagnosticsRevision += 1; - set({ output: "", issues: [], lastExitCode: null }); + set((state) => ({ + output: "", + issues: [], + lastExitCode: null, + taskStatus: state.taskStatus === "cancelled" ? "idle" : state.taskStatus, + })); }, appendOutput: (sessionId, chunk) => { @@ -491,6 +502,18 @@ export const createMavenStore = ( const root = state.root; const output = state.output; const revision = ++diagnosticsRevision; + if (state.taskStatus === "stopping") { + set({ + taskStatus: "cancelled", + taskError: null, + activeSessionId: null, + runningTitle: null, + lastExitCode: null, + output: cancelledOutput(output), + }); + releaseMavenSessionWorkspace(sessionId); + return; + } set({ taskStatus: exitCode === 0 ? "idle" : "failed", taskError: exitCode === 0 ? null : `Maven exited with code ${exitCode}.`, diff --git a/windows/tauri/src/features/maven/types/maven.types.test.ts b/windows/tauri/src/features/maven/types/maven.types.test.ts new file mode 100644 index 00000000..ad882018 --- /dev/null +++ b/windows/tauri/src/features/maven/types/maven.types.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, test } from "bun:test"; +import platformContract from "../../../../../../shared/fixtures/maven/platform-contract-v1.json"; +import { MAVEN_LIFECYCLE_PHASES } from "./maven.types"; + +describe("Maven platform contract", () => { + test("keeps the Windows lifecycle phases aligned with the shared fixture", () => { + expect(platformContract.lifecyclePhases).toEqual([...MAVEN_LIFECYCLE_PHASES]); + }); +}); diff --git a/windows/tauri/src/features/maven/types/maven.types.ts b/windows/tauri/src/features/maven/types/maven.types.ts index f2f6076b..b4506de5 100644 --- a/windows/tauri/src/features/maven/types/maven.types.ts +++ b/windows/tauri/src/features/maven/types/maven.types.ts @@ -1,5 +1,5 @@ export type MavenProjectStatus = "idle" | "loading" | "ready" | "failed"; -export type MavenTaskStatus = "idle" | "running" | "stopping" | "failed"; +export type MavenTaskStatus = "idle" | "running" | "stopping" | "failed" | "cancelled"; export interface MavenProfile { id: string; diff --git a/windows/tauri/src/features/run/api/run-core-api.test.ts b/windows/tauri/src/features/run/api/run-core-api.test.ts index aa4fb848..456e3139 100644 --- a/windows/tauri/src/features/run/api/run-core-api.test.ts +++ b/windows/tauri/src/features/run/api/run-core-api.test.ts @@ -57,6 +57,7 @@ describe("saveRunConfigurationEditorChanges", () => { javaHomePath: "D:\\fixture\\project\\toolchains\\jdk", mavenExecutablePath: "D:/fixture/project/toolchains/maven/bin/mvn.cmd", mavenJavaHomePath: "D:/fixture/project/toolchains/maven-jdk", + mavenSkipTests: false, workingDirectoryPath: "D:/fixture/project/app", vmArguments: "-Xmx2g", programArguments: "--dev", @@ -77,6 +78,7 @@ describe("saveRunConfigurationEditorChanges", () => { arguments: "--dev", environment: { APP_ENV: "dev" }, mavenProfiles: [], + mavenSkipTests: false, javaHomePath: "toolchains/jdk", mavenExecutablePath: "toolchains/maven/bin/mvn.cmd", mavenJavaHomePath: "toolchains/maven-jdk", @@ -114,6 +116,34 @@ describe("saveRunConfigurationEditorChanges", () => { ); }); + test("uses empty working directory and null test override to inherit project defaults", async () => { + await saveRunConfigurationEditorChanges( + "D:/fixture/project", + "spring", + "project", + { + javaHomePath: "", + mavenExecutablePath: "", + mavenJavaHomePath: "", + mavenSkipTests: null, + workingDirectoryPath: "", + vmArguments: "", + programArguments: "", + environment: {}, + }, + emptyToolchain, + ); + + expect(executeCore).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + workingDirectory: "", + mavenSkipTests: null, + }), + }), + ); + }); + test("rejects a project path outside the workspace", () => { expect(() => saveRunConfigurationEditorChanges( diff --git a/windows/tauri/src/features/run/api/run-core-api.ts b/windows/tauri/src/features/run/api/run-core-api.ts index c38a9e8d..eb7d85ec 100644 --- a/windows/tauri/src/features/run/api/run-core-api.ts +++ b/windows/tauri/src/features/run/api/run-core-api.ts @@ -95,8 +95,9 @@ export function saveRunConfigurationEditorChanges( } function scopedRunOptions(root: string, scope: RunSaveScope, options: RunOptions) { - const workingDirectory = - scope === "project" + const workingDirectory = !options.workingDirectoryPath.trim() + ? "" + : scope === "project" ? projectScopedPath(root, options.workingDirectoryPath) : options.workingDirectoryPath; const scopedToolchainPath = (value: string) => { @@ -120,6 +121,7 @@ function scopedRunOptions(root: string, scope: RunSaveScope, options: RunOptions arguments: options.programArguments, environment: options.environment, mavenProfiles: [], + mavenSkipTests: options.mavenSkipTests ?? null, javaHomePath, mavenExecutablePath, mavenJavaHomePath, diff --git a/windows/tauri/src/features/run/components/run-configuration-editor.tsx b/windows/tauri/src/features/run/components/run-configuration-editor.tsx index a2264c73..c8b01859 100644 --- a/windows/tauri/src/features/run/components/run-configuration-editor.tsx +++ b/windows/tauri/src/features/run/components/run-configuration-editor.tsx @@ -353,6 +353,33 @@ export function RunConfigurationEditor({ onSelect={(value) => setDraft((current) => ({ ...current, mavenJavaHomePath: value }))} onPick={() => pickDirectory("mavenJavaHomePath")} /> + + {t("run.mavenTests")} + + setDraft((current) => ({ + ...current, + mavenSkipTests: + event.target.value === "inherit" ? null : event.target.value === "skip", + })) + } + > + + {t("run.mavenTestsProjectDefault")} + + {t("run.mavenTestsRun")} + {t("run.mavenTestsSkip")} + + {t("run.mavenTestsHint")} + ) : null} diff --git a/windows/tauri/src/features/run/stores/run.store.ts b/windows/tauri/src/features/run/stores/run.store.ts index 3f3cbeec..4125d165 100644 --- a/windows/tauri/src/features/run/stores/run.store.ts +++ b/windows/tauri/src/features/run/stores/run.store.ts @@ -49,11 +49,7 @@ import { selectedToolchainCandidates, } from "../utils/run-configuration"; import { editorSaveFailureMessage, runEditorSaveWorkflow } from "../services/run-editor-save"; -import { - createOutputStamper, - trimRunOutput, - type OutputStamper, -} from "../utils/output-timestamper"; +import { createOutputStamper, trimRunOutput, type OutputStamper } from "../utils/output-timestamper"; const MAXIMUM_OUTPUT_CHARACTERS = 500_000; const sessionWorkspaces = new Map(); @@ -167,6 +163,7 @@ function optionsFromConfiguration(configuration: RunConfiguration): RunOptions { javaHomePath: configuration.javaHomePath, mavenExecutablePath: configuration.mavenExecutablePath, mavenJavaHomePath: configuration.mavenJavaHomePath, + mavenSkipTests: configuration.mavenSkipTests, workingDirectoryPath: configuration.cwd, vmArguments: configuration.jvmArguments.join(" "), programArguments: configuration.programArguments.join(" "), @@ -184,11 +181,14 @@ async function resolveConfigurations(root: string): Promise runtimeExecutablePaths: automaticRuntimePaths, }), ); - const globalToolchain = mapCoreToolchain(preliminary.toolchain, preliminary.localToolchains); + const globalToolchain = mapCoreToolchain( + preliminary.toolchain, + preliminary.localToolchains, + ); const hasSelectedToolchain = Boolean( globalToolchain.javaHomePath || - globalToolchain.mavenExecutablePath || - Object.values(globalToolchain.runtimeExecutablePaths).some(Boolean), + globalToolchain.mavenExecutablePath || + Object.values(globalToolchain.runtimeExecutablePaths).some(Boolean), ); const discovered = hasSelectedToolchain ? await discoverRunToolchains(root, globalToolchain) diff --git a/windows/tauri/src/features/run/types/run.types.ts b/windows/tauri/src/features/run/types/run.types.ts index f6bc4af4..9f31ad7c 100644 --- a/windows/tauri/src/features/run/types/run.types.ts +++ b/windows/tauri/src/features/run/types/run.types.ts @@ -30,6 +30,7 @@ export interface RunConfiguration { jvmArguments: string[]; programArguments: string[]; profiles: string[]; + mavenSkipTests: boolean | null; javaHomePath: string; mavenExecutablePath: string; mavenJavaHomePath: string; @@ -42,6 +43,7 @@ export interface RunOptions { javaHomePath: string; mavenExecutablePath: string; mavenJavaHomePath: string; + mavenSkipTests?: boolean | null; workingDirectoryPath: string; vmArguments: string; programArguments: string; @@ -142,6 +144,7 @@ export interface CoreResolvedConfiguration { jvmArguments?: string[]; programArguments?: string[]; profiles?: string[]; + skipTests?: boolean; }; java?: { homePath?: string; @@ -158,6 +161,7 @@ export const EMPTY_RUN_OPTIONS: RunOptions = { javaHomePath: "", mavenExecutablePath: "", mavenJavaHomePath: "", + mavenSkipTests: null, workingDirectoryPath: "", vmArguments: "", programArguments: "", diff --git a/windows/tauri/src/features/run/utils/run-configuration.test.ts b/windows/tauri/src/features/run/utils/run-configuration.test.ts index 86d2ce9b..df002f17 100644 --- a/windows/tauri/src/features/run/utils/run-configuration.test.ts +++ b/windows/tauri/src/features/run/utils/run-configuration.test.ts @@ -23,7 +23,11 @@ describe("run configuration mapping", () => { execution: "service", source: "generated", extensions: { - maven: { module: ".", mainClass: "com.example.demo.DemoApplication" }, + maven: { + module: ".", + mainClass: "com.example.demo.DemoApplication", + skipTests: false, + }, }, }); @@ -31,6 +35,7 @@ describe("run configuration mapping", () => { expect(configuration.execution).toBe("service"); expect(configuration.mainClass).toBe("com.example.demo.DemoApplication"); expect(configuration.modulePath).toBeUndefined(); + expect(configuration.mavenSkipTests).toBe(false); }); test("groups runnable configurations and hides Current File", () => { diff --git a/windows/tauri/src/features/run/utils/run-configuration.ts b/windows/tauri/src/features/run/utils/run-configuration.ts index 6f4bc3f2..db9bfc10 100644 --- a/windows/tauri/src/features/run/utils/run-configuration.ts +++ b/windows/tauri/src/features/run/utils/run-configuration.ts @@ -42,6 +42,7 @@ export function mapCoreConfiguration(value: CoreResolvedConfiguration): RunConfi jvmArguments: maven?.jvmArguments ?? [], programArguments: maven?.programArguments ?? value.args ?? [], profiles: maven?.profiles ?? [], + mavenSkipTests: maven?.skipTests ?? null, javaHomePath: java?.homePath ?? "", mavenExecutablePath: java?.mavenExecutablePath ?? "", mavenJavaHomePath: java?.mavenJavaHomePath ?? "", diff --git a/windows/tauri/src/i18n/locale.ts b/windows/tauri/src/i18n/locale.ts index 56da5f9d..5964e6c7 100644 --- a/windows/tauri/src/i18n/locale.ts +++ b/windows/tauri/src/i18n/locale.ts @@ -1379,6 +1379,12 @@ const catalogs = { "run.nodeExecutableHint": "Choose node.exe, or leave empty to use the detected Node.js runtime.", "run.mavenJdkHome": "Maven JDK Home", "run.mavenJdkHomeHint": "Leave empty to use the same JDK as the application.", + "run.mavenTests": "Maven tests", + "run.mavenTestsProjectDefault": "Use project default", + "run.mavenTestsRun": "Run tests", + "run.mavenTestsSkip": "Skip tests", + "run.mavenTestsHint": + "Override the Maven tool window's Skip Tests setting for this run configuration.", "run.toolchainAuto": "Auto-detect (leave empty)", "run.toolchainCurrent": "Current path", "run.runtimeSection": "Runtime (this PC)", @@ -1423,6 +1429,7 @@ const catalogs = { "maven.mavenExecutable": "Maven home or executable", "maven.javaHome": "Maven JDK Home", "maven.stop": "Stop Maven task", + "maven.cancelled": "Cancelled", "maven.runSelected": "Run selected lifecycle phase", "maven.executeGoal": "Execute Maven goal", "maven.reloadProjects": "Reload Maven projects", @@ -5192,6 +5199,11 @@ const catalogs = { "run.nodeExecutableHint": "可选择 node.exe;留空则使用自动检测到的 Node.js 运行时。", "run.mavenJdkHome": "Maven JDK 主目录", "run.mavenJdkHomeHint": "留空则与应用使用同一个 JDK。", + "run.mavenTests": "Maven 测试", + "run.mavenTestsProjectDefault": "使用项目默认值", + "run.mavenTestsRun": "运行测试", + "run.mavenTestsSkip": "跳过测试", + "run.mavenTestsHint": "为当前运行配置覆盖 Maven 工具窗口中的“跳过测试”设置。", "run.toolchainAuto": "自动检测(留空)", "run.toolchainCurrent": "当前路径", "run.runtimeSection": "运行环境(本机)", @@ -5235,6 +5247,7 @@ const catalogs = { "maven.mavenExecutable": "Maven 主目录 / 可执行文件", "maven.javaHome": "Maven JDK 主目录", "maven.stop": "停止 Maven 任务", + "maven.cancelled": "已取消", "maven.runSelected": "运行选中的生命周期阶段", "maven.executeGoal": "执行 Maven Goal", "maven.reloadProjects": "重新加载 Maven 项目", From 14d76ce01b681bf8580c4dd04f37212b2fe0583d Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Sun, 30 Aug 2026 11:19:39 +0800 Subject: [PATCH 64/66] fix(windows): await Maven context before Run launch Wait for the workspace Maven load before planning Maven-backed Run configurations so project profiles, settings, skip-tests, and toolchain paths are applied consistently. Add a deterministic regression test for the pending-load race. Refs #291 --- .../run/stores/run-maven-context.test.ts | 111 ++++++++++++++++++ .../src/features/run/stores/run.store.ts | 41 +++++-- 2 files changed, 145 insertions(+), 7 deletions(-) create mode 100644 windows/tauri/src/features/run/stores/run-maven-context.test.ts diff --git a/windows/tauri/src/features/run/stores/run-maven-context.test.ts b/windows/tauri/src/features/run/stores/run-maven-context.test.ts new file mode 100644 index 00000000..c300887e --- /dev/null +++ b/windows/tauri/src/features/run/stores/run-maven-context.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, mock, test } from "bun:test"; +import type { MavenLaunchContext } from "@/features/maven/types/maven.types"; +import type { RunConfiguration } from "../types/run.types"; +import { createRunStore, type RunStoreDependencies } from "./run.store"; + +type Deferred = { + promise: Promise; + resolve: (value: T) => void; +}; + +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +const mavenContext: MavenLaunchContext = { + version: 1, + reactorPath: "reactor", + profiles: ["dev"], + settingsPath: "C:/Users/example/.m2/settings.xml", + skipTests: true, + mavenExecutablePath: "D:/Tools/apache-maven", + javaHomePath: "C:/Java/jdk-21", +}; + +const configuration: RunConfiguration = { + id: "spring", + name: "Spring Boot", + provider: "spring-boot.maven", + kindTitle: "Spring Boot", + execution: "service", + cwd: "", + args: [], + env: {}, + jvmArguments: [], + programArguments: [], + profiles: [], + mavenSkipTests: null, + javaHomePath: "", + mavenExecutablePath: "", + mavenJavaHomePath: "", + toolchains: { java: "project-jdk", maven: "project-maven" }, + source: "generated", + disabled: false, +}; + +describe("Maven-backed Run context", () => { + test("waits for the workspace Maven load before creating the launch plan", async () => { + const pendingContext = deferred(); + const events: string[] = []; + const createLaunchPlan = mock( + async (...args: Parameters) => { + events.push("plan-created"); + expect(args[3]).toEqual(mavenContext); + return { + executable: { toolchain: "project-maven" }, + arguments: ["-B", "spring-boot:run"], + workingDirectory: "reactor", + }; + }, + ); + const mavenLaunchContextForWorkspace = mock(async () => { + events.push("context-started"); + return pendingContext.promise; + }); + const resolveRunLaunch = mock(async () => ({ + executable: "D:/Tools/apache-maven/bin/mvn.cmd", + workingDirectory: "D:/work/reactor", + environment: {}, + })); + const startRunProcess = mock(async () => undefined); + const stopRunProcess = mock(async () => undefined); + const dependencies: RunStoreDependencies = { + createLaunchPlan, + mavenLaunchContextForWorkspace, + resolveRunLaunch, + startRunProcess, + stopRunProcess, + }; + const store = createRunStore("workspace", dependencies); + store.setState({ + root: "D:/work", + configurations: [configuration], + diagnostics: [], + effectiveRuntimeExecutablePaths: {}, + }); + + const run = store.getState().actions.runConfiguration(configuration.id); + try { + await Promise.resolve(); + await Promise.resolve(); + expect(events).toEqual(["context-started"]); + expect(createLaunchPlan).not.toHaveBeenCalled(); + } finally { + pendingContext.resolve(mavenContext); + await run; + } + + expect(mavenLaunchContextForWorkspace).toHaveBeenCalledWith("D:/work", [], "workspace"); + expect(createLaunchPlan).toHaveBeenCalledWith("D:/work", "spring", undefined, mavenContext); + expect(resolveRunLaunch).toHaveBeenCalledWith( + expect.objectContaining({ + mavenExecutablePath: "D:/Tools/apache-maven", + mavenJavaHomePath: "C:/Java/jdk-21", + }), + ); + }); +}); diff --git a/windows/tauri/src/features/run/stores/run.store.ts b/windows/tauri/src/features/run/stores/run.store.ts index 4125d165..f352c2d8 100644 --- a/windows/tauri/src/features/run/stores/run.store.ts +++ b/windows/tauri/src/features/run/stores/run.store.ts @@ -1,7 +1,7 @@ import { createStore } from "zustand/vanilla"; import { createWorkspaceScopedStore } from "@/features/workspace/stores/create-workspace-scoped-store"; import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; -import { currentMavenLaunchContext } from "@/features/maven/stores/maven.store"; +import { mavenLaunchContextForWorkspace } from "@/features/maven/stores/maven.store"; import { createLaunchPlan, generateRunConfiguration, @@ -47,6 +47,7 @@ import { recoveryActionForError, recoveryPathFromMessage, selectedToolchainCandidates, + configurationUsesMaven, } from "../utils/run-configuration"; import { editorSaveFailureMessage, runEditorSaveWorkflow } from "../services/run-editor-save"; import { createOutputStamper, trimRunOutput, type OutputStamper } from "../utils/output-timestamper"; @@ -100,6 +101,22 @@ interface RunState { }; } +export interface RunStoreDependencies { + createLaunchPlan: typeof createLaunchPlan; + mavenLaunchContextForWorkspace: typeof mavenLaunchContextForWorkspace; + resolveRunLaunch: typeof resolveRunLaunch; + startRunProcess: typeof startRunProcess; + stopRunProcess: typeof stopRunProcess; +} + +const defaultRunStoreDependencies: RunStoreDependencies = { + createLaunchPlan, + mavenLaunchContextForWorkspace, + resolveRunLaunch, + startRunProcess, + stopRunProcess, +}; + interface ResolvedRunProject { configurations: RunConfiguration[]; diagnostics: RunDiagnostic[]; @@ -259,7 +276,10 @@ function readyRunState( }; } -export const createRunStore = (workspaceId = workspaceRuntimeRegistry.getActiveWorkspaceId()) => +export const createRunStore = ( + workspaceId = workspaceRuntimeRegistry.getActiveWorkspaceId(), + dependencies: RunStoreDependencies = defaultRunStoreDependencies, +) => createStore()((set, get) => ({ root: null, status: "missing", @@ -404,11 +424,18 @@ export const createRunStore = (workspaceId = workspaceRuntimeRegistry.getActiveW configuration.execution === "service" ? configuration.id : PRIMARY_SESSION_ID; bindRunSessionWorkspace(sessionId, workspaceId); resetOutputStamper(sessionId); - await stopRunProcess(sessionId).catch(() => undefined); + await dependencies.stopRunProcess(sessionId).catch(() => undefined); try { - const mavenContext = currentMavenLaunchContext(root, workspaceId); - const plan = await createLaunchPlan(root, configuration.id, currentFile, mavenContext); - const resolved = await resolveRunLaunch({ + const mavenContext = configurationUsesMaven(configuration) + ? await dependencies.mavenLaunchContextForWorkspace(root, [], workspaceId) + : null; + const plan = await dependencies.createLaunchPlan( + root, + configuration.id, + currentFile, + mavenContext, + ); + const resolved = await dependencies.resolveRunLaunch({ root, executable: plan.executable, workingDirectory: plan.workingDirectory, @@ -444,7 +471,7 @@ export const createRunStore = (workspaceId = workspaceRuntimeRegistry.getActiveW ], })); } - await startRunProcess({ + await dependencies.startRunProcess({ sessionId, executable: resolved.executable, arguments: plan.arguments, From 459e7c1db0175868e6148e20b6fec89d841da54c Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Sun, 30 Aug 2026 12:32:02 +0800 Subject: [PATCH 65/66] fix(windows): save workspace before build launches Await active and pending editor saves before Maven goals or Run configurations create their launch plans. Surface actionable failures and cover save ordering, failures, and the in-flight auto-save race. --- .../services/save-workspace-before-launch.ts | 56 ++++++++++++++ .../editor/stores/editor-app.store.test.ts | 75 ++++++++++++++++++- .../features/maven/stores/maven.store.test.ts | 39 ++++++++++ .../src/features/maven/stores/maven.store.ts | 4 + .../run/stores/run-maven-context.test.ts | 51 ++++++++++++- .../src/features/run/stores/run.store.ts | 37 ++++++--- 6 files changed, 248 insertions(+), 14 deletions(-) create mode 100644 windows/tauri/src/features/editor/services/save-workspace-before-launch.ts diff --git a/windows/tauri/src/features/editor/services/save-workspace-before-launch.ts b/windows/tauri/src/features/editor/services/save-workspace-before-launch.ts new file mode 100644 index 00000000..c3608f6a --- /dev/null +++ b/windows/tauri/src/features/editor/services/save-workspace-before-launch.ts @@ -0,0 +1,56 @@ +import { isEditorContent } from "@/features/panes/types/pane-content.types"; +import { useBufferStore } from "../stores/buffer.store"; +import { useEditorAppStore } from "../stores/editor-app.store"; + +function hasActiveWritableSave(workspaceId: string): boolean { + return useBufferStore + .getStore(workspaceId) + .getState() + .buffers.some( + (buffer) => + isEditorContent(buffer) && + !buffer.readOnly && + buffer.documentLifecycle?.status === "saving", + ); +} + +async function waitForActiveWorkspaceSaves(workspaceId: string): Promise { + if (!hasActiveWritableSave(workspaceId)) return; + + const bufferStore = useBufferStore.getStore(workspaceId); + await new Promise((resolve) => { + let unsubscribe = () => {}; + const resolveWhenIdle = () => { + if (hasActiveWritableSave(workspaceId)) return; + unsubscribe(); + resolve(); + }; + unsubscribe = bufferStore.subscribe(resolveWhenIdle); + resolveWhenIdle(); + }); +} + +function unsavedWritableBufferNames(workspaceId: string): string[] { + const names = useBufferStore + .getStore(workspaceId) + .getState() + .buffers.filter( + (buffer) => isEditorContent(buffer) && buffer.isDirty && !buffer.readOnly, + ) + .map((buffer) => buffer.name); + return [...new Set(names)].sort(); +} + +export async function saveWorkspaceBeforeLaunch(workspaceId: string): Promise { + while (true) { + await waitForActiveWorkspaceSaves(workspaceId); + await useEditorAppStore.getStore(workspaceId).getState().actions.handleSaveAll(); + const unsavedNames = unsavedWritableBufferNames(workspaceId); + if (unsavedNames.length === 0) return; + if (hasActiveWritableSave(workspaceId)) continue; + + throw new Error( + `Unable to start because modified files could not be saved: ${unsavedNames.join(", ")}.`, + ); + } +} diff --git a/windows/tauri/src/features/editor/stores/editor-app.store.test.ts b/windows/tauri/src/features/editor/stores/editor-app.store.test.ts index 926c12b2..b39d3543 100644 --- a/windows/tauri/src/features/editor/stores/editor-app.store.test.ts +++ b/windows/tauri/src/features/editor/stores/editor-app.store.test.ts @@ -1,7 +1,8 @@ -import { beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { toast } from "sonner"; import type { EditorContent } from "@/features/panes/types/pane-content.types"; import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { saveWorkspaceBeforeLaunch } from "../services/save-workspace-before-launch"; import { getBufferById } from "../utils/buffer-index"; import { useBufferStore } from "./buffer.store"; import { useEditorAppStore } from "./editor-app.store"; @@ -54,6 +55,11 @@ beforeEach(() => { workspaceRuntimeRegistry.ensureWorkspace({ id: WORKSPACE_B, name: "Workspace B" }, "ready"); }); +afterEach(() => { + useEditorAppStore.getStore(WORKSPACE_A).getState().actions.cleanup(); + useEditorAppStore.getStore(WORKSPACE_B).getState().actions.cleanup(); +}); + describe("workspace-scoped editor actions", () => { test("routes content changes to the source workspace", async () => { setWorkspaceBuffers( @@ -178,4 +184,71 @@ describe("workspace-scoped editor actions", () => { expectedParseError.mockRestore(); } }); + + test("saves only the target workspace before an external launch", async () => { + setWorkspaceBuffers(WORKSPACE_A, [editorBuffer("a", "A edited", { isDirty: true })], "a"); + setWorkspaceBuffers(WORKSPACE_B, [editorBuffer("b", "B edited", { isDirty: true })], "b"); + + await saveWorkspaceBeforeLaunch(WORKSPACE_A); + + expect(getEditorBuffer(WORKSPACE_A, "a").isDirty).toBe(false); + expect(getEditorBuffer(WORKSPACE_B, "b").isDirty).toBe(true); + }); + + test("waits for an active auto-save before checking external launch readiness", async () => { + setWorkspaceBuffers(WORKSPACE_A, [editorBuffer("a", "A edited", { isDirty: true })], "a"); + const bufferActions = useBufferStore.getStore(WORKSPACE_A).getState().actions; + bufferActions.applyDocumentLifecycle("a", { + status: "saving", + revision: 1, + savedRevision: 0, + saveRevision: 1, + operationId: "auto-save-a", + }); + + const launchSaveState: { value: "pending" | "resolved" | "rejected" } = { + value: "pending", + }; + const launchSave = saveWorkspaceBeforeLaunch(WORKSPACE_A).then( + () => { + launchSaveState.value = "resolved"; + }, + () => { + launchSaveState.value = "rejected"; + }, + ); + await Promise.resolve(); + expect(launchSaveState.value).toBe("pending"); + + bufferActions.recordSuccessfulBufferSave("a", "A edited", { + status: "clean", + revision: 1, + }); + await launchSave; + + expect(launchSaveState.value).toBe("resolved"); + }, 1_000); + + test("rejects an external launch when a workspace file remains unsaved", async () => { + const saveFailureToast = spyOn(toast, "error").mockImplementation(() => "test-toast"); + const expectedParseError = spyOn(console, "error").mockImplementation(() => undefined); + setWorkspaceBuffers( + WORKSPACE_A, + [ + editorBuffer("settings", "not valid json", { + isDirty: true, + path: "settings://user-settings.json", + }), + ], + "settings", + ); + + try { + await expect(saveWorkspaceBeforeLaunch(WORKSPACE_A)).rejects.toThrow("settings.txt"); + expect(getEditorBuffer(WORKSPACE_A, "settings").isDirty).toBe(true); + } finally { + saveFailureToast.mockRestore(); + expectedParseError.mockRestore(); + } + }); }); diff --git a/windows/tauri/src/features/maven/stores/maven.store.test.ts b/windows/tauri/src/features/maven/stores/maven.store.test.ts index 740b173f..ab008831 100644 --- a/windows/tauri/src/features/maven/stores/maven.store.test.ts +++ b/windows/tauri/src/features/maven/stores/maven.store.test.ts @@ -60,6 +60,7 @@ const resolveMavenLaunch = mock(async () => ({ workingDirectory: "D:/work/reactor", environment: {}, })); +const saveWorkspaceBeforeLaunch = mock(async (_workspaceId: string): Promise => undefined); const startMavenProcess = mock(async () => undefined); const stopMavenProcess = mock(async () => undefined); @@ -68,6 +69,7 @@ const dependencies = { loadMavenConfiguration, parseMavenDiagnostics, resolveMavenLaunch, + saveWorkspaceBeforeLaunch, scanMavenProject, startMavenProcess, stopMavenProcess, @@ -85,6 +87,8 @@ beforeEach(() => { parseMavenDiagnostics.mockReset(); parseMavenDiagnostics.mockResolvedValue([]); resolveMavenLaunch.mockClear(); + saveWorkspaceBeforeLaunch.mockReset(); + saveWorkspaceBeforeLaunch.mockResolvedValue(undefined); startMavenProcess.mockClear(); stopMavenProcess.mockClear(); }); @@ -264,6 +268,41 @@ describe("Maven workspace state", () => { expect(store.getState().output).toBe(""); }); + test("waits for workspace files to save before creating a launch plan", async () => { + const pendingSave = deferred(); + saveWorkspaceBeforeLaunch.mockImplementationOnce(() => pendingSave.promise); + const store = createMavenStore("workspace", dependencies); + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + + const run = store.getState().actions.runGoals(["compile"], null, "compile"); + try { + await Promise.resolve(); + expect(saveWorkspaceBeforeLaunch).toHaveBeenCalledWith("workspace"); + expect(createMavenLaunchPlan).not.toHaveBeenCalled(); + } finally { + pendingSave.resolve(undefined); + await run; + } + + expect(createMavenLaunchPlan).toHaveBeenCalledTimes(1); + expect(startMavenProcess).toHaveBeenCalledTimes(1); + }); + + test("does not launch Maven when workspace files cannot be saved", async () => { + saveWorkspaceBeforeLaunch.mockRejectedValueOnce( + new Error("Unable to start because modified files could not be saved: App.java."), + ); + const store = createMavenStore("workspace", dependencies); + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + + await store.getState().actions.runGoals(["compile"], null, "compile"); + + expect(createMavenLaunchPlan).not.toHaveBeenCalled(); + expect(startMavenProcess).not.toHaveBeenCalled(); + expect(store.getState().taskStatus).toBe("failed"); + expect(store.getState().taskError).toContain("App.java"); + }); + test("keeps cancellation when process exit arrives before stop completes", async () => { const stopFinished = deferred(); stopMavenProcess.mockImplementationOnce(() => stopFinished.promise); diff --git a/windows/tauri/src/features/maven/stores/maven.store.ts b/windows/tauri/src/features/maven/stores/maven.store.ts index efb81b9c..1f461401 100644 --- a/windows/tauri/src/features/maven/stores/maven.store.ts +++ b/windows/tauri/src/features/maven/stores/maven.store.ts @@ -1,4 +1,5 @@ import { createStore } from "zustand/vanilla"; +import { saveWorkspaceBeforeLaunch } from "@/features/editor/services/save-workspace-before-launch"; import { createWorkspaceScopedStore } from "@/features/workspace/stores/create-workspace-scoped-store"; import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; import { @@ -41,6 +42,7 @@ export interface MavenStoreDependencies { loadMavenConfiguration: typeof loadMavenConfiguration; parseMavenDiagnostics: typeof parseMavenDiagnostics; resolveMavenLaunch: typeof resolveMavenLaunch; + saveWorkspaceBeforeLaunch: typeof saveWorkspaceBeforeLaunch; scanMavenProject: typeof scanMavenProject; startMavenProcess: typeof startMavenProcess; stopMavenProcess: typeof stopMavenProcess; @@ -52,6 +54,7 @@ const defaultMavenStoreDependencies: MavenStoreDependencies = { loadMavenConfiguration, parseMavenDiagnostics, resolveMavenLaunch, + saveWorkspaceBeforeLaunch, scanMavenProject, startMavenProcess, stopMavenProcess, @@ -406,6 +409,7 @@ export const createMavenStore = ( lastExitCode: null, }); try { + await dependencies.saveWorkspaceBeforeLaunch(workspaceId); const plan = await dependencies.createMavenLaunchPlan( state.root, context, diff --git a/windows/tauri/src/features/run/stores/run-maven-context.test.ts b/windows/tauri/src/features/run/stores/run-maven-context.test.ts index c300887e..532645a6 100644 --- a/windows/tauri/src/features/run/stores/run-maven-context.test.ts +++ b/windows/tauri/src/features/run/stores/run-maven-context.test.ts @@ -71,12 +71,16 @@ describe("Maven-backed Run context", () => { workingDirectory: "D:/work/reactor", environment: {}, })); + const saveWorkspaceBeforeLaunch = mock(async () => { + events.push("files-saved"); + }); const startRunProcess = mock(async () => undefined); const stopRunProcess = mock(async () => undefined); const dependencies: RunStoreDependencies = { createLaunchPlan, mavenLaunchContextForWorkspace, resolveRunLaunch, + saveWorkspaceBeforeLaunch, startRunProcess, stopRunProcess, }; @@ -92,13 +96,15 @@ describe("Maven-backed Run context", () => { try { await Promise.resolve(); await Promise.resolve(); - expect(events).toEqual(["context-started"]); + await Promise.resolve(); + expect(events).toEqual(["files-saved", "context-started"]); expect(createLaunchPlan).not.toHaveBeenCalled(); } finally { pendingContext.resolve(mavenContext); await run; } + expect(saveWorkspaceBeforeLaunch).toHaveBeenCalledWith("workspace"); expect(mavenLaunchContextForWorkspace).toHaveBeenCalledWith("D:/work", [], "workspace"); expect(createLaunchPlan).toHaveBeenCalledWith("D:/work", "spring", undefined, mavenContext); expect(resolveRunLaunch).toHaveBeenCalledWith( @@ -108,4 +114,47 @@ describe("Maven-backed Run context", () => { }), ); }); + + test("does not create a launch plan when workspace files cannot be saved", async () => { + const createLaunchPlan = mock(async () => ({ + executable: { toolchain: "project-maven" as const }, + arguments: ["-B", "spring-boot:run"], + workingDirectory: "reactor", + })); + const startRunProcess = mock(async () => undefined); + const dependencies: RunStoreDependencies = { + createLaunchPlan, + mavenLaunchContextForWorkspace: mock(async () => mavenContext), + resolveRunLaunch: mock(async () => ({ + executable: "D:/Tools/apache-maven/bin/mvn.cmd", + workingDirectory: "D:/work/reactor", + environment: {}, + })), + saveWorkspaceBeforeLaunch: mock(async () => { + throw new Error("Unable to start because modified files could not be saved: App.java."); + }), + startRunProcess, + stopRunProcess: mock(async () => undefined), + }; + const store = createRunStore("workspace", dependencies); + store.setState({ + root: "D:/work", + configurations: [configuration], + diagnostics: [], + effectiveRuntimeExecutablePaths: {}, + }); + + await store.getState().actions.runConfiguration(configuration.id); + + expect(createLaunchPlan).not.toHaveBeenCalled(); + expect(startRunProcess).not.toHaveBeenCalled(); + expect(store.getState().sessions).toEqual([ + expect.objectContaining({ + id: configuration.id, + isRunning: false, + exitCode: 1, + output: expect.stringContaining("App.java"), + }), + ]); + }); }); diff --git a/windows/tauri/src/features/run/stores/run.store.ts b/windows/tauri/src/features/run/stores/run.store.ts index f352c2d8..d51dbf17 100644 --- a/windows/tauri/src/features/run/stores/run.store.ts +++ b/windows/tauri/src/features/run/stores/run.store.ts @@ -1,4 +1,5 @@ import { createStore } from "zustand/vanilla"; +import { saveWorkspaceBeforeLaunch } from "@/features/editor/services/save-workspace-before-launch"; import { createWorkspaceScopedStore } from "@/features/workspace/stores/create-workspace-scoped-store"; import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; import { mavenLaunchContextForWorkspace } from "@/features/maven/stores/maven.store"; @@ -105,6 +106,7 @@ export interface RunStoreDependencies { createLaunchPlan: typeof createLaunchPlan; mavenLaunchContextForWorkspace: typeof mavenLaunchContextForWorkspace; resolveRunLaunch: typeof resolveRunLaunch; + saveWorkspaceBeforeLaunch: typeof saveWorkspaceBeforeLaunch; startRunProcess: typeof startRunProcess; stopRunProcess: typeof stopRunProcess; } @@ -113,6 +115,7 @@ const defaultRunStoreDependencies: RunStoreDependencies = { createLaunchPlan, mavenLaunchContextForWorkspace, resolveRunLaunch, + saveWorkspaceBeforeLaunch, startRunProcess, stopRunProcess, }; @@ -426,6 +429,7 @@ export const createRunStore = ( resetOutputStamper(sessionId); await dependencies.stopRunProcess(sessionId).catch(() => undefined); try { + await dependencies.saveWorkspaceBeforeLaunch(workspaceId); const mavenContext = configurationUsesMaven(configuration) ? await dependencies.mavenLaunchContextForWorkspace(root, [], workspaceId) : null; @@ -488,18 +492,27 @@ export const createRunStore = ( primaryOutput: trimOutput(`${get().primaryOutput}${message}\n`), }); } else { - set((current) => ({ - sessions: current.sessions.map((session) => - session.id === sessionId - ? { - ...session, - isRunning: false, - exitCode: 1, - output: trimOutput(`${session.output}${message}\n`), - } - : session, - ), - })); + set((current) => { + const existingSession = current.sessions.find( + (session) => session.id === sessionId, + ); + const failedSession: RunSession = { + id: sessionId, + configurationId: configuration.id, + title: configuration.name, + output: trimOutput(`${existingSession?.output ?? ""}${message}\n`), + isRunning: false, + exitCode: 1, + }; + return { + selectedSessionId: sessionId, + sessions: existingSession + ? current.sessions.map((session) => + session.id === sessionId ? failedSession : session, + ) + : [...current.sessions, failedSession], + }; + }); } } }, From b29da6757a0142c714eb398d2e677f07128b5933 Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Sun, 30 Aug 2026 21:54:40 +0800 Subject: [PATCH 66/66] fix(windows): isolate Maven reload workspace scope Preserve explicit workspace ownership across Java LSP launch and Maven reload paths, and expose Maven in activity visibility and localization. --- .../editor/components/monaco-editor.tsx | 8 +- .../editor/engines/monaco/definition-link.ts | 13 +- .../editor/hooks/use-lsp-integration.ts | 18 +- .../lsp/java-navigation-marker-loader.test.ts | 4 +- .../lsp/java-navigation-marker-loader.ts | 9 +- .../java-workspace-language-server.test.ts | 59 +++++-- .../lsp/java-workspace-language-server.ts | 38 +++-- .../src/features/editor/lsp/lsp-client.ts | 106 ++++++++---- .../lsp/resolve-editor-lsp-launch.test.ts | 32 ++-- .../editor/lsp/resolve-editor-lsp-launch.ts | 23 ++- .../file-system/stores/file-system.store.ts | 10 +- .../commands/navigation-command-actions.ts | 10 +- .../components/sidebar/main-sidebar.tsx | 96 ++++------- .../features/layout/config/item-order.test.ts | 30 ++++ .../src/features/layout/config/item-order.ts | 29 ++++ .../features/maven/components/maven-pane.tsx | 45 +++-- .../services/reload-maven-workspace.test.ts | 156 ++++++++++++++++++ .../maven/services/reload-maven-workspace.ts | 102 ++++++++++++ .../features/maven/stores/maven.store.test.ts | 55 +++++- .../workspace/types/workspace-launch-scope.ts | 26 +++ windows/tauri/src/i18n/locale.test.ts | 2 + windows/tauri/src/i18n/locale.ts | 2 + 22 files changed, 696 insertions(+), 177 deletions(-) create mode 100644 windows/tauri/src/features/maven/services/reload-maven-workspace.test.ts create mode 100644 windows/tauri/src/features/maven/services/reload-maven-workspace.ts create mode 100644 windows/tauri/src/features/workspace/types/workspace-launch-scope.ts diff --git a/windows/tauri/src/features/editor/components/monaco-editor.tsx b/windows/tauri/src/features/editor/components/monaco-editor.tsx index fa2d9176..d8d4001f 100644 --- a/windows/tauri/src/features/editor/components/monaco-editor.tsx +++ b/windows/tauri/src/features/editor/components/monaco-editor.tsx @@ -32,6 +32,7 @@ import { InlineEditPopover } from "@/features/editor/inline-edit/inline-edit-pop import { useInlineEdit } from "@/features/editor/inline-edit/use-inline-edit"; import { useInlineEditToolbarStore } from "@/features/editor/stores/inline-edit-toolbar.store"; import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; +import { useActiveWorkspaceId } from "@/features/workspace/stores/create-workspace-scoped-store"; import { useGitBlame } from "@/features/git/hooks/use-git-blame"; import { keymapRegistry } from "@/features/keymaps/utils/registry"; import { useSettingsStore } from "@/features/settings/stores/settings.store"; @@ -272,6 +273,7 @@ export function MonacoEditor({ javaMarkerRefreshRevision(state.lspStatus), ); const inlineGitBlameEnabled = useSettingsStore((state) => state.settings.enableInlineGitBlame); + const workspaceId = useActiveWorkspaceId(); const rootFolderPath = useFileSystemStore((state) => state.rootFolderPath); const workspaceFolders = useFileSystemStore((state) => state.workspaceFolders); const vimModeEnabled = useSettingsStore((state) => state.settings.vimMode); @@ -795,7 +797,7 @@ export function MonacoEditor({ editor, model, documentTarget, - workspaceRoot: rootFolderPath, + workspaceScope: rootFolderPath ? { workspaceId, root: rootFolderPath } : undefined, enabled: enableExpensiveServices, }); let definitionClickIntent = 0; @@ -1127,6 +1129,7 @@ export function MonacoEditor({ renderIndentGuides, renderWhitespace, rootFolderPath, + workspaceId, scrollable, scheduleInlineGitBlameRender, selectEntireModel, @@ -1371,7 +1374,7 @@ export function MonacoEditor({ const markers = await loadJavaNavigationMarkers({ client: lspClient, target: documentTarget, - workspaceRoot: rootFolderPath, + workspaceScope: { workspaceId, root: rootFolderPath }, content: model.getValue(), }); if (isDisposed()) { @@ -1450,6 +1453,7 @@ export function MonacoEditor({ javaMarkerRevision, monacoLanguageId, rootFolderPath, + workspaceId, ]); useEffect(() => { diff --git a/windows/tauri/src/features/editor/engines/monaco/definition-link.ts b/windows/tauri/src/features/editor/engines/monaco/definition-link.ts index e17ee4f0..527da1f6 100644 --- a/windows/tauri/src/features/editor/engines/monaco/definition-link.ts +++ b/windows/tauri/src/features/editor/engines/monaco/definition-link.ts @@ -1,6 +1,7 @@ import { editor as monacoEditor, Range as MonacoRange } from "monaco-editor"; import type * as Monaco from "monaco-editor"; import type { DefinitionNavigationHint } from "@/features/editor/lsp/definition-navigation-hint"; +import type { WorkspaceLaunchScope } from "@/features/workspace/types/workspace-launch-scope"; import { isEditorLspTargetSupported, type LspDocumentTarget, @@ -25,7 +26,7 @@ interface MonacoDefinitionLinkOptions { editor: Monaco.editor.IStandaloneCodeEditor; model: Monaco.editor.ITextModel; documentTarget: LspDocumentTarget; - workspaceRoot?: string; + workspaceScope?: WorkspaceLaunchScope; enabled?: boolean; } @@ -54,7 +55,7 @@ export function registerMonacoDefinitionLinkGesture({ editor, model, documentTarget, - workspaceRoot, + workspaceScope, enabled = true, }: MonacoDefinitionLinkOptions): MonacoDefinitionLinkGesture { const decorations = editor.createDecorationsCollection(); @@ -114,7 +115,7 @@ export function registerMonacoDefinitionLinkGesture({ } const lspClient = LspClient.getInstance(); if ( - workspaceRoot && + workspaceScope && !isDocumentFeatureAvailable( lspClient.getDocumentAvailability(documentTarget, "definition"), ) @@ -122,7 +123,7 @@ export function registerMonacoDefinitionLinkGesture({ try { await lspClient.ensureDocumentReady( documentTarget, - workspaceRoot, + workspaceScope, model.getValue(), "definition", ); @@ -143,7 +144,7 @@ export function registerMonacoDefinitionLinkGesture({ model.isDisposed() || model.getLanguageId() !== "java" || documentTarget.documentUri || - !workspaceRoot + !workspaceScope ) { return { locations }; } @@ -152,7 +153,7 @@ export function registerMonacoDefinitionLinkGesture({ const lombokDefinition = await resolveLombokAccessorDefinition({ source: model.getValue(), sourceFilePath: documentTarget.filePath, - workspaceRoot, + workspaceRoot: workspaceScope.root, line, character: request.character, }); diff --git a/windows/tauri/src/features/editor/hooks/use-lsp-integration.ts b/windows/tauri/src/features/editor/hooks/use-lsp-integration.ts index 609471fa..f1cebfd1 100644 --- a/windows/tauri/src/features/editor/hooks/use-lsp-integration.ts +++ b/windows/tauri/src/features/editor/hooks/use-lsp-integration.ts @@ -12,6 +12,8 @@ import { useBufferStore } from "@/features/editor/stores/buffer.store"; import { getSourceEditorBufferByPath } from "@/features/editor/utils/buffer-index"; import { logger } from "@/features/editor/utils/logger"; import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; +import { useActiveWorkspaceId } from "@/features/workspace/stores/create-workspace-scoped-store"; +import { workspaceScopeMatchesRoot } from "@/features/workspace/types/workspace-launch-scope"; import { getDirName } from "@/utils/path-helpers"; interface UseLspIntegrationOptions { @@ -67,6 +69,7 @@ export const useLspIntegration = ({ contentRevision = 0, }: UseLspIntegrationOptions) => { const lspClient = useMemo(() => LspClient.getInstance(), []); + const workspaceId = useActiveWorkspaceId(); const rootFolderPath = useFileSystemStore((state) => state.rootFolderPath); const installedExtensions = useExtensionStore.use.installedExtensions(); const activeFilePath = enabled ? filePath : undefined; @@ -85,6 +88,17 @@ export const useLspIntegration = ({ logger.warn("LspIntegration", `Could not determine workspace path for ${filePath}`); return; } + const scope = { workspaceId, root: workspacePath }; + if ( + rootFolderPath && + !workspaceScopeMatchesRoot( + scope, + useFileSystemStore.getStore(workspaceId).getState().rootFolderPath, + ) + ) { + logger.warn("LspIntegration", `Ignoring stale workspace scope for ${filePath}`); + return; + } const existingOwner = documentOwnersRef.current.get(filePath); const owner: LspDocumentOwner = @@ -141,7 +155,7 @@ export const useLspIntegration = ({ const initializeLsp = async () => { try { logger.debug("LspIntegration", `Starting LSP for ${filePath} in ${workspacePath}`); - const attachment = await lspClient.startForFile(filePath, workspacePath); + const attachment = await lspClient.startForFile(filePath, scope); if (attachment.kind !== "attached") { owner.state = { phase: "stopped" }; if (documentOwnersRef.current.get(filePath) === owner) { @@ -183,7 +197,7 @@ export const useLspIntegration = ({ cancelInitialization(); cleanupDocument(); }; - }, [enabled, filePath, isLspSupported, lspClient, rootFolderPath]); + }, [enabled, filePath, isLspSupported, lspClient, rootFolderPath, workspaceId]); useEffect(() => { if (!enabled || !filePath || !isLspSupported) return; diff --git a/windows/tauri/src/features/editor/lsp/java-navigation-marker-loader.test.ts b/windows/tauri/src/features/editor/lsp/java-navigation-marker-loader.test.ts index 3515bf56..b99161d7 100644 --- a/windows/tauri/src/features/editor/lsp/java-navigation-marker-loader.test.ts +++ b/windows/tauri/src/features/editor/lsp/java-navigation-marker-loader.test.ts @@ -29,14 +29,14 @@ test("attaches the Java document before requesting gutter markers", async () => const markers = await loadJavaNavigationMarkers({ client: { ensureDocumentReady, getJavaNavigationMarkers }, target, - workspaceRoot: "C:/work", + workspaceScope: { workspaceId: "workspace-a", root: "C:/work" }, content: "interface Service {}", }); expect(calls).toEqual(["ensureDocumentReady", "getJavaNavigationMarkers"]); expect(ensureDocumentReady).toHaveBeenCalledWith( target, - "C:/work", + { workspaceId: "workspace-a", root: "C:/work" }, "interface Service {}", "codeLens", ); diff --git a/windows/tauri/src/features/editor/lsp/java-navigation-marker-loader.ts b/windows/tauri/src/features/editor/lsp/java-navigation-marker-loader.ts index c296d16e..9114dde2 100644 --- a/windows/tauri/src/features/editor/lsp/java-navigation-marker-loader.ts +++ b/windows/tauri/src/features/editor/lsp/java-navigation-marker-loader.ts @@ -1,11 +1,12 @@ import type { JavaImplementationMarker } from "./java-navigation-models"; import type { LspDocumentAvailability } from "./lsp-client"; import type { LspDocumentTarget } from "./lsp-document-target"; +import type { WorkspaceLaunchScope } from "@/features/workspace/types/workspace-launch-scope"; export interface JavaNavigationMarkerClient { ensureDocumentReady( target: LspDocumentTarget, - workspaceRoot: string, + scope: WorkspaceLaunchScope, content: string, feature?: string, ): Promise; @@ -15,7 +16,7 @@ export interface JavaNavigationMarkerClient { interface LoadJavaNavigationMarkersOptions { client: JavaNavigationMarkerClient; target: LspDocumentTarget; - workspaceRoot: string; + workspaceScope: WorkspaceLaunchScope; content: string; } @@ -27,9 +28,9 @@ interface LoadJavaNavigationMarkersOptions { export async function loadJavaNavigationMarkers({ client, target, - workspaceRoot, + workspaceScope, content, }: LoadJavaNavigationMarkersOptions): Promise { - await client.ensureDocumentReady(target, workspaceRoot, content, "codeLens"); + await client.ensureDocumentReady(target, workspaceScope, content, "codeLens"); return client.getJavaNavigationMarkers(target); } diff --git a/windows/tauri/src/features/editor/lsp/java-workspace-language-server.test.ts b/windows/tauri/src/features/editor/lsp/java-workspace-language-server.test.ts index a2634a88..49d4b470 100644 --- a/windows/tauri/src/features/editor/lsp/java-workspace-language-server.test.ts +++ b/windows/tauri/src/features/editor/lsp/java-workspace-language-server.test.ts @@ -1,6 +1,11 @@ -import { expect, mock, test } from "bun:test"; +import { afterEach, expect, mock, test } from "bun:test"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; import { JavaWorkspaceLanguageServerOwner } from "./java-workspace-language-server"; +const workspaceA = { workspaceId: "workspace-a", root: "C:/work" }; + +afterEach(() => workspaceRuntimeRegistry.resetForTests()); + function operationRecorder() { const outcomes: string[] = []; const operationIds: string[] = []; @@ -43,14 +48,14 @@ test("shares one Java workspace prewarm and reports readiness once", async () => () => undefined, ); - const first = owner.prewarm("C:/work", "C:/work/src/Main.java"); - const second = owner.prewarm("C:\\work", "C:/work/src/Other.java"); + const first = owner.prewarm(workspaceA, "C:/work/src/Main.java"); + const second = owner.prewarm({ ...workspaceA, root: "C:\\work" }, "C:/work/src/Other.java"); expect(start).toHaveBeenCalledTimes(1); releaseStart?.({ kind: "ready" }); expect(await first).toEqual({ kind: "ready" }); expect(await second).toEqual({ kind: "ready" }); - expect(await owner.prewarm("C:/work", "C:/work/src/Third.java")).toEqual({ kind: "ready" }); + expect(await owner.prewarm(workspaceA, "C:/work/src/Third.java")).toEqual({ kind: "ready" }); expect(operations.outcomes).toEqual(["succeeded"]); expect(operations.names).toEqual(["workspacePrewarm"]); expect(notifyReady).toHaveBeenCalledTimes(1); @@ -78,8 +83,8 @@ test("closing a workspace cancels an in-flight prewarm and stops its server", as () => undefined, ); - const prewarm = owner.prewarm("C:/work", "C:/work/src/Main.java"); - const close = owner.stop("C:\\work"); + const prewarm = owner.prewarm(workspaceA, "C:/work/src/Main.java"); + const close = owner.stop({ ...workspaceA, root: "C:\\work" }); releaseStart?.({ kind: "ready" }); expect(await prewarm).toEqual({ @@ -113,7 +118,7 @@ test("records a timeout without converting it to a generic failure", async () => notifyFailure, ); - expect(await owner.prewarm("C:/work", "C:/work/src/Main.java")).toEqual({ + expect(await owner.prewarm(workspaceA, "C:/work/src/Main.java")).toEqual({ kind: "timedOut", error: timeout, }); @@ -152,15 +157,15 @@ test("waits for a stopping owner before starting a replacement workspace session () => undefined, ); - const first = owner.prewarm("C:/work", "C:/work/src/First.java"); - const stopping = owner.stop("C:/work"); + const first = owner.prewarm(workspaceA, "C:/work/src/First.java"); + const stopping = owner.stop(workspaceA); startResolvers[0]?.({ kind: "ready" }); expect(await first).toEqual({ kind: "cancelled", reason: "workspace-closed-before-ready", }); - const replacement = owner.prewarm("C:/work", "C:/work/src/Second.java"); + const replacement = owner.prewarm(workspaceA, "C:/work/src/Second.java"); await Promise.resolve(); expect(stop).toHaveBeenCalledTimes(1); releaseStop?.(); @@ -194,11 +199,11 @@ test("creates a new operation after a failed start is retried", async () => { () => undefined, ); - expect(await owner.prewarm("C:/work", "C:/work/src/Main.java")).toEqual({ + expect(await owner.prewarm(workspaceA, "C:/work/src/Main.java")).toEqual({ kind: "failed", error: failure, }); - expect(await owner.prewarm("C:/work", "C:/work/src/Main.java")).toEqual({ kind: "ready" }); + expect(await owner.prewarm(workspaceA, "C:/work/src/Main.java")).toEqual({ kind: "ready" }); expect(operations.outcomes).toEqual(["failed", "succeeded"]); expect(operations.operationIds).toHaveLength(2); @@ -220,7 +225,7 @@ test("reports a configured workspace without a usable runtime as unavailable", a notifyFailure, ); - expect(await owner.prewarm("C:/work", "C:/work/src/Main.java")).toEqual({ + expect(await owner.prewarm(workspaceA, "C:/work/src/Main.java")).toEqual({ kind: "unavailable", reason: "notConfigured", }); @@ -232,3 +237,31 @@ test("reports a configured workspace without a usable runtime as unavailable", a expect.any(Function), ); }); + +test("keeps workspace A scope when retrying while workspace B is active", async () => { + const retryCallbacks: Array<() => void> = []; + let startAttempt = 0; + const start = mock(async (_scope: typeof workspaceA) => { + startAttempt += 1; + if (startAttempt === 1) throw new Error("first start failed"); + return { kind: "ready" } as const; + }); + const owner = new JavaWorkspaceLanguageServerOwner( + { start, stop: async () => undefined }, + operationRecorder().factory, + () => undefined, + () => undefined, + () => undefined, + (_workspacePath, _languageId, _failure, retry) => retryCallbacks.push(retry), + ); + + workspaceRuntimeRegistry.activateWorkspace({ id: "workspace-b", name: "B" }, "ready"); + await owner.prewarm(workspaceA, "C:/work/src/Main.java"); + expect(retryCallbacks).toHaveLength(1); + retryCallbacks[0]!(); + expect(await owner.prewarm(workspaceA, "C:/work/src/Main.java")).toEqual({ kind: "ready" }); + + expect(workspaceRuntimeRegistry.getActiveWorkspaceId()).toBe("workspace-b"); + expect(start).toHaveBeenCalledTimes(2); + expect(start.mock.calls.map((call) => call[0])).toEqual([workspaceA, workspaceA]); +}); diff --git a/windows/tauri/src/features/editor/lsp/java-workspace-language-server.ts b/windows/tauri/src/features/editor/lsp/java-workspace-language-server.ts index 91c2df41..47ec4e15 100644 --- a/windows/tauri/src/features/editor/lsp/java-workspace-language-server.ts +++ b/windows/tauri/src/features/editor/lsp/java-workspace-language-server.ts @@ -1,4 +1,5 @@ import { LspOperationLog } from "@/platform/lsp-session-lifecycle"; +import type { WorkspaceLaunchScope } from "@/features/workspace/types/workspace-launch-scope"; import { JAVA_LANGUAGE_ID } from "./built-in-language-support"; import { clearLanguageServerReadyFeedback, @@ -14,7 +15,7 @@ import { interface WorkspaceLanguageServerClient { start( - workspacePath: string, + scope: WorkspaceLaunchScope, representativeFilePath?: string, ): Promise; stop(workspacePath: string): Promise; @@ -50,13 +51,13 @@ type WorkspaceOwnerState = interface WorkspaceOwner { operationId: string; operation: OperationLog; - workspacePath: string; + scope: WorkspaceLaunchScope; representativeJavaFile: string; state: WorkspaceOwnerState; } -function workspaceKey(workspacePath: string): string { - return workspacePath.replace(/\\/g, "/").toLowerCase(); +function workspaceKey(scope: WorkspaceLaunchScope): string { + return `${scope.workspaceId}\0${scope.root.replace(/\\/g, "/").toLowerCase()}`; } function isTimeout(error: unknown): boolean { @@ -92,21 +93,22 @@ export class JavaWorkspaceLanguageServerOwner { ) {} prewarm( - workspacePath: string, + scope: WorkspaceLaunchScope, representativeJavaFile: string, ): Promise { - const key = workspaceKey(workspacePath); + const workspacePath = scope.root; + const key = workspaceKey(scope); const existing = this.owners.get(key); if (existing) { if (existing.state.phase === "starting" || existing.state.phase === "ready") { return existing.state.task; } if (existing.state.phase === "stopping") { - return existing.state.task.then(() => this.prewarm(workspacePath, representativeJavaFile)); + return existing.state.task.then(() => this.prewarm(scope, representativeJavaFile)); } if (existing.state.phase === "stopFailed") { - return this.stop(workspacePath).then(() => - this.prewarm(workspacePath, representativeJavaFile), + return this.stop(scope).then(() => + this.prewarm(scope, representativeJavaFile), ); } this.owners.delete(key); @@ -114,6 +116,7 @@ export class JavaWorkspaceLanguageServerOwner { const operationId = crypto.randomUUID(); const operation = this.createOperationLog("workspacePrewarm", operationId, { + workspaceId: scope.workspaceId, workspacePath, languageId: JAVA_LANGUAGE_ID, }); @@ -121,7 +124,7 @@ export class JavaWorkspaceLanguageServerOwner { const owner: WorkspaceOwner = { operationId, operation, - workspacePath, + scope, representativeJavaFile, state: { phase: "created" }, }; @@ -135,9 +138,10 @@ export class JavaWorkspaceLanguageServerOwner { owner: WorkspaceOwner, key: string, ): Promise { - const { workspacePath, representativeJavaFile, operation } = owner; + const { scope, representativeJavaFile, operation } = owner; + const workspacePath = scope.root; try { - const startOutcome = await this.client.start(workspacePath, representativeJavaFile); + const startOutcome = await this.client.start(scope, representativeJavaFile); if (this.owners.get(key) !== owner) { operation.cancelled("superseded-owner"); return { kind: "cancelled", reason: "superseded-owner" }; @@ -152,7 +156,7 @@ export class JavaWorkspaceLanguageServerOwner { workspacePath, JAVA_LANGUAGE_ID, { kind: "unavailable" }, - () => void this.prewarm(workspacePath, representativeJavaFile), + () => void this.prewarm(scope, representativeJavaFile), ); if (this.owners.get(key) === owner) this.owners.delete(key); return { kind: "unavailable", reason: startOutcome.kind }; @@ -184,20 +188,22 @@ export class JavaWorkspaceLanguageServerOwner { kind: timedOut ? "timedOut" : "failed", detail: error instanceof Error ? error.message : String(error), }, - () => void this.prewarm(workspacePath, representativeJavaFile), + () => void this.prewarm(scope, representativeJavaFile), ); if (this.owners.get(key) === owner) this.owners.delete(key); return timedOut ? { kind: "timedOut", error } : { kind: "failed", error }; } } - async stop(workspacePath: string): Promise { - const key = workspaceKey(workspacePath); + async stop(scope: WorkspaceLaunchScope): Promise { + const workspacePath = scope.root; + const key = workspaceKey(scope); const owner = this.owners.get(key); if (owner?.state.phase === "stopping") return owner.state.task; const operationId = crypto.randomUUID(); const operation = this.createOperationLog("workspaceStop", operationId, { + workspaceId: scope.workspaceId, workspacePath, languageId: JAVA_LANGUAGE_ID, }); diff --git a/windows/tauri/src/features/editor/lsp/lsp-client.ts b/windows/tauri/src/features/editor/lsp/lsp-client.ts index 91d95cc6..ad1d588a 100644 --- a/windows/tauri/src/features/editor/lsp/lsp-client.ts +++ b/windows/tauri/src/features/editor/lsp/lsp-client.ts @@ -58,6 +58,10 @@ import { type WorkspaceEdit, } from "./workspace-edit"; import type { LspAdapterSessionPhase } from "@/platform/lsp-session-lifecycle"; +import { + workspaceScopesMatch, + type WorkspaceLaunchScope, +} from "@/features/workspace/types/workspace-launch-scope"; export type LspWorkspaceStartOutcome = | { kind: "ready" } @@ -128,10 +132,15 @@ type TrackedLspDocument = { }; type PendingFileStart = { - workspacePath: string; + scope: WorkspaceLaunchScope; task: Promise; }; +type PendingWorkspaceStart = { + scope: WorkspaceLaunchScope; + task: Promise; +}; + type LspFileStartIntent = "attach" | "manualRestart"; type LspFileStartAttempt = @@ -201,12 +210,13 @@ export class LspClient { private activeLanguages = new Set(); // Track active language IDs for status private activeServerFiles = new Map>(); // workspace:language -> tracked files private workspaceRepresentativeFiles = new Map(); + private serverScopes = new Map(); /** workspace:language -> failure timestamp (ms); expired after a short cooldown. */ private failedLanguageServers = new Map(); private repairLanguageServerPromises = new Map>(); private fileAttachmentIds = new Map(); private fileStartTasks = new Map(); - private workspaceStartTasks = new Map>(); + private workspaceStartTasks = new Map(); private documentOpenTasks = new Map(); private documents = new Map(); @@ -347,6 +357,7 @@ export class LspClient { if (trackedFiles.size === 0) { this.activeServerFiles.delete(existingKey); this.activeLanguageServers.delete(existingKey); + this.serverScopes.delete(existingKey); } } const trackedFiles = this.activeServerFiles.get(serverKey) ?? new Set(); @@ -357,8 +368,14 @@ export class LspClient { this.activeServerFiles.set(serverKey, trackedFiles); } - private registerActiveServer(serverKey: string, languageId: string, filePath?: string) { + private registerActiveServer( + serverKey: string, + languageId: string, + scope: WorkspaceLaunchScope, + filePath?: string, + ) { this.activeLanguageServers.add(serverKey); + this.serverScopes.set(serverKey, scope); if (filePath) this.addTrackedFile(serverKey, filePath); this.activeLanguages.add(getLanguageDisplayName(languageId)); this.updateLspStatus(); @@ -551,9 +568,10 @@ export class LspClient { } async start( - workspacePath: string, + scope: WorkspaceLaunchScope, representativeFilePath?: string, ): Promise { + const workspacePath = scope.root; try { logger.debug("LSPClient", "Starting LSP with workspace:", workspacePath); @@ -563,7 +581,7 @@ export class LspClient { } const launch = representativeFilePath - ? await resolveEditorLspLaunch(representativeFilePath, workspacePath) + ? await resolveEditorLspLaunch(representativeFilePath, scope) : null; if (!launch) { logger.debug("LSPClient", `No LSP server configured for workspace ${workspacePath}`); @@ -579,12 +597,15 @@ export class LspClient { if (representativeFilePath) { this.workspaceRepresentativeFiles.set(serverKey, representativeFilePath); } - this.registerActiveServer(serverKey, launch.languageId); + this.registerActiveServer(serverKey, launch.languageId, scope); return { kind: "ready" } as const; } const existingTask = this.workspaceStartTasks.get(serverKey); - if (existingTask) return existingTask; + if (existingTask && workspaceScopesMatch(existingTask.scope, scope)) { + return existingTask.task; + } + if (existingTask) await existingTask.task; const task: Promise = (async (): Promise => { logger.debug( @@ -611,15 +632,15 @@ export class LspClient { if (representativeFilePath) { this.workspaceRepresentativeFiles.set(serverKey, representativeFilePath); } - this.registerActiveServer(serverKey, launch.languageId); + this.registerActiveServer(serverKey, launch.languageId, scope); logger.debug("LSPClient", "LSP started successfully for workspace:", workspacePath); return { kind: "ready" }; })().finally(() => { - if (this.workspaceStartTasks.get(serverKey) === task) { + if (this.workspaceStartTasks.get(serverKey)?.task === task) { this.workspaceStartTasks.delete(serverKey); } }); - this.workspaceStartTasks.set(serverKey, task); + this.workspaceStartTasks.set(serverKey, { scope, task }); return await task; } catch (error) { logger.error("LSPClient", "Failed to start LSP:", error); @@ -634,7 +655,7 @@ export class LspClient { const workspaceKey = trackedFileKey(workspacePath); const pendingStarts = [...this.workspaceStartTasks.entries()] .filter(([key]) => trackedFileKey(this.parseServerKey(key).workspacePath) === workspaceKey) - .map(([, task]) => task); + .map(([, pending]) => pending.task); if (pendingStarts.length > 0) await Promise.allSettled(pendingStarts); await invoke("lsp_stop", { workspacePath }); @@ -653,6 +674,7 @@ export class LspClient { this.activeLanguageServers.delete(server); this.activeServerFiles.delete(server); this.workspaceRepresentativeFiles.delete(server); + this.serverScopes.delete(server); const { languageId: language } = this.parseServerKey(server); if (language) { const displayName = getLanguageDisplayName(language); @@ -684,33 +706,35 @@ export class LspClient { async startForFile( filePath: string, - workspacePath: string, + scope: WorkspaceLaunchScope, intent: LspFileStartIntent = "attach", ): Promise { const attachmentKey = trackedFileKey(filePath); const currentAttachmentId = this.fileAttachmentIds.get(attachmentKey); const currentSession = getLspSessionSnapshot({ filePath }); + const currentServerKey = currentSession + ? `${currentSession.workspacePath}:${currentSession.languageId}` + : null; + const currentScope = currentServerKey ? this.serverScopes.get(currentServerKey) : undefined; if ( currentAttachmentId && currentSession && - trackedFileKey(currentSession.workspacePath) === trackedFileKey(workspacePath) + currentServerKey && + currentScope && + workspaceScopesMatch(currentScope, scope) ) { - this.registerActiveServer( - `${currentSession.workspacePath}:${currentSession.languageId}`, - currentSession.languageId, - filePath, - ); + this.registerActiveServer(currentServerKey, currentSession.languageId, scope, filePath); return { kind: "attached", attachmentId: currentAttachmentId }; } const pending = this.fileStartTasks.get(attachmentKey); - if (pending && trackedFileKey(pending.workspacePath) === trackedFileKey(workspacePath)) { + if (pending && workspaceScopesMatch(pending.scope, scope)) { return pending.task; } const attachmentId = crypto.randomUUID(); this.fileAttachmentIds.set(attachmentKey, attachmentId); - const task = this.startFileAttachment(filePath, workspacePath, { + const task = this.startFileAttachment(filePath, scope, { kind: intent, attachmentId, }).finally(() => { @@ -718,15 +742,16 @@ export class LspClient { this.fileStartTasks.delete(attachmentKey); } }); - this.fileStartTasks.set(attachmentKey, { workspacePath, task }); + this.fileStartTasks.set(attachmentKey, { scope, task }); return task; } private async startFileAttachment( filePath: string, - workspacePath: string, + scope: WorkspaceLaunchScope, attempt: LspFileStartAttempt, ): Promise { + const workspacePath = scope.root; const attachmentKey = trackedFileKey(filePath); const attachmentId = attempt.attachmentId; if (this.fileAttachmentIds.get(attachmentKey) !== attachmentId) { @@ -745,14 +770,14 @@ export class LspClient { let launch: Awaited> = null; try { - launch = await resolveEditorLspLaunch(filePath, workspacePath); + launch = await resolveEditorLspLaunch(filePath, scope); } catch (error) { if (attempt.kind !== "repairRetry" && !isBuiltInLspPath(filePath)) { const languageId = languageIdForEditorFile(filePath); if (languageId) { const repaired = await this.repairLanguageServerForFile(filePath, languageId); if (repaired.kind === "repaired") { - return this.startFileAttachment(filePath, workspacePath, { + return this.startFileAttachment(filePath, scope, { kind: "repairRetry", attachmentId, }); @@ -767,7 +792,7 @@ export class LspClient { if (languageId && attempt.kind !== "repairRetry" && !isBuiltInLspPath(filePath)) { const repaired = await this.repairLanguageServerForFile(filePath, languageId); if (repaired.kind === "repaired") { - return this.startFileAttachment(filePath, workspacePath, { + return this.startFileAttachment(filePath, scope, { kind: "repairRetry", attachmentId, }); @@ -842,13 +867,13 @@ export class LspClient { return { kind: "cancelled", reason: "superseded" }; } clearLanguageServerFailure(this.failedLanguageServers, serverKey); - this.registerActiveServer(serverKey, languageId, filePath); + this.registerActiveServer(serverKey, languageId, scope, filePath); } catch (error) { recordLanguageServerFailure(this.failedLanguageServers, serverKey, Date.now()); if (attempt.kind !== "repairRetry" && this.isRepairableStartupError(error)) { const repaired = await this.repairLanguageServerForFile(filePath, languageId); if (repaired.kind === "repaired") { - return this.startFileAttachment(filePath, workspacePath, { + return this.startFileAttachment(filePath, scope, { kind: "repairRetry", attachmentId, }); @@ -918,7 +943,7 @@ export class LspClient { async ensureDocumentReady( target: LspDocumentTargetInput, - workspacePath: string, + scope: WorkspaceLaunchScope, content: string, feature?: string, ): Promise { @@ -931,7 +956,7 @@ export class LspClient { // that source document from virtual class text would corrupt synchronization. if (sessionFilePath !== document.filePath) return initial; - const attachment = await this.startForFile(sessionFilePath, workspacePath); + const attachment = await this.startForFile(sessionFilePath, scope); if (attachment.kind !== "attached") return this.getDocumentAvailability(document, feature); const { attachmentId } = attachment; @@ -977,6 +1002,7 @@ export class LspClient { }); if (!stillActiveForServer && !(languageId === JAVA_LANGUAGE_ID && workspaceSession)) { this.activeLanguageServers.delete(activeKey); + this.serverScopes.delete(activeKey); } clearLanguageServerFailure(this.failedLanguageServers, activeKey); } @@ -1010,7 +1036,11 @@ export class LspClient { await this.stopForFile(trackedFilePath); } - async restartForFile(filePath: string, workspacePath: string, content: string): Promise { + async restartForFile( + filePath: string, + scope: WorkspaceLaunchScope, + content: string, + ): Promise { const { actions } = useLspStore.getState(); try { @@ -1019,7 +1049,7 @@ export class LspClient { await this.notifyDocumentClose(filePath); await this.stopForFile(filePath); - const attachment = await this.startForFile(filePath, workspacePath, "manualRestart"); + const attachment = await this.startForFile(filePath, scope, "manualRestart"); if (attachment.kind !== "attached") { throw new Error("Language server failed to start."); } @@ -1038,16 +1068,24 @@ export class LspClient { if (!representativeFilePath) { throw new Error("No representative file for this language server"); } - const { workspacePath } = this.parseServerKey(serverKey); + const scope = this.serverScopes.get(serverKey); + if (!scope) { + throw new Error("No workspace scope for this language server"); + } + const workspacePath = scope.root; await this.stop(workspacePath); - await this.start(workspacePath, representativeFilePath); + await this.start(scope, representativeFilePath); return; } const filePath = trackedFilePath; const buffer = useBufferStore.getState().buffers.find((entry) => entry.path === filePath); const content = buffer && hasTextContent(buffer) ? buffer.content : ""; - await this.restartForFile(filePath, this.parseServerKey(serverKey).workspacePath, content); + const scope = this.serverScopes.get(serverKey); + if (!scope) { + throw new Error("No workspace scope for this language server"); + } + await this.restartForFile(filePath, scope, content); } async restartAllTrackedServers(): Promise { diff --git a/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.test.ts b/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.test.ts index df24672f..49ca9d6c 100644 --- a/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.test.ts +++ b/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.test.ts @@ -1,4 +1,8 @@ -import { expect, mock, test } from "bun:test"; +import { afterEach, expect, mock, test } from "bun:test"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { resolveEditorLspLaunch } from "./resolve-editor-lsp-launch"; + +afterEach(() => workspaceRuntimeRegistry.resetForTests()); const resolveJavaLspLaunch = mock(async () => ({ providerId: "java", @@ -20,17 +24,23 @@ const mavenLaunchContextForWorkspace = mock(async () => ({ javaHomePath: "C:/Java/jdk-21", })); -mock.module("./java-lsp-host-api", () => ({ resolveJavaLspLaunch })); -mock.module("@/features/maven/stores/maven.store", () => ({ - mavenLaunchContextForWorkspace, -})); - -const { resolveEditorLspLaunch } = await import("./resolve-editor-lsp-launch"); - -test("forwards the current Maven context to the Java language server", async () => { - const launch = await resolveEditorLspLaunch("D:/work/src/App.java", "D:/work"); +test("resolves workspace A Maven context while workspace B is active", async () => { + workspaceRuntimeRegistry.activateWorkspace({ id: "workspace-b", name: "B" }, "ready"); + const launch = await resolveEditorLspLaunch( + "D:/work-a/src/App.java", + { + workspaceId: "workspace-a", + root: "D:/work-a", + }, + { resolveJavaLspLaunch, mavenLaunchContextForWorkspace }, + ); - expect(mavenLaunchContextForWorkspace).toHaveBeenCalledWith("D:/work", ["src/App.java"]); + expect(workspaceRuntimeRegistry.getActiveWorkspaceId()).toBe("workspace-b"); + expect(mavenLaunchContextForWorkspace).toHaveBeenCalledWith( + "D:/work-a", + ["src/App.java"], + "workspace-a", + ); expect(launch?.mavenContext).toEqual( expect.objectContaining({ profiles: ["dev"], diff --git a/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.ts b/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.ts index 0c150c23..fe44b6aa 100644 --- a/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.ts +++ b/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.ts @@ -3,6 +3,7 @@ import { isJavaSourcePath, JAVA_LANGUAGE_ID, JAVA_PROVIDER_ID } from "./built-in import { resolveJavaLspLaunch, type JdtlsLaunchResources } from "./java-lsp-host-api"; import type { MavenLaunchContext } from "@/features/maven/types/maven.types"; import { mavenLaunchContextForWorkspace } from "@/features/maven/stores/maven.store"; +import type { WorkspaceLaunchScope } from "@/features/workspace/types/workspace-launch-scope"; import { getRelativePath } from "@/utils/path-helpers"; export interface EditorLspLaunch { @@ -21,14 +22,30 @@ export interface EditorLspLaunch { mavenContext?: MavenLaunchContext | null; } +export interface EditorLspLaunchDependencies { + resolveJavaLspLaunch: typeof resolveJavaLspLaunch; + mavenLaunchContextForWorkspace: typeof mavenLaunchContextForWorkspace; +} + +const defaultDependencies: EditorLspLaunchDependencies = { + resolveJavaLspLaunch, + mavenLaunchContextForWorkspace, +}; + export async function resolveEditorLspLaunch( filePath: string, - workspacePath: string, + scope: WorkspaceLaunchScope, + dependencies: EditorLspLaunchDependencies = defaultDependencies, ): Promise { + const workspacePath = scope.root; if (isJavaSourcePath(filePath)) { const [launch, mavenContext] = await Promise.all([ - resolveJavaLspLaunch(workspacePath), - mavenLaunchContextForWorkspace(workspacePath, [getRelativePath(filePath, workspacePath)]), + dependencies.resolveJavaLspLaunch(workspacePath), + dependencies.mavenLaunchContextForWorkspace( + workspacePath, + [getRelativePath(filePath, workspacePath)], + scope.workspaceId, + ), ]); const environment: Record = {}; if (launch.environment.JAVA_HOME) { diff --git a/windows/tauri/src/features/file-system/stores/file-system.store.ts b/windows/tauri/src/features/file-system/stores/file-system.store.ts index f95213eb..c051d941 100644 --- a/windows/tauri/src/features/file-system/stores/file-system.store.ts +++ b/windows/tauri/src/features/file-system/stores/file-system.store.ts @@ -555,7 +555,10 @@ const initializeLocalWorkspaceInBackground = ( } operation.succeeded({ representativeJavaPath: policy.representativeJavaPath }); - await getJavaWorkspaceLanguageServerOwner().prewarm(path, javaFile); + await getJavaWorkspaceLanguageServerOwner().prewarm( + { workspaceId, root: path }, + javaFile, + ); } catch (error) { operation.failed(error); } @@ -3078,7 +3081,10 @@ const createFileSystemStore = (workspaceId: string): StoreApi { diff --git a/windows/tauri/src/features/keymaps/commands/navigation-command-actions.ts b/windows/tauri/src/features/keymaps/commands/navigation-command-actions.ts index 4ad88ae0..ae154f83 100644 --- a/windows/tauri/src/features/keymaps/commands/navigation-command-actions.ts +++ b/windows/tauri/src/features/keymaps/commands/navigation-command-actions.ts @@ -35,6 +35,8 @@ import { } from "@/features/spring/utils/spring-navigation"; import { useUIState } from "@/features/window/stores/ui-state.store"; import { useProjectStore } from "@/features/window/stores/project.store"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import type { WorkspaceLaunchScope } from "@/features/workspace/types/workspace-launch-scope"; import { createTranslator } from "@/i18n/locale"; import { logger } from "@/features/editor/utils/logger"; import { normalizePath } from "@/utils/path-helpers"; @@ -67,7 +69,7 @@ type LspNavigationClient = { ) => LspDocumentAvailability; ensureDocumentReady: ( target: LspDocumentTargetInput, - workspacePath: string, + scope: WorkspaceLaunchScope, content: string, feature?: string, ) => Promise; @@ -167,6 +169,10 @@ async function ensureNavigationLanguageServer( } const target = lspDocumentTargetForEditor(buffer); + const scope = { + workspaceId: workspaceRuntimeRegistry.getActiveWorkspaceId(), + root: workspacePath, + }; toast.info( navigationBlockMessage( { reason: "preparing", languageId: block.languageId }, @@ -179,7 +185,7 @@ async function ensureNavigationLanguageServer( // here. Continuing after readiness would move the editor long after the user // has switched context. void lspClient - .ensureDocumentReady(target, workspacePath, buffer.content, feature) + .ensureDocumentReady(target, scope, buffer.content, feature) .catch((error) => { logger.warn("LSPNavigation", "Background document attachment failed", error); }); diff --git a/windows/tauri/src/features/layout/components/sidebar/main-sidebar.tsx b/windows/tauri/src/features/layout/components/sidebar/main-sidebar.tsx index 6fd5e6b4..395b39e2 100644 --- a/windows/tauri/src/features/layout/components/sidebar/main-sidebar.tsx +++ b/windows/tauri/src/features/layout/components/sidebar/main-sidebar.tsx @@ -25,6 +25,11 @@ import { getProjectSwipeBounds, } from "@/features/layout/utils/project-carousel"; import type { SidebarView } from "@/features/layout/utils/sidebar-pane-utils"; +import { + setSidebarActivityItemVisibility, + sidebarActivityVisibilityItemIds, + type SidebarActivityItemId, +} from "@/features/layout/config/item-order"; import { openGlobalSearchSidebar, toggleDiagnosticsPane, @@ -70,6 +75,7 @@ import { GitBranchIcon, GitGraphIcon, MagnifyingGlassIcon, + PackageIcon, TerminalWindowIcon, WarningIcon, } from "@/ui/icons"; @@ -174,74 +180,34 @@ export const SidebarActivityRail = memo(({ expanded = false }: SidebarActivityRa openSidebarView(view); }; - const activityRailVisibilityItems = useMemo( - () => [ - { - id: "files", - label: t("workbench.project"), - icon: , - }, - ...(coreFeatures.search - ? [ - { - id: "search", - label: t("workbench.search"), - icon: , - }, - ] - : []), - ...(coreFeatures.git - ? [ - { - id: "git", - label: t("workbench.changes"), - icon: , - }, - { - id: "gitLog", - label: t("workbench.gitLog"), - icon: , - }, - ] - : []), - ...(coreFeatures.terminal - ? [ - { - id: "terminal", - label: t("workbench.terminal"), - icon: , - }, - ] - : []), - ...(coreFeatures.diagnostics - ? [ - { - id: "diagnostics", - label: t("workbench.diagnostics"), - icon: , - }, - ] - : []), - { - id: "run", - label: t("workbench.run"), - icon: , - }, - { - id: "settings", - label: t("workbench.settings"), - icon: , - }, - ], - [coreFeatures.diagnostics, coreFeatures.git, coreFeatures.search, coreFeatures.terminal, t], - ); + const activityRailVisibilityItems = useMemo(() => { + const items = new Map< + SidebarActivityItemId, + { id: SidebarActivityItemId; label: string; icon: ReactNode } + >([ + ["files", { id: "files", label: t("workbench.project"), icon: }], + ["git", { id: "git", label: t("workbench.changes"), icon: }], + ["search", { id: "search", label: t("workbench.search"), icon: }], + ["maven", { id: "maven", label: t("workbench.maven"), icon: }], + ["run", { id: "run", label: t("workbench.run"), icon: }], + [ + "terminal", + { id: "terminal", label: t("workbench.terminal"), icon: }, + ], + [ + "diagnostics", + { id: "diagnostics", label: t("workbench.diagnostics"), icon: }, + ], + ["gitLog", { id: "gitLog", label: t("workbench.gitLog"), icon: }], + ["settings", { id: "settings", label: t("workbench.settings"), icon: }], + ]); + return sidebarActivityVisibilityItemIds(coreFeatures).map((id) => items.get(id)!); + }, [coreFeatures.diagnostics, coreFeatures.git, coreFeatures.search, coreFeatures.terminal, t]); const setActivityRailItemVisible = useCallback( - (itemId: string, visible: boolean) => { + (itemId: SidebarActivityItemId, visible: boolean) => { const currentHiddenItems = useSettingsStore.getState().settings.hiddenSidebarActivityItems; - const nextHiddenItems = visible - ? currentHiddenItems.filter((hiddenItemId) => hiddenItemId !== itemId) - : Array.from(new Set([...currentHiddenItems, itemId])); + const nextHiddenItems = setSidebarActivityItemVisibility(currentHiddenItems, itemId, visible); void updateSetting("hiddenSidebarActivityItems", nextHiddenItems); }, diff --git a/windows/tauri/src/features/layout/config/item-order.test.ts b/windows/tauri/src/features/layout/config/item-order.test.ts index bb4b2674..17d3ac3f 100644 --- a/windows/tauri/src/features/layout/config/item-order.test.ts +++ b/windows/tauri/src/features/layout/config/item-order.test.ts @@ -4,6 +4,8 @@ import { SIDEBAR_ACTIVITY_ITEM_IDS, SIDEBAR_BOTTOM_ACTIVITY_ITEM_IDS, normalizeItemOrder, + setSidebarActivityItemVisibility, + sidebarActivityVisibilityItemIds, } from "./item-order"; describe("footer item order", () => { @@ -23,6 +25,34 @@ describe("footer item order", () => { }); describe("sidebar activity order", () => { + test("includes Maven in the default visibility order", () => { + expect( + sidebarActivityVisibilityItemIds({ + search: true, + git: true, + terminal: true, + diagnostics: true, + }), + ).toEqual([ + "files", + "git", + "search", + "maven", + "run", + "terminal", + "diagnostics", + "gitLog", + "settings", + ]); + }); + + test("hides and restores Maven independently", () => { + const hidden = setSidebarActivityItemVisibility([], "maven", false); + + expect(hidden).toEqual(["maven"]); + expect(setSidebarActivityItemVisibility(hidden, "maven", true)).toEqual([]); + }); + test("does not expose an unavailable Database placeholder", () => { expect([...SIDEBAR_ACTIVITY_ITEM_IDS]).not.toContain("database"); }); diff --git a/windows/tauri/src/features/layout/config/item-order.ts b/windows/tauri/src/features/layout/config/item-order.ts index 6afde5cc..fa8edb7d 100644 --- a/windows/tauri/src/features/layout/config/item-order.ts +++ b/windows/tauri/src/features/layout/config/item-order.ts @@ -31,6 +31,35 @@ export type SidebarActivityItemId = (typeof SIDEBAR_ACTIVITY_ITEM_IDS)[number]; export type FooterLeadingItemId = (typeof FOOTER_LEADING_ITEM_IDS)[number] | "debugger"; export type FooterTrailingItemId = (typeof FOOTER_TRAILING_ITEM_IDS)[number]; +interface SidebarActivityVisibilityFeatures { + search: boolean; + git: boolean; + terminal: boolean; + diagnostics: boolean; +} + +export function sidebarActivityVisibilityItemIds( + features: SidebarActivityVisibilityFeatures, +): SidebarActivityItemId[] { + return SIDEBAR_ACTIVITY_ITEM_IDS.filter((id) => { + if (id === "search") return features.search; + if (id === "git" || id === "gitLog") return features.git; + if (id === "terminal") return features.terminal; + if (id === "diagnostics") return features.diagnostics; + return true; + }); +} + +export function setSidebarActivityItemVisibility( + hiddenItemIds: readonly string[], + itemId: SidebarActivityItemId, + visible: boolean, +): string[] { + return visible + ? hiddenItemIds.filter((hiddenItemId) => hiddenItemId !== itemId) + : [...new Set([...hiddenItemIds, itemId])]; +} + export function normalizeItemOrder( persistedOrder: readonly T[] | undefined, defaultOrder: readonly T[], diff --git a/windows/tauri/src/features/maven/components/maven-pane.tsx b/windows/tauri/src/features/maven/components/maven-pane.tsx index be2242ea..02bcd39b 100644 --- a/windows/tauri/src/features/maven/components/maven-pane.tsx +++ b/windows/tauri/src/features/maven/components/maven-pane.tsx @@ -1,7 +1,9 @@ import { useEffect, useMemo, useState, type ReactNode } from "react"; import { open } from "@tauri-apps/plugin-dialog"; -import { getJavaWorkspaceLanguageServerOwner } from "@/features/editor/lsp/java-workspace-language-server"; import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { useActiveWorkspaceId } from "@/features/workspace/stores/create-workspace-scoped-store"; +import { workspaceScopeMatchesRoot } from "@/features/workspace/types/workspace-launch-scope"; import { RunOutputText } from "@/features/run/components/run-output-text"; import { useUIState } from "@/features/window/stores/ui-state.store"; import { useTranslation } from "@/i18n/locale-provider"; @@ -34,6 +36,10 @@ import { joinPath } from "@/utils/path-helpers"; import { cn } from "@/utils/cn"; import { ensureMavenProcessListeners } from "../hooks/use-maven-process-events"; import { availableMavenProfiles, useMavenStore } from "../stores/maven.store"; +import { + reloadJavaForMavenWorkspace, + reloadMavenWorkspaceProjects, +} from "../services/reload-maven-workspace"; import { MAVEN_LIFECYCLE_PHASES, type MavenLifecyclePhase, @@ -216,6 +222,7 @@ function MavenSettingsDialog({ export default function MavenPane() { const { t } = useTranslation(); + const workspaceId = useActiveWorkspaceId(); const root = useMavenStore((state) => state.root); const visiblePaths = useMavenStore((state) => state.visiblePaths); const projectStatus = useMavenStore((state) => state.projectStatus); @@ -258,6 +265,10 @@ export default function MavenPane() { void ensureMavenProcessListeners(); }, []); + useEffect(() => { + setReloadError(null); + }, [root, workspaceId]); + useEffect(() => { if (!project) return; const initial = new Set([`project:${project.relativePath}`]); @@ -299,26 +310,34 @@ export default function MavenPane() { const reloadJava = async () => { if (!root) return; + const scope = { workspaceId, root }; setReloadError(null); try { - const files = await useFileSystemStore.getState().getAllProjectFiles(); - const javaFile = files - .filter((entry) => !entry.isDir && entry.path.toLowerCase().endsWith(".java")) - .map((entry) => entry.path) - .sort()[0]; - const owner = getJavaWorkspaceLanguageServerOwner(); - await owner.stop(root); - if (javaFile) await owner.prewarm(root, javaFile); - actions.acknowledgeReload(); + await reloadJavaForMavenWorkspace(scope); } catch (error) { - setReloadError(error instanceof Error ? error.message : t("maven.reloadFailed")); + if ( + workspaceRuntimeRegistry.getActiveWorkspaceId() === workspaceId && + workspaceScopeMatchesRoot(scope, useMavenStore.getStore(workspaceId).getState().root) + ) { + setReloadError(error instanceof Error ? error.message : t("maven.reloadFailed")); + } } }; const reloadProjects = async () => { if (!root) return; - await actions.loadProject(root, visiblePaths); - if (useMavenStore.getState().project) await reloadJava(); + const scope = { workspaceId, root }; + setReloadError(null); + try { + await reloadMavenWorkspaceProjects(scope); + } catch (error) { + if ( + workspaceRuntimeRegistry.getActiveWorkspaceId() === workspaceId && + workspaceScopeMatchesRoot(scope, useMavenStore.getStore(workspaceId).getState().root) + ) { + setReloadError(error instanceof Error ? error.message : t("maven.reloadFailed")); + } + } }; const openIssue = (path: string, line: number, column?: number | null) => { diff --git a/windows/tauri/src/features/maven/services/reload-maven-workspace.test.ts b/windows/tauri/src/features/maven/services/reload-maven-workspace.test.ts new file mode 100644 index 00000000..37b15057 --- /dev/null +++ b/windows/tauri/src/features/maven/services/reload-maven-workspace.test.ts @@ -0,0 +1,156 @@ +import { afterEach, expect, mock, test } from "bun:test"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import type { MavenProject } from "../types/maven.types"; +import { + reloadJavaForMavenWorkspace, + reloadMavenWorkspaceProjects, +} from "./reload-maven-workspace"; + +afterEach(() => workspaceRuntimeRegistry.resetForTests()); + +type Deferred = { + promise: Promise; + resolve(value: T): void; +}; + +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +function mavenProject(artifactId: string): MavenProject { + return { + relativePath: ".", + artifactId, + packaging: "jar", + modules: [], + profiles: [], + hasWrapper: true, + }; +} + +test("finishes workspace A reload without reading or mutating active workspace B", async () => { + const scanStarted = deferred(); + const finishScan = deferred(); + const acknowledgeA = mock(() => undefined); + const getFilesA = mock(async () => [ + { name: "Main.java", path: "D:/work-a/src/Main.java", isDir: false }, + ]); + const getFilesB = mock(async () => [ + { name: "Wrong.java", path: "D:/work-b/src/Wrong.java", isDir: false }, + ]); + const mavenA = { + root: "D:/work-a" as string | null, + visiblePaths: ["pom.xml"], + project: mavenProject("old-a") as MavenProject | null, + activeSessionId: null as string | null, + output: "A output", + actions: { + loadProject: mock(async () => { + scanStarted.resolve(undefined); + await finishScan.promise; + mavenA.project = mavenProject("new-a"); + }), + acknowledgeReload: acknowledgeA, + }, + }; + const mavenB = { + root: "D:/work-b" as string | null, + visiblePaths: ["pom.xml"], + project: mavenProject("project-b") as MavenProject | null, + activeSessionId: "session-b", + output: "B output", + actions: { + loadProject: mock(async () => undefined), + acknowledgeReload: mock(() => undefined), + }, + }; + const fileSystemA = { rootFolderPath: "D:/work-a", getAllProjectFiles: getFilesA }; + const fileSystemB = { rootFolderPath: "D:/work-b", getAllProjectFiles: getFilesB }; + const stop = mock(async () => undefined); + const prewarm = mock(async () => ({ kind: "ready" })); + const mavenStates = new Map([ + ["workspace-a", mavenA], + ["workspace-b", mavenB], + ]); + const fileSystemStates = new Map([ + ["workspace-a", fileSystemA], + ["workspace-b", fileSystemB], + ]); + const scopeA = { workspaceId: "workspace-a", root: "D:/work-a" }; + workspaceRuntimeRegistry.ensureWorkspace({ id: "workspace-a", name: "A" }, "ready"); + const getMavenState = mock((workspaceId: string) => mavenStates.get(workspaceId)!); + const getFileSystemState = mock((workspaceId: string) => fileSystemStates.get(workspaceId)!); + const reload = reloadMavenWorkspaceProjects(scopeA, { + hasWorkspace: (workspaceId) => workspaceRuntimeRegistry.hasWorkspace(workspaceId), + getMavenState, + getFileSystemState, + getJavaOwner: () => ({ stop, prewarm }), + }); + + try { + await scanStarted.promise; + workspaceRuntimeRegistry.activateWorkspace({ id: "workspace-b", name: "B" }, "ready"); + const workspaceBBefore = { + project: mavenB.project, + activeSessionId: mavenB.activeSessionId, + output: mavenB.output, + rootFolderPath: fileSystemB.rootFolderPath, + }; + finishScan.resolve(undefined); + + expect(await reload).toBe("completed"); + expect(workspaceRuntimeRegistry.getActiveWorkspaceId()).toBe("workspace-b"); + expect({ + project: mavenB.project, + activeSessionId: mavenB.activeSessionId, + output: mavenB.output, + rootFolderPath: fileSystemB.rootFolderPath, + }).toEqual(workspaceBBefore); + expect(mavenB.actions.loadProject).not.toHaveBeenCalled(); + expect(mavenB.actions.acknowledgeReload).not.toHaveBeenCalled(); + expect(getFilesB).not.toHaveBeenCalled(); + expect(getMavenState.mock.calls.every(([workspaceId]) => workspaceId === "workspace-a")).toBe( + true, + ); + expect( + getFileSystemState.mock.calls.every(([workspaceId]) => workspaceId === "workspace-a"), + ).toBe(true); + expect(stop).toHaveBeenCalledWith(scopeA); + expect(prewarm).toHaveBeenCalledWith(scopeA, "D:/work-a/src/Main.java"); + expect(acknowledgeA).toHaveBeenCalledTimes(1); + } finally { + finishScan.resolve(undefined); + await reload; + } +}); + +test("does not recreate stores after the workspace is closed", async () => { + const getMavenState = mock(() => { + throw new Error("Maven store must not be recreated"); + }); + const getFileSystemState = mock(() => { + throw new Error("File-system store must not be recreated"); + }); + const getJavaOwner = mock(() => { + throw new Error("Java owner must not be resolved"); + }); + + const outcome = await reloadJavaForMavenWorkspace( + { workspaceId: "closed-workspace", root: "D:/closed" }, + { + hasWorkspace: () => false, + getMavenState, + getFileSystemState, + getJavaOwner, + }, + ); + + expect(outcome).toBe("stale"); + expect(getMavenState).not.toHaveBeenCalled(); + expect(getFileSystemState).not.toHaveBeenCalled(); + expect(getJavaOwner).not.toHaveBeenCalled(); +}); diff --git a/windows/tauri/src/features/maven/services/reload-maven-workspace.ts b/windows/tauri/src/features/maven/services/reload-maven-workspace.ts new file mode 100644 index 00000000..1c3a8d67 --- /dev/null +++ b/windows/tauri/src/features/maven/services/reload-maven-workspace.ts @@ -0,0 +1,102 @@ +import { getJavaWorkspaceLanguageServerOwner } from "@/features/editor/lsp/java-workspace-language-server"; +import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; +import type { FileEntry } from "@/features/file-system/types/app.types"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { + workspaceScopeMatchesRoot, + type WorkspaceLaunchScope, +} from "@/features/workspace/types/workspace-launch-scope"; +import type { MavenProject } from "../types/maven.types"; +import { useMavenStore } from "../stores/maven.store"; + +interface MavenReloadState { + root: string | null; + visiblePaths: string[]; + project: MavenProject | null; + actions: { + loadProject(root: string, visiblePaths?: string[]): Promise; + acknowledgeReload(): void; + }; +} + +interface FileSystemReloadState { + rootFolderPath?: string; + getAllProjectFiles(): Promise; +} + +interface JavaWorkspaceReloadOwner { + stop(scope: WorkspaceLaunchScope): Promise; + prewarm(scope: WorkspaceLaunchScope, representativeJavaFile: string): Promise; +} + +export interface MavenWorkspaceReloadDependencies { + hasWorkspace(workspaceId: string): boolean; + getMavenState(workspaceId: string): MavenReloadState; + getFileSystemState(workspaceId: string): FileSystemReloadState; + getJavaOwner(): JavaWorkspaceReloadOwner; +} + +export type MavenWorkspaceReloadOutcome = "completed" | "noProject" | "stale"; + +const defaultDependencies: MavenWorkspaceReloadDependencies = { + hasWorkspace: (workspaceId) => workspaceRuntimeRegistry.hasWorkspace(workspaceId), + getMavenState: (workspaceId) => useMavenStore.getStore(workspaceId).getState(), + getFileSystemState: (workspaceId) => useFileSystemStore.getStore(workspaceId).getState(), + getJavaOwner: getJavaWorkspaceLanguageServerOwner, +}; + +function scopedStates( + scope: WorkspaceLaunchScope, + dependencies: MavenWorkspaceReloadDependencies, +): { maven: MavenReloadState; fileSystem: FileSystemReloadState } | null { + if (!dependencies.hasWorkspace(scope.workspaceId)) return null; + const maven = dependencies.getMavenState(scope.workspaceId); + const fileSystem = dependencies.getFileSystemState(scope.workspaceId); + return workspaceScopeMatchesRoot(scope, maven.root) && + workspaceScopeMatchesRoot(scope, fileSystem.rootFolderPath) + ? { maven, fileSystem } + : null; +} + +export async function reloadJavaForMavenWorkspace( + scope: WorkspaceLaunchScope, + dependencies: MavenWorkspaceReloadDependencies = defaultDependencies, +): Promise { + let states = scopedStates(scope, dependencies); + if (!states) return "stale"; + + const files = await states.fileSystem.getAllProjectFiles(); + states = scopedStates(scope, dependencies); + if (!states) return "stale"; + + const javaFile = files + .filter((entry) => !entry.isDir && entry.path.toLowerCase().endsWith(".java")) + .map((entry) => entry.path) + .sort()[0]; + const owner = dependencies.getJavaOwner(); + await owner.stop(scope); + + states = scopedStates(scope, dependencies); + if (!states) return "stale"; + if (javaFile) await owner.prewarm(scope, javaFile); + + states = scopedStates(scope, dependencies); + if (!states) return "stale"; + states.maven.actions.acknowledgeReload(); + return "completed"; +} + +export async function reloadMavenWorkspaceProjects( + scope: WorkspaceLaunchScope, + dependencies: MavenWorkspaceReloadDependencies = defaultDependencies, +): Promise { + let states = scopedStates(scope, dependencies); + if (!states) return "stale"; + + await states.maven.actions.loadProject(scope.root, [...states.maven.visiblePaths]); + states = scopedStates(scope, dependencies); + if (!states) return "stale"; + if (!states.maven.project) return "noProject"; + + return reloadJavaForMavenWorkspace(scope, dependencies); +} diff --git a/windows/tauri/src/features/maven/stores/maven.store.test.ts b/windows/tauri/src/features/maven/stores/maven.store.test.ts index ab008831..d56823c9 100644 --- a/windows/tauri/src/features/maven/stores/maven.store.test.ts +++ b/windows/tauri/src/features/maven/stores/maven.store.test.ts @@ -1,11 +1,18 @@ -import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import type { MavenDiagnostic, MavenLaunchPlan, MavenProject, MavenStoredConfiguration, } from "../types/maven.types"; -import { createMavenStore, mavenLaunchContext, type MavenStoreDependencies } from "./maven.store"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { + createMavenStore, + mavenLaunchContext, + mavenLaunchContextForWorkspace, + useMavenStore, + type MavenStoreDependencies, +} from "./maven.store"; type Deferred = { promise: Promise; @@ -93,7 +100,51 @@ beforeEach(() => { stopMavenProcess.mockClear(); }); +afterEach(() => workspaceRuntimeRegistry.resetForTests()); + describe("Maven workspace state", () => { + test("resolves workspace A without mutating active workspace B", async () => { + const workspaceA = useMavenStore.getStore("workspace-a"); + const workspaceB = useMavenStore.getStore("workspace-b"); + const loadWorkspaceB = mock(async () => undefined); + workspaceA.setState({ + root: "D:/work-a", + projectStatus: "ready", + project: { ...project, artifactId: "project-a" }, + }); + workspaceB.setState((state) => ({ + root: "D:/work-b", + projectStatus: "ready", + project: { ...project, artifactId: "project-b" }, + activeSessionId: "session-b", + output: "B output", + actions: { ...state.actions, loadProject: loadWorkspaceB }, + })); + workspaceRuntimeRegistry.activateWorkspace({ id: "workspace-b", name: "B" }, "ready"); + const workspaceBBefore = { + root: workspaceB.getState().root, + project: workspaceB.getState().project, + activeSessionId: workspaceB.getState().activeSessionId, + output: workspaceB.getState().output, + }; + + const context = await mavenLaunchContextForWorkspace( + "D:/work-a", + ["src/Main.java"], + "workspace-a", + ); + + expect(context?.reactorPath).toBe("reactor"); + expect(workspaceRuntimeRegistry.getActiveWorkspaceId()).toBe("workspace-b"); + expect({ + root: workspaceB.getState().root, + project: workspaceB.getState().project, + activeSessionId: workspaceB.getState().activeSessionId, + output: workspaceB.getState().output, + }).toEqual(workspaceBBefore); + expect(loadWorkspaceB).not.toHaveBeenCalled(); + }); + test("restores portable selections and machine-local paths into one launch context", async () => { loadMavenConfiguration.mockResolvedValue({ portable: { diff --git a/windows/tauri/src/features/workspace/types/workspace-launch-scope.ts b/windows/tauri/src/features/workspace/types/workspace-launch-scope.ts new file mode 100644 index 00000000..9fca4995 --- /dev/null +++ b/windows/tauri/src/features/workspace/types/workspace-launch-scope.ts @@ -0,0 +1,26 @@ +import { normalizePath, stripTrailingPathSeparators } from "@/utils/path-helpers"; + +export interface WorkspaceLaunchScope { + workspaceId: string; + root: string; +} + +function workspaceRootKey(root: string): string { + const normalized = normalizePath(stripTrailingPathSeparators(root)); + return /^(?:[A-Za-z]:\/|\/\/)/.test(normalized) ? normalized.toLowerCase() : normalized; +} + +export function workspaceScopeMatchesRoot( + scope: WorkspaceLaunchScope, + root: string | null | undefined, +): boolean { + if (!root) return false; + return workspaceRootKey(root) === workspaceRootKey(scope.root); +} + +export function workspaceScopesMatch( + left: WorkspaceLaunchScope, + right: WorkspaceLaunchScope, +): boolean { + return left.workspaceId === right.workspaceId && workspaceScopeMatchesRoot(left, right.root); +} diff --git a/windows/tauri/src/i18n/locale.test.ts b/windows/tauri/src/i18n/locale.test.ts index eaeb9e50..3e0fbcdb 100644 --- a/windows/tauri/src/i18n/locale.test.ts +++ b/windows/tauri/src/i18n/locale.test.ts @@ -37,6 +37,8 @@ describe("Windows display language", () => { expect(translate("git.log.headCurrentBranch")).toBe("HEAD(当前分支)"); expect(translate("run.identifyAndGenerate")).toBe("识别并生成"); expect(createTranslator("en-US")("workbench.run")).toBe("Run"); + expect(createTranslator("en-US")("workbench.maven")).toBe("Maven"); + expect(translate("workbench.maven")).toBe("Maven"); expect(createTranslator("en-US")("titleProject.closeProject", { name: "Lithe" })).toBe( "Close project Lithe", ); diff --git a/windows/tauri/src/i18n/locale.ts b/windows/tauri/src/i18n/locale.ts index 5964e6c7..6c31db58 100644 --- a/windows/tauri/src/i18n/locale.ts +++ b/windows/tauri/src/i18n/locale.ts @@ -1328,6 +1328,7 @@ const catalogs = { "workbench.database": "Database", "workbench.settings": "Settings", "workbench.run": "Run", + "workbench.maven": "Maven", "workbench.terminal": "Terminal", "workbench.diagnostics": "Diagnostics", "run.title": "Run", @@ -5148,6 +5149,7 @@ const catalogs = { "workbench.database": "数据库", "workbench.settings": "设置", "workbench.run": "运行", + "workbench.maven": "Maven", "workbench.terminal": "终端", "workbench.diagnostics": "诊断", "run.title": "运行",