From 799f11bb73c3a415f959287de681c18865c8a13c Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Tue, 27 Oct 2026 23:27:48 -0700 Subject: [PATCH 1/2] fix(run): share launch environment and resilient workspace policies --- ...-java-project-build-and-launch-boundary.md | 13 + ...ernal-document-changes-and-guarded-save.md | 14 + ...026-09-22-run-panel-fingerprint-loading.md | 16 ++ macos/Resources/en.lproj/Localizable.strings | 4 + .../zh-Hans.lproj/Localizable.strings | 4 + .../Ports/RunLaunchEnvironmentResolving.swift | 11 + .../FileWatching/MacDirectoryWatcher.swift | 19 +- .../Platform/MacOS/MacServiceContainer.swift | 3 +- .../MacRunLaunchEnvironment.swift | 41 +++ .../Services/Java/RunExecutableResolver.swift | 17 +- macos/Sources/Lithe/Views/Run/RunView.swift | 7 + .../RunLaunchEnvironmentTests.swift | 29 ++ .../lithe-core/src/execution/configuration.rs | 249 ++++++++++++------ .../src/execution/detectors/scan.rs | 101 +------ rust/lithe-core/src/execution/environment.rs | 112 ++++++++ rust/lithe-core/src/execution/mod.rs | 8 + .../src/execution/workspace_scan.rs | 225 ++++++++++++++++ rust/lithe-core/src/lib.rs | 3 + rust/lithe-core/src/project/files.rs | 39 +++ rust/lithe-core/src/project/mod.rs | 2 + rust/lithe-core/src/protocol/command.rs | 6 + rust/lithe-core/src/runtime/dispatcher.rs | 35 +++ .../lithe-core/src/tests/run_configuration.rs | 69 +++++ shared/contracts/application-boundary.md | 14 + .../run-configuration-v2.schema.json | 1 + shared/contracts/rust-core-api.md | 14 + .../execution/launch-environment-v1.json | 145 ++++++++++ .../fixtures/workspace/change-filter-v1.json | 25 ++ shared/i18n/locales/en-US.json | 4 +- shared/i18n/locales/zh-CN.json | 4 +- shared/i18n/messages.json | 24 ++ .../features/file-explorer.json | 9 +- .../features/run-discovery.json | 10 +- .../features/run-save-toolchain.json | 10 +- windows/tauri/crates/project/Cargo.toml | 1 + windows/tauri/crates/project/src/lib.rs | 205 +++++++++----- windows/tauri/src-tauri/Cargo.lock | 22 ++ windows/tauri/src-tauri/src/run.rs | 193 ++++++-------- .../src/features/run/components/run-pane.tsx | 7 +- .../run/utils/run-configuration.test.ts | 7 + .../features/run/utils/run-configuration.ts | 4 +- windows/tauri/src/i18n/shared.generated.ts | 4 + 42 files changed, 1364 insertions(+), 366 deletions(-) create mode 100644 macos/Sources/Lithe/Core/Ports/RunLaunchEnvironmentResolving.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacRunLaunchEnvironment.swift create mode 100644 rust/lithe-core/src/execution/environment.rs create mode 100644 rust/lithe-core/src/execution/workspace_scan.rs create mode 100644 shared/fixtures/execution/launch-environment-v1.json create mode 100644 shared/fixtures/workspace/change-filter-v1.json diff --git a/.agents/notes/implemented/architecture/2026-09-18-java-project-build-and-launch-boundary.md b/.agents/notes/implemented/architecture/2026-09-18-java-project-build-and-launch-boundary.md index dcfe042ffc..7ae9ab367b 100644 --- a/.agents/notes/implemented/architecture/2026-09-18-java-project-build-and-launch-boundary.md +++ b/.agents/notes/implemented/architecture/2026-09-18-java-project-build-and-launch-boundary.md @@ -188,6 +188,19 @@ Run 控件,也不在 `java-run-launch` 或平台 adapter 再做一次 `blocksR 用户点击后可以在准备状态可见的同时排队等待 Core。否则快照更新时序会产生“面板 已就绪、启动仍被准备中拦截”的自相矛盾状态。 +### 每一步使用自己的工具链环境(#1216) + +Windows 曾先填入自动发现的 JAVA_HOME,再用“仅为空时插入”应用 Maven JDK, +导致用户选的 Maven JDK 永远无法覆盖先填的 Java 值。两个宿主现在调用 Core 的 +execution.resolveEnvironment:继承环境低于当前步骤选定的工具链,用户显式 +字符串最高。Maven 资源处理步骤即使继承主程序的 Java 绑定,也选择 Maven JDK; +后续 JVM 保持项目 JDK。Windows 环境键按不区分大小写处理。 + +平台仍负责定位可执行文件与 JDK home,Core 不探测机器、不执行 Maven,也不 +复制上游构建模型。不要在 Windows 调换两次 insert 就结束:这会让后续前置 +步骤继续各自解释优先级。共享 fixture 和真实 Windows 宿主解析测试覆盖选定 +Maven JDK、显式覆盖及资源步骤;#1133 的 Maven 原生资源步骤保留,不改成手工复制。 + ## 考虑过的备选方案 - 先执行 `mvn compile`,再手工猜 `target/classes` 与依赖:被否。自定义输出目录、 diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-external-document-changes-and-guarded-save.md b/.agents/notes/implemented/bug-fix/2026-09-15-external-document-changes-and-guarded-save.md index bd4cbebe3a..023d9d41f7 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-15-external-document-changes-and-guarded-save.md +++ b/.agents/notes/implemented/bug-fix/2026-09-15-external-document-changes-and-guarded-save.md @@ -134,6 +134,20 @@ Windows 是独立前端实现,写入进行中到期的那次延迟自动保存 磁盘 B 不符,B 和 C 都保留。认可 B 后外部又写入 D,仍必须重新提示。 不要把“保留编辑器版本”实现成永久 `force=true`。 +### 构建输出先筛选,再判断事件洪峰(#1176) + +512 条阈值现在统计筛选后的有效变化。普通工作区事件由 Core 的默认目录策略 +排除构建输出;Windows 原生宿主在跨 Tauri 边界前筛选,macOS 使用同一批量 +命令。相对路径以最具体的显式监听根计算,项目根本身叫 build 不会被误排除。 +显式监听的文件与生成源码目录仍有自己的事件。重命名先分解两端,再独立筛选, +从 target 移入 src 的文件仍能出现。 + +系统报告事件丢失或监听失败时始终恢复重扫,不能因为路径在 target 下就忽略。 +不采用暂停 watcher、延迟几秒或屏蔽整批事件:构建期间可能同时编辑源码。 +代价是原生 watcher 仍收集构建输出,改善的是跨平台业务刷新量,不宣称减少 +操作系统通知量;Git 和已打开文档监听保持独立。共享 fixture、输出洪峰混合 +源码、显式监听、跨过滤边界重命名及原生丢失标记回归共同约束该边界。 + ## 考虑过的备选方案 - 无界的全项目递归监听会让资源和事件量随整个项目增长,也无法解决漏通知后的 diff --git a/.agents/notes/implemented/bug-fix/2026-09-22-run-panel-fingerprint-loading.md b/.agents/notes/implemented/bug-fix/2026-09-22-run-panel-fingerprint-loading.md index 23b64c237f..6043569b6c 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-22-run-panel-fingerprint-loading.md +++ b/.agents/notes/implemented/bug-fix/2026-09-22-run-panel-fingerprint-loading.md @@ -78,6 +78,22 @@ Current File 当作最终配置执行。配置文档可读不等于运行项目 工具链探测暂停首个加载,验证加载中不能启动、重叠加载选用配置默认项,以及 完成后的用户选择能够保留。不要通过增加 CI 等待时间掩盖这类状态提交竞态。 +### 不可读目录不是已删除目录(#1389) + +自动发现、指纹、Node 清单和 Windows Java 文件列表使用 Core 的同一遍历策略。 +先排除已知缓存和构建输出,再进入子目录;不能按“点开头”排除所有隐藏源码。 +工作区根不可读仍失败,可选子目录不可读则继续兄弟目录,并记录未知路径。 +重新生成保留未知路径下的旧配置及输入摘要;检查返回 incompleteProjectScan, +不从不完整快照推断删除或新鲜度。即使后续目录可读,旧基线也要完整重新生成后 +才能解除诊断。两端 Run 面板都必须接纳这个诊断代码,并通过共享 i18n 显示 +权限恢复与重新识别的提示;不能只在 Core 返回却被面板筛选隐藏。 +代价是权限恢复前可能保留一个实际已删除的入口,优于静默丢配置。 + +不要只给 Windows 加 catch 并返回空数组:这样会把访问失败写成成功的空快照, +且 macOS 的指纹仍使用另一套规则。扫描不接管 JDT 的 Java 入口事实或 Maven +声明模块;只替换已有的启发式文件遍历。注入目录读取失败的测试覆盖兄弟目录、 +必需根失败和链接不跟随,保留测试覆盖旧配置与输入摘要的未知/已删除区别。 + ## 考虑过的备选方案 - 持久化大小和修改时间以复用摘要:同大小文件可在保留时间戳时发生内容变化, diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index ac90fb089e..f8286a0e39 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -2754,6 +2754,7 @@ "Project run configuration is invalid" = "Project run configuration is invalid"; "Project run configuration not found" = "Project run configuration not found"; "Project runtime settings are saved locally for this project." = "Project runtime settings are saved locally for this project."; +"Project scan is incomplete" = "Project scan is incomplete"; "Project skills" = "Project skills"; "Project toolchain needs attention" = "Project toolchain needs attention"; "Project will be created in: %@" = "Project will be created in: %@"; @@ -3488,6 +3489,7 @@ "Solidity Support" = "Solidity Support"; "Some MCP configuration files could not be restored: %@. Refresh and check these files before retrying." = "Some MCP configuration files could not be restored: %@. Refresh and check these files before retrying."; "Some dependencies are not shown." = "Some dependencies are not shown."; +"Some project folders could not be read. Existing run configurations were retained. Restore access, then regenerate configurations." = "Some project folders could not be read. Existing run configurations were retained. Restore access, then regenerate configurations."; "Sort by Last Updated" = "Sort by Last Updated"; "Sort by Name" = "Sort by Name"; "Sort plugins" = "Sort plugins"; @@ -8985,6 +8987,8 @@ "results" = "results"; "revert" = "revert"; "run configuration" = "run configuration"; +"run.incompleteProjectScanMessage" = "Some project folders could not be read. Existing run configurations were retained. Restore access, then regenerate configurations."; +"run.incompleteProjectScanTitle" = "Project scan is incomplete"; "running for %@" = "running for %@"; "scope managed from %@" = "scope managed from %@"; "scope not updated to %@" = "scope not updated to %@"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index d4d87f70de..2e0cd8f4be 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -2754,6 +2754,7 @@ "Project run configuration is invalid" = "项目运行配置无效"; "Project run configuration not found" = "未找到项目运行配置"; "Project runtime settings are saved locally for this project." = "项目运行时设置会保存在本机的项目配置中。"; +"Project scan is incomplete" = "项目扫描不完整"; "Project skills" = "项目技能"; "Project toolchain needs attention" = "项目工具链需要处理"; "Project will be created in: %@" = "项目将在以下位置创建:%@"; @@ -3488,6 +3489,7 @@ "Solidity Support" = "Solidity 支持"; "Some MCP configuration files could not be restored: %@. Refresh and check these files before retrying." = "部分 MCP 配置文件未能恢复:%@。请刷新并检查这些文件后重试。"; "Some dependencies are not shown." = "部分依赖未显示。"; +"Some project folders could not be read. Existing run configurations were retained. Restore access, then regenerate configurations." = "部分项目目录无法读取,已有运行配置已保留。请恢复访问权限后重新识别运行配置。"; "Sort by Last Updated" = "按最近更新排序"; "Sort by Name" = "按名称排序"; "Sort plugins" = "插件排序"; @@ -8985,6 +8987,8 @@ "results" = "个结果"; "revert" = "反向提交"; "run configuration" = "运行配置"; +"run.incompleteProjectScanMessage" = "部分项目目录无法读取,已有运行配置已保留。请恢复访问权限后重新识别运行配置。"; +"run.incompleteProjectScanTitle" = "项目扫描不完整"; "running for %@" = "已运行 %@"; "scope managed from %@" = "依赖管理前 scope 为 %@"; "scope not updated to %@" = "未提升为 %@ scope"; diff --git a/macos/Sources/Lithe/Core/Ports/RunLaunchEnvironmentResolving.swift b/macos/Sources/Lithe/Core/Ports/RunLaunchEnvironmentResolving.swift new file mode 100644 index 0000000000..819f101a8f --- /dev/null +++ b/macos/Sources/Lithe/Core/Ports/RunLaunchEnvironmentResolving.swift @@ -0,0 +1,11 @@ +import Foundation + +/// Platform discovery supplies homes; Core owns precedence for every launch step. +protocol RunLaunchEnvironmentResolving { + func resolveLaunchEnvironment( + toolchain: String?, + selectedHome: String?, + inherited: [String: String], + overrides: [String: String] + ) throws -> [String: String] +} diff --git a/macos/Sources/Lithe/Platform/MacOS/FileWatching/MacDirectoryWatcher.swift b/macos/Sources/Lithe/Platform/MacOS/FileWatching/MacDirectoryWatcher.swift index f0338d350f..34f9bba877 100644 --- a/macos/Sources/Lithe/Platform/MacOS/FileWatching/MacDirectoryWatcher.swift +++ b/macos/Sources/Lithe/Platform/MacOS/FileWatching/MacDirectoryWatcher.swift @@ -10,6 +10,7 @@ final class MacDirectoryWatcher: DirectoryChangeSource, @unchecked Sendable { } } + private let core = RustCoreBridge() private let configuration: DirectoryWatchConfiguration private let visibilityRules: FileVisibilityRules private let queue = DispatchQueue(label: "app.lithe.file-events", qos: .utility) @@ -175,7 +176,23 @@ final class MacDirectoryWatcher: DirectoryChangeSource, @unchecked Sendable { batch.requiresFullRescan = true batch.gitStateMayHaveChanged = true } - batch.workspacePaths = workspacePaths.sorted() + let rootPrefix = configuration.workspaceRoot.standardizedFileURL.path + "/" + let relativePaths = workspacePaths.sorted().map { path in + path.hasPrefix(rootPrefix) ? String(path.dropFirst(rootPrefix.count)) : "" + } + // One Core call per batch; native loss flags above remain unconditional. + let filtered: Result<[String], RustCoreBridge.CoreCallError> = core.executeResult( + command: "workspace.filterChanges", payload: relativePaths + ) + switch filtered { + case .success(let paths): + let accepted = Set(paths) + batch.workspacePaths = zip(workspacePaths.sorted(), relativePaths) + .filter { accepted.contains($0.1) }.map(\.0) + case .failure: + // A failed classifier cannot prove that a source change is irrelevant. + batch.requiresFullRescan = true + } return batch } diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index fa32f95e59..09240a73d8 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -498,7 +498,8 @@ final class MacServiceContainer { let executableResolver = providedRunExecutableResolver ?? RunExecutableResolver( runtimeService: runtimeService, toolchainRegistry: runToolchainRegistry, - metadataResolver: ProcessRunToolchainMetadataResolver(processRunner: processRunner) + metadataResolver: ProcessRunToolchainMetadataResolver(processRunner: processRunner), + environmentResolver: rustCore ) let buildSystemPlugins = pluginStartup.registeredPlugins.filter { !$0.descriptor.buildSystems.isEmpty } let buildSystems = buildSystemPlugins.isEmpty ? nil : BuildSystemService( diff --git a/macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacRunLaunchEnvironment.swift b/macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacRunLaunchEnvironment.swift new file mode 100644 index 0000000000..46a5e68ca6 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacRunLaunchEnvironment.swift @@ -0,0 +1,41 @@ +import Foundation + +extension RustCoreBridge: RunLaunchEnvironmentResolving { + private struct LaunchEnvironmentPayload: Encodable { + let toolchain: String? + let javaHome: String? + let mavenJavaHome: String? + let inherited: [String: String] + let overrides: [String: String] + } + + private struct LaunchEnvironmentResult: Decodable { + let environment: [String: String] + } + + func resolveLaunchEnvironment( + toolchain: String?, selectedHome: String?, + inherited: [String: String], overrides: [String: String] + ) throws -> [String: String] { + let result: LaunchEnvironmentResult = try executeResult( + command: "execution.resolveEnvironment", + payload: LaunchEnvironmentPayload( + toolchain: toolchain, javaHome: selectedHome, mavenJavaHome: selectedHome, + inherited: inherited, overrides: overrides + ) + ).get() + return result.environment + } +} + +@MainActor +extension RunExecutableResolver { + convenience init( + runtimeService: ProjectRuntimeService, + toolchainRegistry: RunToolchainRegistry? = nil, + metadataResolver: (any RunToolchainMetadataResolving)? = nil + ) { + self.init(runtimeService: runtimeService, toolchainRegistry: toolchainRegistry, + metadataResolver: metadataResolver, environmentResolver: RustCoreBridge()) + } +} diff --git a/macos/Sources/Lithe/Services/Java/RunExecutableResolver.swift b/macos/Sources/Lithe/Services/Java/RunExecutableResolver.swift index 2f22d2c4ef..610abffd95 100644 --- a/macos/Sources/Lithe/Services/Java/RunExecutableResolver.swift +++ b/macos/Sources/Lithe/Services/Java/RunExecutableResolver.swift @@ -2,6 +2,7 @@ import Foundation @MainActor final class RunExecutableResolver: RunExecutableResolving { + private let environmentResolver: any RunLaunchEnvironmentResolving private let runtimeService: ProjectRuntimeService private let toolchainRegistry: RunToolchainRegistry private let metadataResolver: (any RunToolchainMetadataResolving)? @@ -10,8 +11,10 @@ final class RunExecutableResolver: RunExecutableResolving { init( runtimeService: ProjectRuntimeService, toolchainRegistry: RunToolchainRegistry? = nil, - metadataResolver: (any RunToolchainMetadataResolving)? = nil + metadataResolver: (any RunToolchainMetadataResolving)? = nil, + environmentResolver: any RunLaunchEnvironmentResolving ) { + self.environmentResolver = environmentResolver self.runtimeService = runtimeService self.toolchainRegistry = toolchainRegistry ?? .standard() self.metadataResolver = metadataResolver @@ -38,7 +41,7 @@ final class RunExecutableResolver: RunExecutableResolving { ) resolved = ResolvedRunExecutable( executableURL: toolchain.executableURL, - environment: toolchain.environment.merging(planEnvironment) { _, override in override } + environment: toolchain.environment ) case .command(let command): @@ -49,14 +52,20 @@ final class RunExecutableResolver: RunExecutableResolving { } resolved = ResolvedRunExecutable( executableURL: executable, - environment: runtimeService.processEnvironment(overrides: planEnvironment) + environment: runtimeService.processEnvironment() ) } + let identifier: String? + if case .toolchain(let id) = plan.executable { identifier = id } else { identifier = nil } + let environment = try environmentResolver.resolveLaunchEnvironment( + toolchain: identifier, selectedHome: resolved.environment["JAVA_HOME"], + inherited: resolved.environment, overrides: planEnvironment + ) return ResolvedRunExecutable( executableURL: resolved.executableURL, environment: RunLaunchEnvironment.augmenting( - resolved.environment, + environment, executableURL: resolved.executableURL, nodeCandidates: runtimeService.executableCandidates("node"), projectURL: projectURL, diff --git a/macos/Sources/Lithe/Views/Run/RunView.swift b/macos/Sources/Lithe/Views/Run/RunView.swift index 24b93f2f93..81aa3d8827 100644 --- a/macos/Sources/Lithe/Views/Run/RunView.swift +++ b/macos/Sources/Lithe/Views/Run/RunView.swift @@ -191,6 +191,13 @@ struct RunView: View { } private var configurationNotice: (title: String, message: String, systemImage: String)? { + if feature.configurationDiagnostics.contains(where: { $0.code == "incompleteProjectScan" }) { + return ( + runText("Project scan is incomplete"), + runText("Some project folders could not be read. Existing run configurations were retained. Restore access, then regenerate configurations."), + "exclamationmark.triangle.fill" + ) + } if let diagnostic = feature.configurationDiagnostics.first(where: { $0.code == "staleFingerprint" }) { return ( runText("Run configurations may be out of date"), diff --git a/macos/Tests/LitheTests/RunLaunchEnvironmentTests.swift b/macos/Tests/LitheTests/RunLaunchEnvironmentTests.swift index 1e12c6236b..b5f91a28f7 100644 --- a/macos/Tests/LitheTests/RunLaunchEnvironmentTests.swift +++ b/macos/Tests/LitheTests/RunLaunchEnvironmentTests.swift @@ -11,6 +11,35 @@ import Testing struct RunLaunchEnvironmentTests { private let projectURL = URL(fileURLWithPath: "/fixture-project", isDirectory: true) + @Test + func sharedWorkspaceChangeFilterCrossesTheNativeBridge() throws { + struct Fixture: Decodable { let paths: [String]; let expected: [String] } + let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent() + let fixture = try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: + root.appendingPathComponent("shared/fixtures/workspace/change-filter-v1.json"))) + let actual: [String] = try RustCoreBridge().executeResult( + command: "workspace.filterChanges", payload: fixture.paths + ).get() + #expect(actual == fixture.expected) + } + + @Test + func sharedCoreLaunchEnvironmentRetainsSelectionAndExplicitOverrides() throws { + let core = RustCoreBridge() + let selected = try core.resolveLaunchEnvironment( + toolchain: "project-maven", selectedHome: "/fixture/jdk21", + inherited: ["JAVA_HOME": "/fixture/jdk8", "PATH": "/fixture/bin"], overrides: [:] + ) + #expect(selected["JAVA_HOME"] == "/fixture/jdk21") + #expect(selected["PATH"] == "/fixture/bin") + let overridden = try core.resolveLaunchEnvironment( + toolchain: "project-maven", selectedHome: "/fixture/jdk21", + inherited: selected, overrides: ["JAVA_HOME": "/fixture/explicit"] + ) + #expect(overridden["JAVA_HOME"] == "/fixture/explicit") + } + @Test func guiLaunchPathReachesTheDiscoveredNodeInterpreter() { let environment = RunLaunchEnvironment.augmenting( diff --git a/rust/lithe-core/src/execution/configuration.rs b/rust/lithe-core/src/execution/configuration.rs index 87910c5954..73fec6ca0b 100644 --- a/rust/lithe-core/src/execution/configuration.rs +++ b/rust/lithe-core/src/execution/configuration.rs @@ -14,7 +14,7 @@ const VERSION: u32 = 2; const LEGACY_VERSION: u32 = 1; // Bumped when generation changes what a workspace should contain: existing // workspaces regenerate instead of keeping a stale `generated.json`. -const GENERATOR_REVISION: &str = "10"; +const GENERATOR_REVISION: &str = "11"; /// Toolchain requirements and `project.json` are separate documents that happen /// to live under `.lithe`. Their schema did not change with run-config v2, so /// they keep their own version and must not be validated against `VERSION`. @@ -284,6 +284,9 @@ fn migrate_configuration_value(item: &mut Value) { /// or to `path` for a Java source that only counts by presence. pub struct GeneratorMetadata { pub fingerprint: String, + /// Unreadable inputs retained as unknown; a partial baseline cannot prove freshness. + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + pub unreadable_paths: BTreeSet, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub inputs: BTreeMap, } @@ -437,6 +440,16 @@ pub fn inspect(request: InspectRequest) -> Result { validate_sidecar_version(document.version)?; } let mut diagnostics = Vec::new(); + if request.check_fingerprint == Some(false) { + if let Some(metadata) = generated + .as_ref() + .and_then(|document| document.generator.as_ref()) + { + if !metadata.unreadable_paths.is_empty() { + diagnostics.push(incomplete_scan_diagnostic(&metadata.unreadable_paths)); + } + } + } let local = local_layer_document(&root, request.local_document.clone())?; if let Some((document, metadata)) = generated .as_ref() @@ -444,7 +457,14 @@ pub fn inspect(request: InspectRequest) -> Result { .filter(|_| request.check_fingerprint != Some(false)) { let current_inputs = project_inputs(&root, &java_entry_sources(&document.configurations))?; - if metadata.fingerprint != fingerprint_from_inputs(¤t_inputs) { + if !current_inputs.unreadable_paths.is_empty() || !metadata.unreadable_paths.is_empty() { + let unknown = current_inputs + .unreadable_paths + .union(&metadata.unreadable_paths) + .cloned() + .collect(); + diagnostics.push(incomplete_scan_diagnostic(&unknown)); + } else if metadata.fingerprint != fingerprint_from_inputs(¤t_inputs.inputs) { // Stored inputs that no longer reproduce the stored fingerprint were // recorded by another generator revision, whose input rules (such // as which sources are hashed) may differ from today's. @@ -452,10 +472,10 @@ pub fn inspect(request: InspectRequest) -> Result { fingerprint_from_inputs(&metadata.inputs) != metadata.fingerprint; let message = if metadata.inputs.is_empty() { "Project inputs changed after run configuration generation".to_string() - } else if generator_changed || metadata.inputs == current_inputs { + } else if generator_changed || metadata.inputs == current_inputs.inputs { "Run configuration generator changed; regenerate configurations".to_string() } else { - input_change_summary(&metadata.inputs, ¤t_inputs) + input_change_summary(&metadata.inputs, ¤t_inputs.inputs) }; diagnostics.push(json!({ "code": "staleFingerprint", @@ -651,7 +671,6 @@ pub fn generate(request: GenerateRequest) -> Result { }) .collect::, _>>()?; let mut configurations = deduplicate_java_configurations(configurations); - let java_entry_count = configurations.len(); if has_java_sources { configurations.push(RunConfiguration { id: "current-file".to_string(), @@ -694,10 +713,20 @@ pub fn generate(request: GenerateRequest) -> Result { // Counts real entry points, not documents: the always-present "Current File" // fallback is excluded so an empty project still reports zero and the UI can // say so, while a detected npm service correctly reports one. - let entry_count = java_entry_count + detected.len(); configurations.extend(detected); + let mut snapshot = project_inputs(&root, &java_entry_sources(&configurations))?; + // Unknown subtrees cannot delete known configurations or baseline inputs. + // Invalid required documents still fail instead of being silently overwritten. + if !snapshot.unreadable_paths.is_empty() { + if let Some(previous) = read_document(&root, "run/generated.json")? { + retain_unknown_inputs(&mut configurations, &mut snapshot, previous); + } + } disambiguate_configuration_names(&mut configurations); - let inputs = project_inputs(&root, &java_entry_sources(&configurations))?; + let entry_count = configurations + .iter() + .filter(|item| item.id != "current-file") + .count(); let requirements = detect_requirements( &root, maven_root.as_ref().map(|(path, _)| path.as_path()), @@ -707,8 +736,9 @@ pub fn generate(request: GenerateRequest) -> Result { let generated = RunConfigurationDocument { version: VERSION, generator: Some(GeneratorMetadata { - fingerprint: fingerprint_from_inputs(&inputs), - inputs, + fingerprint: fingerprint_from_inputs(&snapshot.inputs), + inputs: snapshot.inputs, + unreadable_paths: snapshot.unreadable_paths, }), configurations, }; @@ -3085,38 +3115,92 @@ pub(super) fn java_feature_version_parts(parts: Vec) -> Vec { /// Collects generation inputs. Java sources count by path unless generation /// reads their content (`content_sources`); every other input is hashed. +struct ProjectInputs { + inputs: BTreeMap, + unreadable_paths: BTreeSet, +} + +// Retention is independent of filesystem permissions so both hosts preserve the +// same last-known configuration when an optional subtree becomes unreadable. +fn retain_unknown_inputs( + configurations: &mut Vec, + snapshot: &mut ProjectInputs, + previous: RunConfigurationDocument, +) { + for configuration in previous.configurations { + let source = configuration.source.as_deref().or_else(|| { + configuration + .extensions + .get("java")? + .get("source")? + .as_str() + }); + if source.is_some_and(|path| under_unknown(path, &snapshot.unreadable_paths)) + && !configurations + .iter() + .any(|current| current.id == configuration.id) + { + configurations.push(configuration); + } + } + if let Some(metadata) = previous.generator { + for (path, hash) in metadata.inputs { + if under_unknown(&path, &snapshot.unreadable_paths) { + snapshot.inputs.entry(path).or_insert(hash); + } + } + } +} + +fn under_unknown(path: &str, unknown: &BTreeSet) -> bool { + unknown + .iter() + .any(|prefix| path == prefix || path.starts_with(&format!("{prefix}/"))) +} + +fn incomplete_scan_diagnostic(paths: &BTreeSet) -> Value { + json!({"code": "incompleteProjectScan", "message": format!( + "Project scan is incomplete; freshness and removals could not be verified. Unreadable inputs: {}", + paths.iter().cloned().collect::>().join(", "))}) +} + fn project_inputs( root: &Path, content_sources: &BTreeSet, -) -> Result, CoreError> { - let mut files = Vec::new(); - let mut stack = vec![root.to_path_buf()]; - while let Some(dir) = stack.pop() { - for entry in fs::read_dir(dir)? { - let entry = entry?; - let path = entry.path(); - if path.is_dir() { - if !ignored_directory(&path) { - stack.push(path); +) -> Result { + let scan = super::workspace_scan::scan_run_workspace(root, None)?; + let mut result = ProjectInputs { + inputs: BTreeMap::new(), + unreadable_paths: scan.unreadable_paths, + }; + for directory in scan.directories { + for name in directory.files { + let path = directory.path.join(name); + if !fingerprint_input(&path) { + continue; + } + let relative = path + .strip_prefix(root) + .expect("scan stays within root") + .to_string_lossy() + .replace('\\', "/"); + if relative.ends_with(".java") && !content_sources.contains(&relative) { + result.inputs.insert(relative, PATH_ONLY_INPUT.to_string()); + continue; + } + crate::protocol::cancellation::check()?; + match fs::read(&path) { + Ok(bytes) => { + result + .inputs + .insert(relative, format!("sha256:{:x}", Sha256::digest(bytes))); } - } else if fingerprint_input(&path) { - if let Ok(relative) = path.strip_prefix(root) { - files.push(relative.to_string_lossy().replace('\\', "/")); + Err(_) => { + result.unreadable_paths.insert(relative); } } } } - files.sort(); - let mut result = BTreeMap::new(); - for relative in files { - if relative.ends_with(".java") && !content_sources.contains(&relative) { - result.insert(relative, PATH_ONLY_INPUT.to_string()); - continue; - } - if let Ok(bytes) = fs::read(root.join(&relative)) { - result.insert(relative, format!("sha256:{:x}", Sha256::digest(bytes))); - } - } Ok(result) } @@ -3154,29 +3238,6 @@ fn input_change_summary( format!("Project inputs changed: {added} added, {removed} removed, {changed} modified") } -fn ignored_directory(path: &Path) -> bool { - let Some(name) = path.file_name().and_then(|name| name.to_str()) else { - return false; - }; - name.eq_ignore_ascii_case(".worktree") - || name.eq_ignore_ascii_case(".worktrees") - || matches!( - name, - ".git" - | ".lithe" - | ".idea" - | ".gradle" - | ".venv" - | "venv" - | "node_modules" - | "vendor" - | "target" - | "build" - | "dist" - | "out" - ) -} - fn fingerprint_input(path: &Path) -> bool { if path.extension().and_then(|extension| extension.to_str()) == Some("java") { return true; @@ -3250,28 +3311,21 @@ fn highest_version(versions: Vec) -> Option { } fn project_manifest_paths(root: &Path, names: &[&str]) -> Vec { - let mut result = Vec::new(); - let mut stack = vec![root.to_path_buf()]; - while let Some(directory) = stack.pop() { - let Ok(entries) = fs::read_dir(directory) else { - continue; - }; - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - if !ignored_directory(&path) { - stack.push(path); - } - } else if path - .file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| names.contains(&name)) - { - result.push(path); - } - } - } - result + // This heuristic only supplements declared toolchain requirements. Fingerprint + // scanning separately reports unknown subtrees to both product hosts. + let Ok(scan) = super::workspace_scan::scan_run_workspace(root, None) else { + return Vec::new(); + }; + scan.directories + .into_iter() + .flat_map(|directory| { + directory + .files + .into_iter() + .filter(|name| names.contains(&name.as_str())) + .map(move |name| directory.path.join(name)) + }) + .collect() } fn declared_java_version(root: &Path) -> Option<(String, Option)> { @@ -3370,3 +3424,42 @@ fn valid_provider(value: &str) -> bool { .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') }) } + +#[cfg(test)] +mod incomplete_scan_tests { + use super::*; + + #[test] + fn unknown_inputs_retain_only_affected_configurations_and_hashes() { + let previous: RunConfigurationDocument = serde_json::from_value(json!({ + "version": 2, + "generator": {"fingerprint": "old", "inputs": { + "blocked/package.json": "old-blocked", "removed/package.json": "old-removed" + }}, + "configurations": [ + {"id":"blocked", "name":"Blocked", "provider":"npm.script", "source":"blocked/package.json"}, + {"id":"removed", "name":"Removed", "provider":"npm.script", "source":"removed/package.json"} + ] + })).unwrap(); + let mut configurations = Vec::new(); + let mut snapshot = ProjectInputs { + inputs: BTreeMap::from([("visible/package.json".into(), "new".into())]), + unreadable_paths: BTreeSet::from(["blocked".into()]), + }; + retain_unknown_inputs(&mut configurations, &mut snapshot, previous); + assert_eq!(configurations.len(), 1); + assert_eq!(configurations[0].id, "blocked"); + assert_eq!( + snapshot + .inputs + .get("blocked/package.json") + .map(String::as_str), + Some("old-blocked") + ); + assert!(!snapshot.inputs.contains_key("removed/package.json")); + assert!(!under_unknown( + "blocked-sibling/package.json", + &snapshot.unreadable_paths + )); + } +} diff --git a/rust/lithe-core/src/execution/detectors/scan.rs b/rust/lithe-core/src/execution/detectors/scan.rs index b54db4b383..a6576d47fd 100644 --- a/rust/lithe-core/src/execution/detectors/scan.rs +++ b/rust/lithe-core/src/execution/detectors/scan.rs @@ -8,49 +8,6 @@ use std::path::{Path, PathBuf}; /// Detectors only care about manifests near the top of a project. Anything /// deeper is vendored code or build output that slipped past the prune list. const MAX_DEPTH: usize = 6; -/// A ceiling on how many directories one project may contribute. Without it a -/// pathological tree turns project open into an unbounded walk. -const MAX_DIRECTORIES: usize = 4000; - -/// Directories that never contain a service worth offering, and whose contents -/// are large enough that descending into them would dominate the walk. -const PRUNED: &[&str] = &[ - ".git", - ".worktree", - ".worktrees", - ".lithe", - ".idea", - ".vscode", - ".gradle", - ".venv", - "venv", - ".tox", - ".mypy_cache", - ".pytest_cache", - "__pycache__", - ".next", - ".nuxt", - ".svelte-kit", - ".terraform", - ".cargo", - ".stack-work", - ".bundle", - ".dart_tool", - ".build", - ".swiftpm", - "node_modules", - "bower_components", - "vendor", - "target", - "build", - "dist", - "out", - "obj", - "coverage", - "DerivedData", - "Pods", -]; - /// One directory plus the file names directly inside it. /// /// Detectors read this instead of touching the filesystem, so checking "is there @@ -138,51 +95,19 @@ impl DirectoryContext { /// Walks the project, pruning uninteresting subtrees *before* descending rather /// than filtering afterwards, so a `node_modules` tree is never read at all. pub fn scan(root: &Path) -> Result, CoreError> { - let mut results = Vec::new(); - let mut queue = vec![(root.to_path_buf(), 0usize)]; - while let Some((directory, depth)) = queue.pop() { - if results.len() >= MAX_DIRECTORIES { - break; - } - let Ok(entries) = fs::read_dir(&directory) else { - continue; - }; - let mut files = BTreeSet::new(); - let mut children = Vec::new(); - for entry in entries.flatten() { - let Ok(kind) = entry.file_type() else { - continue; - }; - let Some(name) = entry.file_name().to_str().map(str::to_string) else { - continue; - }; - // `file_type` does not follow symlinks, so a link pointing at an - // ancestor is recorded as a file and never re-entered. That is what - // keeps this walk loop-free without tracking visited inodes. - if kind.is_dir() { - if depth < MAX_DEPTH && !pruned(&name) { - children.push(entry.path()); - } - } else { - files.insert(name); - } - } - results.push(DirectoryContext { - root: root.to_path_buf(), - relative: relative_path(root, &directory)?, - path: directory, - files, - }); - for child in children { - queue.push((child, depth + 1)); - } - } - results.sort_by(|left, right| left.relative.cmp(&right.relative)); - Ok(results) -} - -fn pruned(name: &str) -> bool { - PRUNED.iter().any(|value| value.eq_ignore_ascii_case(name)) + let snapshot = super::super::workspace_scan::scan_run_workspace(root, Some(MAX_DEPTH))?; + snapshot + .directories + .into_iter() + .map(|directory| { + Ok(DirectoryContext { + root: root.to_owned(), + relative: relative_path(root, &directory.path)?, + path: directory.path, + files: directory.files, + }) + }) + .collect() } fn relative_path(root: &Path, directory: &Path) -> Result { diff --git a/rust/lithe-core/src/execution/environment.rs b/rust/lithe-core/src/execution/environment.rs new file mode 100644 index 0000000000..197b5dff6b --- /dev/null +++ b/rust/lithe-core/src/execution/environment.rs @@ -0,0 +1,112 @@ +//! Resolves launch environment precedence after platform-owned tool discovery. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeMap; + +/// Platform-resolved homes and inherited environment for one launch step. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LaunchEnvironmentRequest { + /// Toolchain of this step, not the later application process. + pub toolchain: Option, + /// Resolved project JDK home. + pub java_home: Option, + /// Resolved Maven JVM home, falling back to the project home when absent. + pub maven_java_home: Option, + /// Native parent process environment; selected homes override it. + #[serde(default)] + pub inherited: BTreeMap, + /// Plan bindings followed by explicit user overrides, already merged by key. + #[serde(default)] + pub overrides: BTreeMap, + /// Windows environment keys are case-insensitive even in a Rust map. + #[serde(default)] + pub case_insensitive: bool, +} + +/// Final values passed directly to the native process adapter. +#[derive(Debug, Serialize)] +pub struct LaunchEnvironmentResponse { + /// Deterministically ordered final environment. + pub environment: BTreeMap, +} + +/// Applies inherited < selected toolchain < explicit launch overrides. +/// Maven steps use their Maven JDK; direct Java steps retain the project JDK. +pub fn resolve_launch_environment(request: LaunchEnvironmentRequest) -> LaunchEnvironmentResponse { + let mut environment = BTreeMap::new(); + let mut insert = |key: String, value: String| { + let key = if request.case_insensitive { + key.to_ascii_uppercase() + } else { + key + }; + environment.insert(key, value); + }; + for (key, value) in request.inherited { + insert(key, value); + } + let selected = if request.toolchain.as_deref() == Some("project-maven") { + request + .maven_java_home + .as_ref() + .or(request.java_home.as_ref()) + } else if request.toolchain.as_deref() == Some("project-jdk") { + request.java_home.as_ref() + } else { + None + }; + if let Some(home) = selected { + insert("JAVA_HOME".into(), home.clone()); + } + for (key, value) in request.overrides { + let resolved = if let Some(text) = value.as_str() { + Some(text.to_owned()) + } else if value.get("property").and_then(Value::as_str) == Some("home") { + // A pre-launch Maven step may inherit its application's Java binding. + // The executable's role owns JAVA_HOME; literal user overrides remain + // authoritative. Other variables keep their explicit binding. + if key.eq_ignore_ascii_case("JAVA_HOME") && selected.is_some() { + selected.cloned() + } else { + match value.get("toolchain").and_then(Value::as_str) { + Some("project-jdk") => request.java_home.clone(), + Some("project-maven") => request + .maven_java_home + .clone() + .or(request.java_home.clone()), + _ => None, + } + } + } else { + None + }; + if let Some(value) = resolved { + insert(key, value); + } + } + LaunchEnvironmentResponse { environment } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shared_launch_environment_fixtures() { + let cases: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/execution/launch-environment-v1.json" + )) + .unwrap(); + for case in cases["cases"].as_array().unwrap() { + let request = serde_json::from_value(case["request"].clone()).unwrap(); + assert_eq!( + serde_json::to_value(resolve_launch_environment(request)).unwrap(), + case["expected"], + "{}", + case["name"] + ); + } + } +} diff --git a/rust/lithe-core/src/execution/mod.rs b/rust/lithe-core/src/execution/mod.rs index 0b8e69c417..6cd305a8bb 100644 --- a/rust/lithe-core/src/execution/mod.rs +++ b/rust/lithe-core/src/execution/mod.rs @@ -21,3 +21,11 @@ pub use launch_command::{ LaunchCommandPlan, LaunchCommandPlanRequest, LaunchCommandPlanResponse, WINDOWS_COMMAND_LINE_LIMIT, }; + +mod workspace_scan; +pub use workspace_scan::{is_run_scan_excluded_directory, list_run_java_sources}; + +mod environment; +pub use environment::{ + resolve_launch_environment, LaunchEnvironmentRequest, LaunchEnvironmentResponse, +}; diff --git a/rust/lithe-core/src/execution/workspace_scan.rs b/rust/lithe-core/src/execution/workspace_scan.rs new file mode 100644 index 0000000000..584a508fad --- /dev/null +++ b/rust/lithe-core/src/execution/workspace_scan.rs @@ -0,0 +1,225 @@ +//! Shared run-discovery traversal and incomplete-snapshot diagnostics. +//! +//! Automatic discovery is best effort below the required workspace root. Declared +//! build-system modules remain owned by their provider and bypass these defaults. + +use crate::protocol::CoreError; +use std::collections::BTreeSet; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +/// Heuristic discovery budget; full fingerprint scans retain cancellation checks. +const MAX_HEURISTIC_DIRECTORIES: usize = 4000; + +const EXCLUDED_DIRECTORIES: &[&str] = &[ + ".git", + ".worktree", + ".worktrees", + ".lithe", + ".idea", + ".vscode", + ".gradle", + ".venv", + "venv", + ".tox", + ".mypy_cache", + ".pytest_cache", + "__pycache__", + ".next", + ".nuxt", + ".svelte-kit", + ".terraform", + ".cargo", + ".stack-work", + ".bundle", + ".dart_tool", + ".build", + ".swiftpm", + "node_modules", + "bower_components", + "vendor", + "target", + "build", + "dist", + "out", + "obj", + "coverage", + "DerivedData", + "Pods", +]; + +/// Default pruning for automatic run discovery, fingerprints and host Java lists. +/// Hidden source directories are allowed unless explicitly named here. +pub fn is_run_scan_excluded_directory(name: &str) -> bool { + EXCLUDED_DIRECTORIES + .iter() + .any(|excluded| excluded.eq_ignore_ascii_case(name)) +} + +/// One directory's files, without following directory symlinks. +pub(crate) struct ScannedDirectory { + pub path: PathBuf, + pub files: BTreeSet, +} + +/// A partial scan must never be used as evidence of deletion or freshness. +pub(crate) struct WorkspaceScan { + pub directories: Vec, + pub unreadable_paths: BTreeSet, +} + +/// Traverses automatic discovery inputs. A depth limit belongs only to heuristic +/// service detection; fingerprints and explicit host file lists use no depth limit. +pub(crate) fn scan_run_workspace( + root: &Path, + max_depth: Option, +) -> Result { + scan_with_reader(root, max_depth, &|path| { + fs::read_dir(path)? + .map(|entry| { + let entry = entry?; + Ok(( + entry.path(), + entry.file_type()?.is_dir(), + entry.file_type()?.is_symlink(), + )) + }) + .collect() + }) +} + +fn scan_with_reader( + root: &Path, + max_depth: Option, + read: &impl Fn(&Path) -> io::Result>, +) -> Result { + let mut result = WorkspaceScan { + directories: Vec::new(), + unreadable_paths: BTreeSet::new(), + }; + let mut pending = vec![(root.to_owned(), 0)]; + while let Some((path, depth)) = pending.pop() { + crate::protocol::cancellation::check()?; + if max_depth.is_some() && result.directories.len() >= MAX_HEURISTIC_DIRECTORIES { + result.unreadable_paths.insert(relative(root, &path)); + result + .unreadable_paths + .extend(pending.iter().map(|(path, _)| relative(root, path))); + break; + } + let entries = match read(&path) { + Ok(entries) => entries, + Err(error) if path == root => return Err(CoreError::from(error)), + Err(_) => { + result.unreadable_paths.insert(relative(root, &path)); + continue; + } + }; + let mut files = BTreeSet::new(); + let mut children = Vec::new(); + for (child, directory, symlink) in entries { + crate::protocol::cancellation::check()?; + let Some(name) = child.file_name().and_then(|name| name.to_str()) else { + result.unreadable_paths.insert(relative(root, &child)); + continue; + }; + if symlink { + continue; + } + if directory { + if !is_run_scan_excluded_directory(name) + && max_depth.is_none_or(|limit| depth < limit) + { + children.push((child, depth + 1)); + } + } else { + files.insert(name.to_owned()); + } + } + children.sort_by(|left, right| right.0.cmp(&left.0)); + pending.extend(children); + result.directories.push(ScannedDirectory { path, files }); + } + result + .directories + .sort_by(|left, right| left.path.cmp(&right.path)); + Ok(result) +} + +fn relative(root: &Path, path: &Path) -> String { + path.strip_prefix(root) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/") +} + +/// Returns Java paths using the same scan policy as run generation. Failure below +/// the root is tolerated; the subsequent Core fingerprint supplies diagnostics. +pub fn list_run_java_sources(root: &Path, limit: usize) -> Result, CoreError> { + let scan = scan_run_workspace(root, None)?; + let mut paths = scan + .directories + .into_iter() + .flat_map(|directory| { + directory + .files + .into_iter() + .filter(|name| name.to_ascii_lowercase().ends_with(".java")) + .map(move |name| relative(root, &directory.path.join(name))) + }) + .collect::>(); + paths.sort(); + paths.truncate(limit); + Ok(paths) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unreadable_child_preserves_siblings_but_root_is_required() { + let root = Path::new("fixture"); + let read = |path: &Path| { + if path == root { + Ok(vec![ + (root.join("blocked"), true, false), + (root.join(".sources"), true, false), + (root.join(".pytest_cache"), true, false), + ]) + } else if path.ends_with(".sources") { + Ok(vec![(path.join("Main.java"), false, false)]) + } else { + Err(io::Error::from(io::ErrorKind::PermissionDenied)) + } + }; + let result = scan_with_reader(root, None, &read).unwrap(); + assert_eq!(result.unreadable_paths, BTreeSet::from(["blocked".into()])); + assert!(result + .directories + .iter() + .any(|dir| dir.files.contains("Main.java"))); + assert!(scan_with_reader(root, None, &|_| Err(io::Error::from( + io::ErrorKind::PermissionDenied + ))) + .is_err()); + } + #[test] + fn directory_links_do_not_escape_or_reenter_the_scan() { + let root = Path::new("fixture"); + let result = scan_with_reader(root, None, &|path| { + assert_eq!(path, root, "links must never be traversed"); + Ok(vec![ + (root.join("cycle"), true, true), + (root.join("Main.java"), false, false), + ]) + }) + .unwrap(); + assert_eq!(result.directories.len(), 1); + assert_eq!( + result.directories[0].files, + BTreeSet::from(["Main.java".into()]) + ); + } +} diff --git a/rust/lithe-core/src/lib.rs b/rust/lithe-core/src/lib.rs index dd25d65386..f3100cf1e2 100644 --- a/rust/lithe-core/src/lib.rs +++ b/rust/lithe-core/src/lib.rs @@ -71,3 +71,6 @@ pub fn execute_json_with_events( pub fn git_askpass_main(prompt: &str) -> i32 { lithe_git_host::authentication::helper_main(prompt) } + +/// Shared classification for ordinary project watcher events after root resolution. +pub use project::is_workspace_event_relevant; diff --git a/rust/lithe-core/src/project/files.rs b/rust/lithe-core/src/project/files.rs index c9eaed5ac5..18833b5a70 100644 --- a/rust/lithe-core/src/project/files.rs +++ b/rust/lithe-core/src/project/files.rs @@ -33,6 +33,17 @@ pub(crate) const BUILT_IN_HIDDEN_DIRECTORIES: &[&str] = &[ "design-qa-artifacts", ".artifacts", ]; +/// Whether an ordinary workspace event belongs to visible project inputs. +/// Callers first relativize to the most specific explicitly watched root; native +/// event-loss flags always bypass this filter and request recovery. +pub fn is_workspace_event_relevant(relative: &str) -> bool { + !relative.split(['/', '\\']).any(|component| { + BUILT_IN_HIDDEN_DIRECTORIES + .iter() + .any(|name| component.eq_ignore_ascii_case(name)) + }) +} + /// File names omitted from workspace snapshots, search, and related scans. const BUILT_IN_HIDDEN_FILE_PATTERNS: &[&str] = &[".DS_Store"]; const MAX_FILE_SIZE: u64 = 2 * 1024 * 1024; @@ -1528,3 +1539,31 @@ pub(crate) fn is_plain_text(text: &str) -> bool { fn is_word_character(character: char) -> bool { character.is_ascii_alphanumeric() || character == '_' || character == '$' } + +#[cfg(test)] +mod event_filter_tests { + use super::*; + + #[test] + fn shared_workspace_change_fixture() { + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/workspace/change-filter-v1.json" + )) + .unwrap(); + let paths: Vec = serde_json::from_value(fixture["paths"].clone()).unwrap(); + let retained: Vec<_> = paths + .iter() + .filter(|path| is_workspace_event_relevant(path)) + .collect(); + assert_eq!(serde_json::to_value(retained).unwrap(), fixture["expected"]); + let response: serde_json::Value = serde_json::from_str(&crate::execute_json( + &serde_json::json!({ + "id": "filter", "command": "workspace.filterChanges", "payload": paths + }) + .to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], true, "{response}"); + assert_eq!(response["data"], fixture["expected"]); + } +} diff --git a/rust/lithe-core/src/project/mod.rs b/rust/lithe-core/src/project/mod.rs index 4210f0f712..a1b711f315 100644 --- a/rust/lithe-core/src/project/mod.rs +++ b/rust/lithe-core/src/project/mod.rs @@ -20,3 +20,5 @@ pub(crate) use maven_dependency_tree::{ dependencies, dependency_tree_arguments, validated_output_file, MavenDependenciesRequest, }; pub(crate) use maven_test_reports::MavenTestReportsRequest; + +pub use files::is_workspace_event_relevant; diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index 94b55d1bf4..539910e363 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -169,6 +169,8 @@ pub enum CoreCommand { WorkspaceSnapshot, /// Lists the visible children of project directories (`workspace.listDirectories`). WorkspaceListDirectories, + /// Filter ordinary workspace-relative event paths using shared defaults. + WorkspaceFilterChanges, /// Finds Git repositories that belong to an opened workspace (`workspace.repositories`). WorkspaceRepositories, /// Builds or reuses the workspace search index (`workspace.searchIndex.warm`). @@ -209,6 +211,8 @@ pub enum CoreCommand { MavenLaunchPlan, /// Shortens an oversized Java launch using a JDK argument file when needed. ExecutionPlanLaunchCommand, + /// Resolve environment precedence after platform tool discovery. + ExecutionResolveEnvironment, /// Decides how each Lithe-owned process ends (`processLifecycle.resolve`). ProcessLifecycleResolve, /// Classifies a process snapshot into lifecycle items (`processLifecycle.inventory`). @@ -565,6 +569,7 @@ impl CoreCommand { "community.discourse.search" => Some(Self::CommunityDiscourseSearch), "community.discourse.auth.revoke" => Some(Self::CommunityDiscourseAuthRevoke), "workspace.snapshot" => Some(Self::WorkspaceSnapshot), + "workspace.filterChanges" => Some(Self::WorkspaceFilterChanges), "workspace.listDirectories" => Some(Self::WorkspaceListDirectories), "workspace.repositories" => Some(Self::WorkspaceRepositories), "workspace.searchIndex.warm" => Some(Self::WorkspaceSearchIndexWarm), @@ -585,6 +590,7 @@ impl CoreCommand { "history.delete" => Some(Self::HistoryDelete), "maven.scan" => Some(Self::MavenScan), "maven.launchPlan" => Some(Self::MavenLaunchPlan), + "execution.resolveEnvironment" => Some(Self::ExecutionResolveEnvironment), "execution.planLaunchCommand" => Some(Self::ExecutionPlanLaunchCommand), "processLifecycle.resolve" => Some(Self::ProcessLifecycleResolve), "processLifecycle.inventory" => Some(Self::ProcessLifecycleInventory), diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index b0c6b14cae..15afc5c486 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -514,6 +514,22 @@ fn execute(request: &str) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::WorkspaceFilterChanges => { + match serde_json::from_value::>(parsed.payload) { + Ok(paths) => CoreResponse::success( + id, + serde_json::json!(paths + .into_iter() + .filter(|path| crate::project::is_workspace_event_relevant(path)) + .collect::>()), + ), + Err(error) => CoreResponse::failure( + id, + CoreError::new(ErrorCode::InvalidRequest, "Invalid workspace event paths") + .with_details(error.to_string()), + ), + } + } CoreCommand::WorkspaceListDirectories => { match serde_json::from_value::(parsed.payload) .map_err(|error| { @@ -823,6 +839,25 @@ fn execute(request: &str) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::ExecutionResolveEnvironment => { + match serde_json::from_value::( + parsed.payload, + ) { + Ok(request) => CoreResponse::success( + id, + serde_json::to_value(crate::execution::resolve_launch_environment(request)) + .expect("environment encodes"), + ), + Err(error) => CoreResponse::failure( + id, + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid launch environment request", + ) + .with_details(error.to_string()), + ), + } + } CoreCommand::ExecutionPlanLaunchCommand => { match serde_json::from_value::( parsed.payload, diff --git a/rust/lithe-core/src/tests/run_configuration.rs b/rust/lithe-core/src/tests/run_configuration.rs index 7a48f10033..a27349a327 100644 --- a/rust/lithe-core/src/tests/run_configuration.rs +++ b/rust/lithe-core/src/tests/run_configuration.rs @@ -4280,3 +4280,72 @@ fn automatic_java_selection_uses_shared_fixture_and_current_requirements() { } } } + +#[test] +fn shared_run_scan_excludes_caches_and_keeps_hidden_sources() { + let root = temporary_root("shared-run-scan"); + struct Cleanup(PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + fs::remove_dir_all(&self.0).unwrap(); + } + } + fs::create_dir_all(&root).unwrap(); + let _cleanup = Cleanup(root.clone()); + for path in [ + ".sources/Main.java", + "src/Main.java", + ".pytest_cache/Bad.java", + "node_modules/Bad.java", + "target/Bad.java", + ] { + let file = root.join(path); + fs::create_dir_all(file.parent().unwrap()).unwrap(); + fs::write(file, "class Main {}").unwrap(); + } + let paths = crate::execution::list_run_java_sources(&root, 100).unwrap(); + assert_eq!(paths, vec![".sources/Main.java", "src/Main.java"]); + let call = |command, payload| -> Value { + let response: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "scan", "command": command, "payload": payload + }) + .to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], true, "{response}"); + response["data"].clone() + }; + let generated = call( + "runConfig.generate", + serde_json::json!({"root": root, "paths": paths}), + ); + let inputs = generated["generated"]["generator"]["inputs"] + .as_object() + .unwrap(); + assert!(inputs.contains_key(".sources/Main.java")); + assert!(!inputs.keys().any(|path| path.contains("Bad.java"))); + let mut document = generated["generated"].clone(); + // A persisted incomplete baseline cannot certify freshness or infer deletion, + // even after the previously inaccessible directory becomes readable again. + document["generator"]["unreadablePaths"] = serde_json::json!(["blocked"]); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::write(root.join(".lithe/run/generated.json"), document.to_string()).unwrap(); + for full in [false, true] { + let inspected = call( + "runConfig.inspect", + serde_json::json!({"root": root, "checkFingerprint": full}), + ); + assert_eq!(inspected["status"], "ready"); + assert!(inspected["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|d| d["code"] == "incompleteProjectScan")); + assert!(!inspected["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|d| d["code"] == "staleFingerprint")); + } +} diff --git a/shared/contracts/application-boundary.md b/shared/contracts/application-boundary.md index d4b37cf69c..221532aafd 100644 --- a/shared/contracts/application-boundary.md +++ b/shared/contracts/application-boundary.md @@ -582,6 +582,20 @@ log stream. Changing repositories or references and closing the history view cancels the owning `operationID` and closes any retained cursor; a late result cannot replace the active selection and its returned cursor is also closed. +Launch environment precedence and automatic run scanning are Rust Core policy. +Platform adapters discover executable/home paths and provide the native parent +environment; `execution.resolveEnvironment` applies inherited < selected current +step toolchain < explicit user values. A Maven resource step uses its Maven JVM, +while the subsequent Java process uses the project JVM. Automatic scan exclusions, +partial-snapshot retention and diagnostics share one implementation; unreadable +optional subtrees must not erase previously discovered configurations. + +Ordinary workspace events use Core's `workspace.filterChanges` policy before a +host applies event-count recovery thresholds. Native lost-event/error flags always +request recovery. Hosts retain native watch registration, Git/document channels, +explicit watches and platform-specific lifecycle; the shared filter must not +replace those channels or swallow real source changes mixed with build output. + Run configuration behavior is exposed through the `runConfig.*` commands. Platform clients coordinate inspection, generation, resolution, typed document edits, and launch planning, but must not implement a second JSON merger, diff --git a/shared/contracts/run-configuration-v2.schema.json b/shared/contracts/run-configuration-v2.schema.json index 6d4d258f3e..428db0ea97 100644 --- a/shared/contracts/run-configuration-v2.schema.json +++ b/shared/contracts/run-configuration-v2.schema.json @@ -11,6 +11,7 @@ "required": ["fingerprint"], "properties": { "fingerprint": { "type": "string", "pattern": "^sha256:" }, + "unreadablePaths": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }, "inputs": { "type": "object", "propertyNames": { "type": "string", "minLength": 1 }, diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index f687838814..0630b24f00 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -952,6 +952,7 @@ package manager owns the download and Lithe does not infer bytes from logs. | `editor.lineEdit` | Apply a deterministic line-level text transform and return the replacement plus selection to restore | | `editor.lineCommentToken` | Resolve the line comment token for a file extension or language id | | `workspace.snapshot` | Enumerate visible workspace nodes and relative file paths; `nestedWorktrees` lists linked worktrees checked out inside their own repository, which are left out of the tree, files, and search index ; `includeTree: false` returns only `files` and a childless `root`; `files` (and the tree, when included) keep the lowercase traversal order that bounded consumers such as Windows Quick Open rely on, not the natural display order of `workspace.listDirectories` | +| `workspace.filterChanges` | Filter an array of workspace-relative paths against Core's default build/cache exclusions; preserve order, spelling, and the empty root path. Native event loss bypasses filtering. Hosts relativize to the most specific explicit watch root and retain explicitly watched files. | | `workspace.listDirectories` | Return one visible level of children for each requested workspace-relative directory (`""` is the root); missing, hidden, symlinked, or nested-worktree paths are omitted, and each listing reports the nested worktrees it left out. Child directories have `children: []` only when a shallow probe proves them visibly empty; otherwise children remain omitted (unloaded), with no recursive tree allocation. Children list directories first, then names in IDEA's natural file-name order (digit runs compare by value, case-insensitive, `-` next to `_`; fixture `shared/fixtures/workspace/file-name-order-v1.json`) | | `workspace.repositories` | Discover deterministic Git repository roots for an opened workspace | | `workspace.search` | Search visible file names and UTF-8 text files | @@ -968,6 +969,7 @@ package manager owns the download and Lithe does not infer bytes from logs. | `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 | +| `execution.resolveEnvironment` | Merge native `inherited` string values, selected `javaHome`/`mavenJavaHome` for the current `toolchain`, and `overrides` into `{ environment }`. `caseInsensitive: true` canonicalizes Windows keys to uppercase. Maven uses Maven JDK with project-JDK fallback; Java uses project JDK. String overrides win; structured `{ toolchain, property: "home" }` bindings use the current step's home for JAVA_HOME, and the named toolchain for other variables. Unknown bindings are omitted. | | `execution.planLaunchCommand` | Move oversized Java path-list options into a JDK argument file | | `processLifecycle.resolve` | Decide how each process or resource of one Lithe instance ends | | `processLifecycle.inventory` | Group a process snapshot of one Lithe instance into terminals, Agent tasks and background services | @@ -3053,6 +3055,18 @@ the sources of generated Java entries by content (`sha256:` in editing a class body is not a staleness. When the stored inputs no longer reproduce the stored fingerprint, the document came from another generator revision and the diagnostic says so instead of listing modified files. +Automatic run discovery, fingerprint traversal, Node manifest discovery and the +Windows Java source list use Core's shared scan policy. Known caches/build outputs +are pruned before descending; other hidden source directories remain visible and +symlinks are not followed. Failure to read the workspace root is an error. An +unreadable optional child or fingerprint input is recorded in +`generator.unreadablePaths`; generation retains previous configurations and input +hashes beneath those unknown paths. Inspection returns `incompleteProjectScan` +instead of claiming fingerprint freshness or inferred removals when either the +current scan or saved baseline is incomplete. The persisted diagnostic also appears +with `checkFingerprint: false`; a successful complete regeneration clears it. +This does not replace declared build-system modules or JDT's entrypoint facts. + `runConfig.inspect` also accepts optional schema-versioned `javaEntrypoints` from the current `lsp.request` result, independent of `checkFingerprint`. Core then compares JDT's `(sourcePath, mainClass)` pairs, with a `module/` prefix diff --git a/shared/fixtures/execution/launch-environment-v1.json b/shared/fixtures/execution/launch-environment-v1.json new file mode 100644 index 0000000000..7d4df56a14 --- /dev/null +++ b/shared/fixtures/execution/launch-environment-v1.json @@ -0,0 +1,145 @@ +{ + "schemaVersion": 1, + "cases": [ + { + "name": "maven-project-selection", + "request": { + "toolchain": "project-maven", + "javaHome": "jdk17", + "mavenJavaHome": "jdk21", + "inherited": { + "JAVA_HOME": "jdk8" + }, + "overrides": {}, + "caseInsensitive": false + }, + "expected": { + "environment": { + "JAVA_HOME": "jdk21" + } + } + }, + { + "name": "java-project-selection", + "request": { + "toolchain": "project-jdk", + "javaHome": "jdk17", + "mavenJavaHome": "jdk21", + "inherited": { + "JAVA_HOME": "jdk8" + }, + "overrides": {}, + "caseInsensitive": false + }, + "expected": { + "environment": { + "JAVA_HOME": "jdk17" + } + } + }, + { + "name": "explicit-user-selection", + "request": { + "toolchain": "project-maven", + "javaHome": "jdk17", + "mavenJavaHome": "jdk21", + "inherited": { + "JAVA_HOME": "jdk8" + }, + "overrides": { + "JAVA_HOME": "user-jdk" + }, + "caseInsensitive": false + }, + "expected": { + "environment": { + "JAVA_HOME": "user-jdk" + } + } + }, + { + "name": "maven-plan-binding", + "request": { + "toolchain": "project-maven", + "javaHome": "jdk17", + "mavenJavaHome": "jdk21", + "inherited": { + "JAVA_HOME": "jdk8" + }, + "overrides": { + "JAVA_HOME": { + "toolchain": "project-maven", + "property": "home" + } + }, + "caseInsensitive": false + }, + "expected": { + "environment": { + "JAVA_HOME": "jdk21" + } + } + }, + { + "name": "windows-key-alias", + "request": { + "toolchain": "project-maven", + "javaHome": "jdk17", + "mavenJavaHome": "jdk21", + "inherited": { + "JAVA_HOME": "jdk8" + }, + "overrides": { + "java_home": "user-jdk" + }, + "caseInsensitive": true + }, + "expected": { + "environment": { + "JAVA_HOME": "user-jdk" + } + } + }, + { + "name": "non-java-keeps-inherited", + "request": { + "toolchain": "project-node", + "javaHome": "jdk17", + "mavenJavaHome": "jdk21", + "inherited": { + "JAVA_HOME": "jdk8" + }, + "overrides": {}, + "caseInsensitive": false + }, + "expected": { + "environment": { + "JAVA_HOME": "jdk8" + } + } + }, + { + "name": "maven-resource-step-inherits-java-application-binding", + "request": { + "toolchain": "project-maven", + "javaHome": "jdk17", + "mavenJavaHome": "jdk21", + "inherited": { + "JAVA_HOME": "jdk8" + }, + "overrides": { + "JAVA_HOME": { + "toolchain": "project-jdk", + "property": "home" + } + }, + "caseInsensitive": false + }, + "expected": { + "environment": { + "JAVA_HOME": "jdk21" + } + } + } + ] +} diff --git a/shared/fixtures/workspace/change-filter-v1.json b/shared/fixtures/workspace/change-filter-v1.json new file mode 100644 index 0000000000..0db0a6c594 --- /dev/null +++ b/shared/fixtures/workspace/change-filter-v1.json @@ -0,0 +1,25 @@ +{ + "paths": [ + "", + "src/Main.java", + "target/classes/Main.class", + "TARGET/classes/Main.class", + "module/build/generated.java", + ".gradle/cache.bin", + "src/targeted/Main.java", + ".sources/Main.java", + ".lithe/run/generated.json", + "pom.xml", + "module/target/X.class", + "module/src/main/resources/application.yml" + ], + "expected": [ + "", + "src/Main.java", + "src/targeted/Main.java", + ".sources/Main.java", + ".lithe/run/generated.json", + "pom.xml", + "module/src/main/resources/application.yml" + ] +} diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index c56afaf259..ad4f9cdbb1 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -9440,7 +9440,9 @@ "debugger.step.intoInstruction": "Step Into Instruction", "debugger.disassembly.stepsByLine": "This adapter cannot step by instruction; steps still move by line", "plugins.java.installNotice": "Java Support is installed separately. Install it from the Marketplace, or use Install from Disk for an offline package. Your Java settings are kept.", - "plugins.java.installTemplates": "Install Java Support for Java, Maven and Spring templates…" + "plugins.java.installTemplates": "Install Java Support for Java, Maven and Spring templates…", + "run.incompleteProjectScanTitle": "Project scan is incomplete", + "run.incompleteProjectScanMessage": "Some project folders could not be read. Existing run configurations were retained. Restore access, then regenerate configurations." }, "overrides": { "windows": { diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index 81e1c6f45e..a481ae952c 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -9440,7 +9440,9 @@ "debugger.step.intoInstruction": "按指令单步进入", "debugger.disassembly.stepsByLine": "该适配器不支持按指令单步,单步仍按源码行前进", "plugins.java.installNotice": "Java Support 现已独立安装。请从插件市场安装,或使用“从磁盘安装”导入离线包。原有 Java 设置会保留。", - "plugins.java.installTemplates": "安装 Java Support,获取 Java、Maven 和 Spring 项目模板…" + "plugins.java.installTemplates": "安装 Java Support,获取 Java、Maven 和 Spring 项目模板…", + "run.incompleteProjectScanTitle": "项目扫描不完整", + "run.incompleteProjectScanMessage": "部分项目目录无法读取,已有运行配置已保留。请恢复访问权限后重新识别运行配置。" }, "overrides": { "macos": { diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index 5258bb1871..471f531706 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -89966,6 +89966,30 @@ "key": "Install Java Support for Java, Maven and Spring templates…", "arguments": [] } + }, + { + "id": "run.incompleteProjectScanTitle", + "description": "Run: incomplete workspace scan warning", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Project scan is incomplete", + "arguments": [] + } + }, + { + "id": "run.incompleteProjectScanMessage", + "description": "Run: incomplete workspace scan warning", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Some project folders could not be read. Existing run configurations were retained. Restore access, then regenerate configurations.", + "arguments": [] + } } ] } diff --git a/shared/platform-feature-matrix/features/file-explorer.json b/shared/platform-feature-matrix/features/file-explorer.json index de083479d0..a472e5c927 100644 --- a/shared/platform-feature-matrix/features/file-explorer.json +++ b/shared/platform-feature-matrix/features/file-explorer.json @@ -27,7 +27,8 @@ "macos/Sources/Lithe/Platform/MacOS/FileSystem/MacWorkspaceSnapshotBuilder.swift", "macos/Tests/LitheTests/ProjectTreeNameOrderTests.swift", "shared/fixtures/workspace/file-name-order-v1.json", - ".agents/notes/implemented/bug-fix/2026-10-22-project-tree-natural-name-order.md" + ".agents/notes/implemented/bug-fix/2026-10-22-project-tree-natural-name-order.md", + "shared/fixtures/workspace/change-filter-v1.json" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -47,12 +48,14 @@ "windows/tauri/src/features/file-explorer/components/file-explorer-selection.test.tsx", "windows/tauri/src/features/file-explorer/components/file-explorer-pane.tsx", "windows/tauri/src/features/file-explorer/hooks/use-file-explorer-drag-drop.test.tsx", - "windows/tauri/src/features/sidebar/utils/tree-selection.test.ts" + "windows/tauri/src/features/sidebar/utils/tree-selection.test.ts", + "rust/lithe-core/src/project/files.rs", + "shared/fixtures/workspace/change-filter-v1.json" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Workspace", - "verification": "浏览目录、展开/折叠、打开资源并在文件变更后刷新树。 macOS 文件树、标签页与面包屑共用 TOML 文件图标:亮色使用 IDEA New UI 官方浅底蓝色 T,深色使用深底蓝色 T;检查明暗主题切换后选择对应 SVG,保留原始 16pt 几何与颜色。 点击目录名称后上下键按可见行移动选择,右键展开目录并再次右键进入首个子项,左键折叠或返回显示父项;双击目录名称展开/折叠,单击与修饰键多选保持独立。验证压缩 Java 包、列表首尾、Shift 上下范围选择与自动滚动;方向键不送到编辑器,点击编辑器或转移到搜索输入框后恢复其按键处理。 Windows Project 文件树滚轮与 Git Log 共用 Community Windows 默认 200ms 惯性过渡;核对连续同向、反向、Shift 横向滚动、顶底边界、键盘定位与滑块拖动取消惯性、卸载帧清理和减少动态效果偏好;虚拟树保持可见区呈现,真实 WebView2 实机验收 pending。 macOS 项目文件和目录选中行共用 LitheTheme.Tree 的主题聚焦色与灰色失焦色;点击编辑器、其他工具窗口后失焦,键盘转移到搜索输入框尚未输入时失焦,重新点击树恢复,整个窗口失焦时保持灰色。深浅主题、非默认主题、多选、上下键和右键菜单均需核对;组件像素和焦点回归不能替代完整应用验收。 Windows 在默认及舒适密度下比较 Project 与 Dependencies 树正文,再修改界面字号确认同步;深浅主题检查刷新图标为循环箭头,点击仍刷新目录。 Windows 打开深层文件并折叠祖先,点击定位当前文件,检查展开后滚动到目标行;滚动离开后重复点击同一文件仍定位;编辑器切换页签不得让树自动滚动。延迟目录加载完成后再滚动,旧请求完成不改变新位置;树内搜索和 Dependencies 视图下定位应回到完整 Project 树。 Windows 搜索首次命中和 Enter 切换后复制/剪切应使用当前命中;同时选择父目录、后代与其他文件后右键后代,复制/删除仍处理整个选择且父子只操作一次。原生移动等待期间关闭 Project 或切换工作区,成功项同步原工作区并停止后续项。受控 WSL/远程/POSIX 路径用例保留大小写不同的文件与目录,Windows 本地/普通 UNC 同名目标继续拒绝覆盖;这些用例不代表已开放 WSL/远程功能,真实 WebView2 工作流仍 pending。 Windows 编辑器保持 README 打开时点击目录名称,通过真实 FileExplorerPane、侧栏与缓冲区 store 验证路径同步往返后目录仍被选中,复制/剪切、键盘焦点和 Shift 范围锚点仍属于目录;实际切换编辑文件时单选跟随,明确多选保留。 Windows 批量拖动分别在目录预检、原生移动和原工作区同步等待期间切换 A→B→A,确认旧批次不再派发剩余项,已完成项仍同步原工作区;普通重渲染继续当前批次,旧移动失败不清除新拖动或显示过期错误。 macOS 打开含 .claude/worktrees 等嵌套 Git linked worktree 与 .artifacts 的仓库:项目树、文件列表与搜索索引不包含这些目录,子模块和位于主检出之外的工作树仍显示;嵌套工作树内的文件变化不触发刷新。外部新增/删除普通文件时树原地增删、不重新扫描整个工作区,新目录、压缩包结构变化时回退完整快照;定位当前文件按路径逐级查找,侧栏渲染不再遍历全部文件。 macOS 项目树按需列目录:快照只返回文件清单,目录展开时才列出下一层;检查首次打开大仓库只显示根层,展开、折叠后再展开、源码根下压缩 Java 包与全量构建一致;外部改动未展开目录下的文件不改变树,展开后可见;重新扫描后已展开目录保持展开并显示最新内容;定位当前文件会逐级加载祖先后滚动到目标行;新增或删除文件后先展开目录再处理监听事件,确认搜索等文件库存仍同步;压缩包重列期间发生其它文件增删,完成后库存和树都保留最新结果。 macOS 项目树同级排序对齐 IDEA 项目视图:目录先于文件,名称按自然顺序(数字按数值、忽略大小写、'-' 与 '_' 相邻);打开含 docs/releases/v0.1.4.md、v0.1.12.md、v0.1.14.md 的仓库,确认 v0.1.4 在 v0.1.12 之前,外部新建 v0.1.5.md 后原地插入到二者之间,与重新扫描的顺序一致。", + "verification": "浏览目录、展开/折叠、打开资源并在文件变更后刷新树。 macOS 文件树、标签页与面包屑共用 TOML 文件图标:亮色使用 IDEA New UI 官方浅底蓝色 T,深色使用深底蓝色 T;检查明暗主题切换后选择对应 SVG,保留原始 16pt 几何与颜色。 点击目录名称后上下键按可见行移动选择,右键展开目录并再次右键进入首个子项,左键折叠或返回显示父项;双击目录名称展开/折叠,单击与修饰键多选保持独立。验证压缩 Java 包、列表首尾、Shift 上下范围选择与自动滚动;方向键不送到编辑器,点击编辑器或转移到搜索输入框后恢复其按键处理。 Windows Project 文件树滚轮与 Git Log 共用 Community Windows 默认 200ms 惯性过渡;核对连续同向、反向、Shift 横向滚动、顶底边界、键盘定位与滑块拖动取消惯性、卸载帧清理和减少动态效果偏好;虚拟树保持可见区呈现,真实 WebView2 实机验收 pending。 macOS 项目文件和目录选中行共用 LitheTheme.Tree 的主题聚焦色与灰色失焦色;点击编辑器、其他工具窗口后失焦,键盘转移到搜索输入框尚未输入时失焦,重新点击树恢复,整个窗口失焦时保持灰色。深浅主题、非默认主题、多选、上下键和右键菜单均需核对;组件像素和焦点回归不能替代完整应用验收。 Windows 在默认及舒适密度下比较 Project 与 Dependencies 树正文,再修改界面字号确认同步;深浅主题检查刷新图标为循环箭头,点击仍刷新目录。 Windows 打开深层文件并折叠祖先,点击定位当前文件,检查展开后滚动到目标行;滚动离开后重复点击同一文件仍定位;编辑器切换页签不得让树自动滚动。延迟目录加载完成后再滚动,旧请求完成不改变新位置;树内搜索和 Dependencies 视图下定位应回到完整 Project 树。 Windows 搜索首次命中和 Enter 切换后复制/剪切应使用当前命中;同时选择父目录、后代与其他文件后右键后代,复制/删除仍处理整个选择且父子只操作一次。原生移动等待期间关闭 Project 或切换工作区,成功项同步原工作区并停止后续项。受控 WSL/远程/POSIX 路径用例保留大小写不同的文件与目录,Windows 本地/普通 UNC 同名目标继续拒绝覆盖;这些用例不代表已开放 WSL/远程功能,真实 WebView2 工作流仍 pending。 Windows 编辑器保持 README 打开时点击目录名称,通过真实 FileExplorerPane、侧栏与缓冲区 store 验证路径同步往返后目录仍被选中,复制/剪切、键盘焦点和 Shift 范围锚点仍属于目录;实际切换编辑文件时单选跟随,明确多选保留。 Windows 批量拖动分别在目录预检、原生移动和原工作区同步等待期间切换 A→B→A,确认旧批次不再派发剩余项,已完成项仍同步原工作区;普通重渲染继续当前批次,旧移动失败不清除新拖动或显示过期错误。 macOS 打开含 .claude/worktrees 等嵌套 Git linked worktree 与 .artifacts 的仓库:项目树、文件列表与搜索索引不包含这些目录,子模块和位于主检出之外的工作树仍显示;嵌套工作树内的文件变化不触发刷新。外部新增/删除普通文件时树原地增删、不重新扫描整个工作区,新目录、压缩包结构变化时回退完整快照;定位当前文件按路径逐级查找,侧栏渲染不再遍历全部文件。 macOS 项目树按需列目录:快照只返回文件清单,目录展开时才列出下一层;检查首次打开大仓库只显示根层,展开、折叠后再展开、源码根下压缩 Java 包与全量构建一致;外部改动未展开目录下的文件不改变树,展开后可见;重新扫描后已展开目录保持展开并显示最新内容;定位当前文件会逐级加载祖先后滚动到目标行;新增或删除文件后先展开目录再处理监听事件,确认搜索等文件库存仍同步;压缩包重列期间发生其它文件增删,完成后库存和树都保留最新结果。 macOS 项目树同级排序对齐 IDEA 项目视图:目录先于文件,名称按自然顺序(数字按数值、忽略大小写、'-' 与 '_' 相邻);打开含 docs/releases/v0.1.4.md、v0.1.12.md、v0.1.14.md 的仓库,确认 v0.1.4 在 v0.1.12 之前,外部新建 v0.1.5.md 后原地插入到二者之间,与重新扫描的顺序一致。 外部构建产生超过 512 条 target/build 事件且混有源码变化时,只转发有效事件,不触发输出洪峰导致的整项目重扫;显式文件/生成源码监听、项目根名为 build、跨排除边界重命名均保留,系统丢失事件始终触发恢复重扫。", "notes": "Windows 补齐 Ctrl/Shift 多选、批量复制/剪切/删除、已选集合拖动、Project/Dependencies 切换、定位当前文件、刷新和隐藏。目录名称单击选择,双击或箭头展开;长名称水平滚动。目录标记按工作区持久化,并随移动、重命名和删除迁移;本地 Windows/UNC 按不区分大小写处理,POSIX、WSL 和远程标识保留大小写并隔离工作区。批量移动预检父子重复及重名目标,原生发布拒绝覆盖;真实 WebView2 的拖动与焦点验收 pending。 Windows 树正文统一使用 ui-text-sm(默认 13px),随界面字号设置变化,舒适密度不单独放大 Dependencies 等树行;Project 刷新按钮使用 IDEA refresh.svg。手动定位使用有代次的展开后滚动请求;编辑器切换页签不再自动跟随滚动(autoRevealActiveFileInFileTree 设置已退役);定位清除树内搜索并切回 Project,保持虚拟列表。 搜索导航同步选择与锚点,右键成员判断保留原始选择集合,批量目标再去除父子重复;已完成移动不因面板卸载或工作区切换而漏掉原工作区同步。路径去重与拖动预检按文件系统大小写语义比较,包含 WSL UNC 别名。编辑器活动文件变化时单选同步高亮、复制/剪切目标及范围锚点,明确多选保留;使用同步 Hook 的真实编辑器目标判断切换,选择目录引发的共享侧栏路径往返不改变选择;真实面板与 store 回归覆盖此链路。 批量移动按每次工作区访问识别生命周期,相同根路径的再次访问不能复活旧批次;预检、移动、同步和失败回调共用该守卫。 macOS 快照跳过嵌套在自身仓库中的 linked worktree(.git 文件指向 <主仓库>/.git/worktrees/*)并内置隐藏 .artifacts;项目树由 workspace.listDirectories 按需逐层列出,children 为 nil 表示目录未加载;平铺文件清单 projectFiles 仍完整保留供搜索等功能使用。 macOS 项目树同级排序由 Rust Core 的 visible_children 与 Swift 的 NaturalNameOrder 共同实现 IDEA FileNameComparator(共享 fixture file-name-order-v1.json);快照 files 与搜索索引按 ChildOrder::Traversal 保持原有的小写排序(Windows 快速打开取前 100 项依赖它),只有 workspace.listDirectories 使用自然顺序。Windows 项目文件树仍使用 localeCompare,Git 文件树已使用同源的 compareGitFileNames,Windows 项目树对齐待后续处理。" } diff --git a/shared/platform-feature-matrix/features/run-discovery.json b/shared/platform-feature-matrix/features/run-discovery.json index bb8eedbe17..0015af9392 100644 --- a/shared/platform-feature-matrix/features/run-discovery.json +++ b/shared/platform-feature-matrix/features/run-discovery.json @@ -12,7 +12,9 @@ "macos/Tests/LitheTests/RealJavaDebugIntegrationTests.swift", "macos/Tests/LitheTests/RunEntryPointTests.swift", "macos/Sources/LitheExecutionModule/Services/RunService.swift", - "macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift" + "macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift", + "rust/lithe-core/src/execution/workspace_scan.rs", + "rust/lithe-core/src/execution/configuration.rs" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -38,12 +40,14 @@ "shared/fixtures/run-configuration", "windows/tauri/src/features/run/services/java-context-entrypoint.ts", "windows/tauri/src/features/run/services/java-main-launch.test.ts", - "rust/lithe-core/src/lsp/interface/engine.rs" + "rust/lithe-core/src/lsp/interface/engine.rs", + "rust/lithe-core/src/execution/workspace_scan.rs", + "rust/lithe-core/src/execution/configuration.rs" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Run", - "verification": "验证 Spring Boot、Java、Maven、Gradle、npm、Cargo、Go、Python 和 Docker Compose 入口识别;Maven 框架 plugin 只在父 POM 声明时,子模块沿 链继承后仍识别为对应服务,父 POM 按 groupId:artifactId:version 完整坐标匹配、仅同名 artifactId 或版本不符的不继承、含未解析 ${...} 表达式的坐标不作数,聚合但未声明 的模块不继承、false 的 plugin 不下传且对更深层级保持阻断、 声明保持不生效、框架 plugin 按完整坐标 groupId:artifactId 识别:每个框架的全部官方坐标组均被接受(Quarkus 的 io.quarkus 与平台再发布的 io.quarkus.platform,Micronaut 3.x 的 io.micronaut.build 与 4+ 的 io.micronaut.maven,Spring Boot 的 org.springframework.boot)、省略 的声明按 Maven 默认组 org.apache.maven.plugins 处理,其他组下的同名插件与含 ${...} 表达式的 group 不识别为框架,子模块的同名自定义 plugin 也不遮蔽父继承的正品;源码根与报告目录的提取同样按完整坐标匹配,build-helper 执行级源根与 Surefire/Failsafe 独立 reportsDirectory 均有官方组与自定义组对照;Java 运行入口缺少主类时 inspect 报 staleJavaEntrypoint 并提示重新生成,重新生成后由 JDT 答案恢复;多模块项目把服务工作目录改成模块目录后,服务仍留在列表中并以该目录启动;填写不存在的目录或 ${workspaceFolder} 等变量时,保存被拒绝并提示原因;在 Windows 确认重新扫描服务使用双箭头刷新图标,配置按语言生态、类型与实例呈现三栏布局,Java 下展示 Maven、Gradle 与 Spring Boot 等已识别类型,Node.js 下展示包管理器与框架;语言切换和外部选中能定位配置,空生态不显示,语言和配置栏不显示重复栏头,生态不显示配置数量;语言侧栏通过拖动阈值折叠和展开,收起与展开阈值不同以避免边界跳动,不显示专门折叠按钮;键盘可调整宽度及恢复展开,保存展开宽度与拖动折叠状态,拖动期间只更新局部宽度和紧凑标记,文字与图标对齐即时切换,结束后保存;语言行、类型行和实例行使用一致的紧凑行高,去掉列表上下留白和选中实例的圆角边距;窄面板自动缩成带提示的图标栏且扩大后恢复,右侧输出保留可用空间,窗口缩放或卸载时清理正在进行的拖动;所选配置的日志、运行状态、顶部停止按钮、清空输出与标准输入均指向其自身会话;保持选中 application 并后台启动服务后,顶部停止仍指向 primary,不停止后台服务,左侧使用 SVG 箭头和类型图标,基础设施默认折叠,选择配置自动展开所在分组,运行、停止、编辑和完整名称提示可用;根据 Core 提供的 npm、Gradle 和 Python 元数据展示明确识别的框架图标,构建与测试任务保留工具图标,未知类型回退通用图标;同名自定义 provider 与已知框架分别分组且折叠状态独立,相同已知框架跨 Maven/Gradle provider 合组,项目与基础设施不合组;Java 生态分组使用 Java 文件类型图标,Java 启动类实例使用原始 Class 与 RunnableMark SVG 同画布叠加图标;Maven 启动的框架服务使用 MavenRunConfigurationType 对应的 Maven 图标,Make 外部任务分组使用任务文件夹、实例使用 Shell 配置图标,均保留原始明暗 SVG,运行或失败不改变类型图标,深浅主题均可辨认;macOS Run 缺少配置且可重新生成时,检查空状态文字与链接在深浅主题下按 IDEA 工具窗口样式显示、窄面板不截断,并确认“识别并生成”仍触发原有生成流程;应用语言设为英文而系统语言为中文时,Run 空状态及其他本面板文案仍应显示英文;Run 顶部标题字号、颜色、41pt 高度和底部分隔线应与 Terminal、Git Log 等工具窗口一致;有项目配置或 Current File 正在运行/已有退出结果时,运行、重启、重新扫描和软换行按钮显示在独立的 36pt 操作栏,检查 16pt IDEA 图标、22pt 共享按钮的悬停、按下、选中及禁用状态;操作栏只保留一个运行/重运行动作:首次启动显示 Run,当前配置已运行过后显示 Rerun;右侧固定停止按钮,未运行禁用、运行中启用,两者始终指向当前选中配置;操作栏不重复显示 Running 圆点及状态文字,重运行按钮复用顶部工具栏的绿色 rerun_stroke 图标;标题栏菜单中的清空输出与原操作一致,收起按钮沿用共享样式;仅有 Current File 的项目启动后也可从 Run 操作栏停止,结束后保留重运行入口;Run 内容区不额外插入 Java 项目准备状态行,准备进度和失败入口保留在工作台状态栏;Run 标题、操作栏、配置列表标题、控制台标签底边及两侧纵向分割线统一使用工具窗口边框颜色和 1pt 绘制,左侧图标栏不出现 5pt 实心粗线,栏内分组线与 Git Log 一致使用共享 divider 颜色、22pt 宽度、1pt 高度及上下 3pt 间距;操作按钮组之间使用专用 ActionToolbar.separator,详情编辑按钮的边框由共享按钮样式负责,拖动命中范围保持不变;配置树直接显示分组与数量,不显示 Application types 标题;默认隐藏勾选框,通过更多菜单或右键进入批量选择,退出清空选择;配置单行显示名称,类型及服务地址放到悬停提示,行内仅运行/停止,编辑/固定/打开服务保留在共享右键菜单,固定后显示标记;分组与配置使用共享树行高、缩进及焦点选择样式;Run 范围筛选以固定图标栏呈现,应用范围筛选及当前运行导航使用中性 ToolWindowRun 图标,实际运行操作继续使用绿色 Execute 图标;全部采用 IDEA groups、服务采用 server 图标,固定、应用及任务沿用对应 IDEA 原始资源;检查所有图标的 16pt 尺寸、明暗 SVG 自动切换、提示和筛选,右键勾选范围和配置行勾选共享同一批量选择;配置行使用 IDEA 固定、编辑、运行及停止图标,悬停与点击维持箭头光标;配置过期提示使用共享中性通知表面及警告图标,操作仍可重新识别;Run 标题栏仅显示 Run,移除重复配置名与右侧状态胶囊;Console 和 Configuration details 标签位于运行操作按钮组右侧,配置列表选中项表示当前配置,内容区直接显示日志或详情;配置详情使用共享 13pt 字体、原生 Grid 自适应标签列、可选择且可换行的路径及值,编辑配置按钮复用共享 LitheCommitDialogButtonStyle 与 IDEA EditSource 图标,检查深浅主题、窄列及点击跳转设置,服务链接保留原动作;两个标签使用共享 LitheToolWindowTabStyle,检查 13pt 文字、28pt 标签、原始明暗 SVG、悬停及内容区聚焦/失焦状态,保留标签切换;Run 与 Debug 均启用共享 OutputTextView 的 IDEA 渲染入口,运行日志采用一致的 13pt 字体、1.2 倍行高和 ANSI 配色,不给普通 INFO 日志整行染蓝,源码链接继续跳转;Copy 与 Jump to latest 为共享图标按钮,复制不带 ANSI,向上滚动后可恢复跟随;其他配置恢复状态保留各自提示。 Current File 运行一次后修改未保存并切换编辑标签,重运行应先保存原运行文件再构建启动计划;原文件保存失败时提示错误并中止重启,当前焦点文档不被误保存;调整 Run 面板高度,外层横向操作栏固定为 36pt,剩余高度交给配置树和控制台。", + "verification": "验证 Spring Boot、Java、Maven、Gradle、npm、Cargo、Go、Python 和 Docker Compose 入口识别;Maven 框架 plugin 只在父 POM 声明时,子模块沿 链继承后仍识别为对应服务,父 POM 按 groupId:artifactId:version 完整坐标匹配、仅同名 artifactId 或版本不符的不继承、含未解析 ${...} 表达式的坐标不作数,聚合但未声明 的模块不继承、false 的 plugin 不下传且对更深层级保持阻断、 声明保持不生效、框架 plugin 按完整坐标 groupId:artifactId 识别:每个框架的全部官方坐标组均被接受(Quarkus 的 io.quarkus 与平台再发布的 io.quarkus.platform,Micronaut 3.x 的 io.micronaut.build 与 4+ 的 io.micronaut.maven,Spring Boot 的 org.springframework.boot)、省略 的声明按 Maven 默认组 org.apache.maven.plugins 处理,其他组下的同名插件与含 ${...} 表达式的 group 不识别为框架,子模块的同名自定义 plugin 也不遮蔽父继承的正品;源码根与报告目录的提取同样按完整坐标匹配,build-helper 执行级源根与 Surefire/Failsafe 独立 reportsDirectory 均有官方组与自定义组对照;Java 运行入口缺少主类时 inspect 报 staleJavaEntrypoint 并提示重新生成,重新生成后由 JDT 答案恢复;多模块项目把服务工作目录改成模块目录后,服务仍留在列表中并以该目录启动;填写不存在的目录或 ${workspaceFolder} 等变量时,保存被拒绝并提示原因;在 Windows 确认重新扫描服务使用双箭头刷新图标,配置按语言生态、类型与实例呈现三栏布局,Java 下展示 Maven、Gradle 与 Spring Boot 等已识别类型,Node.js 下展示包管理器与框架;语言切换和外部选中能定位配置,空生态不显示,语言和配置栏不显示重复栏头,生态不显示配置数量;语言侧栏通过拖动阈值折叠和展开,收起与展开阈值不同以避免边界跳动,不显示专门折叠按钮;键盘可调整宽度及恢复展开,保存展开宽度与拖动折叠状态,拖动期间只更新局部宽度和紧凑标记,文字与图标对齐即时切换,结束后保存;语言行、类型行和实例行使用一致的紧凑行高,去掉列表上下留白和选中实例的圆角边距;窄面板自动缩成带提示的图标栏且扩大后恢复,右侧输出保留可用空间,窗口缩放或卸载时清理正在进行的拖动;所选配置的日志、运行状态、顶部停止按钮、清空输出与标准输入均指向其自身会话;保持选中 application 并后台启动服务后,顶部停止仍指向 primary,不停止后台服务,左侧使用 SVG 箭头和类型图标,基础设施默认折叠,选择配置自动展开所在分组,运行、停止、编辑和完整名称提示可用;根据 Core 提供的 npm、Gradle 和 Python 元数据展示明确识别的框架图标,构建与测试任务保留工具图标,未知类型回退通用图标;同名自定义 provider 与已知框架分别分组且折叠状态独立,相同已知框架跨 Maven/Gradle provider 合组,项目与基础设施不合组;Java 生态分组使用 Java 文件类型图标,Java 启动类实例使用原始 Class 与 RunnableMark SVG 同画布叠加图标;Maven 启动的框架服务使用 MavenRunConfigurationType 对应的 Maven 图标,Make 外部任务分组使用任务文件夹、实例使用 Shell 配置图标,均保留原始明暗 SVG,运行或失败不改变类型图标,深浅主题均可辨认;macOS Run 缺少配置且可重新生成时,检查空状态文字与链接在深浅主题下按 IDEA 工具窗口样式显示、窄面板不截断,并确认“识别并生成”仍触发原有生成流程;应用语言设为英文而系统语言为中文时,Run 空状态及其他本面板文案仍应显示英文;Run 顶部标题字号、颜色、41pt 高度和底部分隔线应与 Terminal、Git Log 等工具窗口一致;有项目配置或 Current File 正在运行/已有退出结果时,运行、重启、重新扫描和软换行按钮显示在独立的 36pt 操作栏,检查 16pt IDEA 图标、22pt 共享按钮的悬停、按下、选中及禁用状态;操作栏只保留一个运行/重运行动作:首次启动显示 Run,当前配置已运行过后显示 Rerun;右侧固定停止按钮,未运行禁用、运行中启用,两者始终指向当前选中配置;操作栏不重复显示 Running 圆点及状态文字,重运行按钮复用顶部工具栏的绿色 rerun_stroke 图标;标题栏菜单中的清空输出与原操作一致,收起按钮沿用共享样式;仅有 Current File 的项目启动后也可从 Run 操作栏停止,结束后保留重运行入口;Run 内容区不额外插入 Java 项目准备状态行,准备进度和失败入口保留在工作台状态栏;Run 标题、操作栏、配置列表标题、控制台标签底边及两侧纵向分割线统一使用工具窗口边框颜色和 1pt 绘制,左侧图标栏不出现 5pt 实心粗线,栏内分组线与 Git Log 一致使用共享 divider 颜色、22pt 宽度、1pt 高度及上下 3pt 间距;操作按钮组之间使用专用 ActionToolbar.separator,详情编辑按钮的边框由共享按钮样式负责,拖动命中范围保持不变;配置树直接显示分组与数量,不显示 Application types 标题;默认隐藏勾选框,通过更多菜单或右键进入批量选择,退出清空选择;配置单行显示名称,类型及服务地址放到悬停提示,行内仅运行/停止,编辑/固定/打开服务保留在共享右键菜单,固定后显示标记;分组与配置使用共享树行高、缩进及焦点选择样式;Run 范围筛选以固定图标栏呈现,应用范围筛选及当前运行导航使用中性 ToolWindowRun 图标,实际运行操作继续使用绿色 Execute 图标;全部采用 IDEA groups、服务采用 server 图标,固定、应用及任务沿用对应 IDEA 原始资源;检查所有图标的 16pt 尺寸、明暗 SVG 自动切换、提示和筛选,右键勾选范围和配置行勾选共享同一批量选择;配置行使用 IDEA 固定、编辑、运行及停止图标,悬停与点击维持箭头光标;配置过期提示使用共享中性通知表面及警告图标,操作仍可重新识别;Run 标题栏仅显示 Run,移除重复配置名与右侧状态胶囊;Console 和 Configuration details 标签位于运行操作按钮组右侧,配置列表选中项表示当前配置,内容区直接显示日志或详情;配置详情使用共享 13pt 字体、原生 Grid 自适应标签列、可选择且可换行的路径及值,编辑配置按钮复用共享 LitheCommitDialogButtonStyle 与 IDEA EditSource 图标,检查深浅主题、窄列及点击跳转设置,服务链接保留原动作;两个标签使用共享 LitheToolWindowTabStyle,检查 13pt 文字、28pt 标签、原始明暗 SVG、悬停及内容区聚焦/失焦状态,保留标签切换;Run 与 Debug 均启用共享 OutputTextView 的 IDEA 渲染入口,运行日志采用一致的 13pt 字体、1.2 倍行高和 ANSI 配色,不给普通 INFO 日志整行染蓝,源码链接继续跳转;Copy 与 Jump to latest 为共享图标按钮,复制不带 ANSI,向上滚动后可恢复跟随;其他配置恢复状态保留各自提示。 Current File 运行一次后修改未保存并切换编辑标签,重运行应先保存原运行文件再构建启动计划;原文件保存失败时提示错误并中止重启,当前焦点文档不被误保存;调整 Run 面板高度,外层横向操作栏固定为 36pt,剩余高度交给配置树和控制台。 缓存目录不可访问时仍可发现兄弟目录和隐藏源码;可选子目录不可读时保留其旧配置并显示 incompleteProjectScan,不误报删除或新鲜;根目录不可读仍失败,完整重新生成后解除未知基线。 两端 Run 面板显示扫描不完整的共享中英文提示;恢复访问权限后重新识别。", "notes": " Windows普通Java文件的main入口在工作区搜索遗漏时,先保存并让JDT对当前文件确认,再生成命名运行配置;拒绝过期入口、准备失败和工作区切换结果。Java入口源码按容错UTF-8读取(非法字节替换为占位符),GBK项目不再因单个非UTF-8文件导致整个运行列表生成失败,Spring Boot注解等ASCII分类标记不受影响。 macOS 运行就绪要求匹配快照且工具链和配置选择加载完成,加载中的清单不可作为启动许可;受控工具链与快照测试验证完成前后状态,以及 Current File 不被迟到的默认配置覆盖。" } diff --git a/shared/platform-feature-matrix/features/run-save-toolchain.json b/shared/platform-feature-matrix/features/run-save-toolchain.json index b6b4422589..4b57c01c2f 100644 --- a/shared/platform-feature-matrix/features/run-save-toolchain.json +++ b/shared/platform-feature-matrix/features/run-save-toolchain.json @@ -12,7 +12,9 @@ "macos/Sources/LitheExecutionModule/Services/RunService.swift", "macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift", "macos/Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift", - "rust/lithe-core/src/lsp/interface/engine_real_jdt_tests.rs" + "rust/lithe-core/src/lsp/interface/engine_real_jdt_tests.rs", + "rust/lithe-core/src/execution/environment.rs", + "shared/fixtures/execution/launch-environment-v1.json" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -26,11 +28,13 @@ "windows/tauri/src-tauri/src/run.rs", "windows/tauri/src/platform/lsp-core-adapter.ts", "windows/tauri/src/platform/lsp-core-adapter.test.ts", - "rust/lithe-core/src/lsp/interface/engine_real_jdt_tests.rs" + "rust/lithe-core/src/lsp/interface/engine_real_jdt_tests.rs", + "rust/lithe-core/src/execution/environment.rs", + "shared/fixtures/execution/launch-environment-v1.json" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Run", - "verification": "修改未保存文件后运行,验证同步、JDK/Maven 选择和版本不匹配诊断;Maven Java Main 与 Spring Boot 直启在 JDT 构建后、JVM 启动前执行 Maven 原生资源处理,覆盖模块依赖、Profile、settings、测试资源和自定义 cwd;资源处理失败或超过十分钟期限不得启动 JVM,超时提示“Pre-launch step timed out after 600 seconds.”。 Issue #379:分别从 Run 面板和编辑器启动 Maven 主源码及测试源码中的 main(含默认包 MainTests、自定义源码/测试输出目录、test scope 依赖与多模块),确认 JDT 自动选择 classpath/module-path,测试入口包含测试输出和依赖,主源码入口排除测试输出;Run、Debug 与服务更新路径校验采用相同的自动 scope。 运行配置与工具链完成解析后才发布快照就绪状态;重叠加载不得保存临时 Current File 选择,已完成加载后的用户选择须保留。 打开运行配置编辑对话框,确认标题栏显示运行时图标、类型与生效来源胶囊,保存范围和 Maven 测试项使用共享分段控件,项目环境入口整行可点击;保存范围、继承/覆盖说明和环境变量格式默认收进 ⓘ,路径输入、环境变量编辑器以及保存/取消按钮与设置页样式一致;窄窗口下内容仍可滚动且底部按钮可见。" + "verification": "修改未保存文件后运行,验证同步、JDK/Maven 选择和版本不匹配诊断;Maven Java Main 与 Spring Boot 直启在 JDT 构建后、JVM 启动前执行 Maven 原生资源处理,覆盖模块依赖、Profile、settings、测试资源和自定义 cwd;资源处理失败或超过十分钟期限不得启动 JVM,超时提示“Pre-launch step timed out after 600 seconds.”。 Issue #379:分别从 Run 面板和编辑器启动 Maven 主源码及测试源码中的 main(含默认包 MainTests、自定义源码/测试输出目录、test scope 依赖与多模块),确认 JDT 自动选择 classpath/module-path,测试入口包含测试输出和依赖,主源码入口排除测试输出;Run、Debug 与服务更新路径校验采用相同的自动 scope。 运行配置与工具链完成解析后才发布快照就绪状态;重叠加载不得保存临时 Current File 选择,已完成加载后的用户选择须保留。 打开运行配置编辑对话框,确认标题栏显示运行时图标、类型与生效来源胶囊,保存范围和 Maven 测试项使用共享分段控件,项目环境入口整行可点击;保存范围、继承/覆盖说明和环境变量格式默认收进 ⓘ,路径输入、环境变量编辑器以及保存/取消按钮与设置页样式一致;窄窗口下内容仍可滚动且底部按钮可见。 项目 JDK 为 8、Maven JDK 为 21 时,Maven goal 和资源前置步骤使用 21,Java 主程序使用 8;显式 JAVA_HOME 优先于选择,Windows 不同大小写环境键不产生冲突。" } diff --git a/windows/tauri/crates/project/Cargo.toml b/windows/tauri/crates/project/Cargo.toml index 2788ccf9ff..bed644a027 100644 --- a/windows/tauri/crates/project/Cargo.toml +++ b/windows/tauri/crates/project/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] +lithe-core = { path = "../../../../rust/lithe-core" } anyhow = "1.0" encoding_rs = "0.8" sha2 = "0.10" diff --git a/windows/tauri/crates/project/src/lib.rs b/windows/tauri/crates/project/src/lib.rs index f3f48a2456..3f886c3722 100644 --- a/windows/tauri/crates/project/src/lib.rs +++ b/windows/tauri/crates/project/src/lib.rs @@ -111,20 +111,18 @@ impl FileWatcher { Ok(new_debouncer( Duration::from_millis(300), None, - move |result: DebounceEventResult| { - match result { - Ok(events) => Self::handle_events( - events, - emitter.as_ref(), - &watched_paths, - &watched_directories, - ), - Err(errors) => { - for error in &errors { - log::warn!("[FileWatcher] Native watcher error: {error}"); - } - Self::emit_rescans(emitter.as_ref(), &watched_directories); + move |result: DebounceEventResult| match result { + Ok(events) => Self::handle_events( + events, + emitter.as_ref(), + &watched_paths, + &watched_directories, + ), + Err(errors) => { + for error in &errors { + log::warn!("[FileWatcher] Native watcher error: {error}"); } + Self::emit_rescans(emitter.as_ref(), &watched_directories); } }, )?) @@ -141,38 +139,46 @@ impl FileWatcher { return; } - if events.len() > MAX_DEBOUNCED_EVENTS_PER_BATCH { - log::warn!( - "[FileWatcher] Collapsing {} debounced events into a bounded rescan", - events.len() - ); + let watched_paths = watched_paths.lock().unwrap(); + let watched_dirs = watched_directories.lock().unwrap(); + // Classify rename pairs before filtering either endpoint. A move from an + // excluded directory into source still creates a visible source event. + let changes = events + .iter() + .flat_map(|event| Self::classify_event(&event.event)) + .filter(|(path, _)| Self::is_relevant_change(path, &watched_paths, &watched_dirs)) + .collect::>(); + drop(watched_dirs); + drop(watched_paths); + if changes.len() > MAX_DEBOUNCED_EVENTS_PER_BATCH { Self::emit_rescans(emitter, watched_directories); return; } + for (path, event_type) in changes { + emitter.emit_file_change(&FileChangeEvent { + path: path.to_string_lossy().to_string(), + event_type, + }); + } + } - let watched_paths = watched_paths.lock().unwrap(); - let watched_dirs = watched_directories.lock().unwrap(); - - for event in events { - let changes = Self::classify_event(&event.event); - for (path, event_type) in changes { - if !Self::is_path_watched(&path, &watched_paths, &watched_dirs) { - continue; - } - - let change_event = FileChangeEvent { - path: path.to_string_lossy().to_string(), - event_type, - }; - - log::debug!( - "[FileWatcher] Emitting file-changed event for: {} ({:?})", - change_event.path, - change_event.event_type - ); - emitter.emit_file_change(&change_event); - } + fn is_relevant_change( + path: &PathBuf, + watched_paths: &HashSet, + watched_dirs: &HashSet, + ) -> bool { + if watched_paths.contains(path) { + return true; } + let root = watched_dirs + .iter() + .filter(|root| path.starts_with(root)) + .max_by_key(|root| root.components().count()); + root + .and_then(|root| path.strip_prefix(root).ok()) + .is_some_and(|relative| { + lithe_core::is_workspace_event_relevant(&relative.to_string_lossy()) + }) } fn classify_event(event: &Event) -> Vec<(PathBuf, FileChangeType)> { @@ -260,14 +266,6 @@ impl FileWatcher { } } - fn is_path_watched( - path: &PathBuf, - watched_paths: &HashSet, - watched_dirs: &HashSet, - ) -> bool { - watched_paths.contains(path) || watched_dirs.iter().any(|dir| path.starts_with(dir)) - } - fn setup_path_watching( &self, path_buf: &PathBuf, @@ -407,17 +405,12 @@ mod tests { path.display() ); match receiver.recv_timeout(remaining) { - Ok(event) - if event.path == path.to_string_lossy() && event.event_type == event_type => - { + Ok(event) if event.path == path.to_string_lossy() && event.event_type == event_type => { return event; } Ok(_) => continue, Err(RecvTimeoutError::Timeout) => { - panic!( - "timed out waiting for {event_type:?} at {}", - path.display() - ) + panic!("timed out waiting for {event_type:?} at {}", path.display()) } Err(RecvTimeoutError::Disconnected) => { panic!("file watcher event channel disconnected") @@ -501,12 +494,7 @@ mod tests { let watched_paths = Arc::new(Mutex::new(HashSet::from([root.clone()]))); let watched_directories = Arc::new(Mutex::new(HashSet::from([root.clone()]))); - FileWatcher::handle_events( - events, - &emitter, - &watched_paths, - &watched_directories, - ); + FileWatcher::handle_events(events, &emitter, &watched_paths, &watched_directories); assert_eq!( receiver.try_recv().expect("rescan event should be emitted"), @@ -517,4 +505,99 @@ mod tests { ); assert!(matches!(receiver.try_recv(), Err(TryRecvError::Empty))); } + #[test] + fn native_event_loss_recovers_even_when_only_output_paths_remain() { + let root = PathBuf::from("fixture"); + let event = Event::new(EventKind::Other) + .add_path(root.join("target/classes/Main.class")) + .set_flag(notify::event::Flag::Rescan); + let (sender, receiver) = mpsc::channel(); + FileWatcher::handle_events( + vec![DebouncedEvent::new(event, Instant::now())], + &ChannelEmitter(sender), + &Arc::new(Mutex::new(HashSet::from([root.clone()]))), + &Arc::new(Mutex::new(HashSet::from([root.clone()]))), + ); + assert_eq!( + receiver.try_iter().collect::>(), + vec![FileChangeEvent { + path: root.to_string_lossy().into_owned(), + event_type: FileChangeType::Rescan, + }] + ); + } + + #[test] + fn build_output_flood_preserves_source_and_explicit_watch_events() { + let root = PathBuf::from("fixture/build"); + let generated = root.join("target/generated-sources"); + let retained = root.join("target/explicit.txt"); + let source = root.join("src/Main.java"); + let mut events = (0..1024) + .map(|index| { + DebouncedEvent::new( + Event::new(EventKind::Create(notify::event::CreateKind::File)) + .add_path(root.join(format!("target/classes/{index}.class"))), + Instant::now(), + ) + }) + .collect::>(); + for path in [&source, &retained, &generated.join("Generated.java")] { + events.push(DebouncedEvent::new( + Event::new(EventKind::Modify(ModifyKind::Any)).add_path(path.clone()), + Instant::now(), + )); + } + let (sender, receiver) = mpsc::channel(); + FileWatcher::handle_events( + events, + &ChannelEmitter(sender), + &Arc::new(Mutex::new(HashSet::from([root.clone(), retained.clone()]))), + &Arc::new(Mutex::new(HashSet::from([root, generated.clone()]))), + ); + let actual = receiver.try_iter().collect::>(); + assert_eq!(actual.len(), 3); + assert!( + actual + .iter() + .all(|event| event.event_type != FileChangeType::Rescan) + ); + assert_eq!( + actual + .iter() + .map(|event| event.path.clone()) + .collect::>(), + [source, retained, generated.join("Generated.java")] + .iter() + .map(|path| path.to_string_lossy().to_string()) + .collect() + ); + } + + #[test] + fn rename_from_output_to_source_retains_only_visible_endpoint() { + let root = PathBuf::from("fixture"); + let source = root.join("src/Generated.java"); + let events = vec![DebouncedEvent::new( + Event::new(EventKind::Modify(ModifyKind::Name(RenameMode::Both))) + .add_path(root.join("target/Generated.java")) + .add_path(source.clone()), + Instant::now(), + )]; + let (sender, receiver) = mpsc::channel(); + FileWatcher::handle_events( + events, + &ChannelEmitter(sender), + &Arc::new(Mutex::new(HashSet::from([root.clone()]))), + &Arc::new(Mutex::new(HashSet::from([root]))), + ); + let actual = receiver.try_iter().collect::>(); + assert_eq!( + actual, + vec![FileChangeEvent { + path: source.to_string_lossy().to_string(), + event_type: FileChangeType::Opened + }] + ); + } } diff --git a/windows/tauri/src-tauri/Cargo.lock b/windows/tauri/src-tauri/Cargo.lock index 2508fb28c0..cc7a4db7d7 100644 --- a/windows/tauri/src-tauri/Cargo.lock +++ b/windows/tauri/src-tauri/Cargo.lock @@ -2896,6 +2896,8 @@ dependencies = [ "lithe-agent-host", "lithe-git-host", "lithe-ide-host", + "lithe-remote-client", + "lithe-remote-protocol", "quick-xml 0.37.5", "rand 0.8.7", "regex", @@ -2945,6 +2947,7 @@ version = "0.1.0" dependencies = [ "anyhow", "encoding_rs", + "lithe-core", "log", "notify", "notify-debouncer-full", @@ -2955,6 +2958,25 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "lithe-remote-client" +version = "0.1.0" +dependencies = [ + "lithe-remote-protocol", + "serde", + "serde_json", + "sha2", +] + +[[package]] +name = "lithe-remote-protocol" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "serde", + "serde_json", +] + [[package]] name = "lithe-terminal" version = "0.1.0" diff --git a/windows/tauri/src-tauri/src/run.rs b/windows/tauri/src-tauri/src/run.rs index 7c7b1fe8d4..dfbe8fa82b 100644 --- a/windows/tauri/src-tauri/src/run.rs +++ b/windows/tauri/src-tauri/src/run.rs @@ -37,24 +37,7 @@ const RUN_OUTPUT_QUEUE_CAPACITY_CHUNKS: usize = 64; const RUN_STOP_TIMEOUT: Duration = Duration::from_secs(10); // The exit code `ProcessTree` gives every process it ends. const STOPPED_EXIT_CODE: i32 = 1; -const SKIPPED_DIRECTORIES: &[&str] = &[ - "target", - "node_modules", - ".git", - "build", - "dist", - "out", - ".idea", - ".lithe", - "bin", - "obj", - "__pycache__", - ".gradle", - "vendor", - "coverage", - ".svn", - ".hg", -]; + const MAX_JAVA_SOURCES: usize = 8_000; const LITHE_GITIGNORE_ENTRIES: &[&str] = &[ "run/local.json", @@ -549,10 +532,8 @@ pub struct PreLaunchOutcome { #[tauri::command] pub fn run_list_java_sources(root: PathBuf) -> Result, String> { let root = existing_directory(&root)?; - let mut paths = Vec::new(); - collect_java_sources(&root, &root, &mut paths)?; - paths.sort(); - Ok(paths) + lithe_core::execution::list_run_java_sources(&root, MAX_JAVA_SOURCES) + .map_err(|error| error.message) } #[tauri::command] @@ -689,20 +670,19 @@ pub fn run_resolve_launch(args: ResolveLaunchArgs) -> Result>(); - if let Some(home) = &java_home { - environment.insert("JAVA_HOME".into(), home.clone()); - } - for (key, value) in args.environment { - if let Some(text) = - resolve_environment_value(&value, java_home.as_deref(), maven_java_home.as_deref()) - { - environment.insert(key, text); - } - } - if let Some(home) = maven_java_home.or(java_home) { - environment.entry("JAVA_HOME".into()).or_insert(home); - } + let mut environment = lithe_core::execution::resolve_launch_environment( + lithe_core::execution::LaunchEnvironmentRequest { + toolchain: args.executable.toolchain.clone(), + java_home, + maven_java_home, + inherited: std::env::vars().collect(), + overrides: args.environment.into_iter().collect(), + case_insensitive: cfg!(windows), + }, + ) + .environment + .into_iter() + .collect(); prepend_runtime_paths( &mut environment, &args.runtime_executable_paths, @@ -1245,46 +1225,6 @@ fn write_generated_documents( atomic_write(&generated_path, pretty_json(generated)?.as_bytes()) } -fn collect_java_sources( - root: &Path, - directory: &Path, - paths: &mut Vec, -) -> Result<(), String> { - if paths.len() >= MAX_JAVA_SOURCES { - return Ok(()); - } - let entries = fs::read_dir(directory).map_err(|error| error.to_string())?; - for entry in entries { - let entry = entry.map_err(|error| error.to_string())?; - let path = entry.path(); - let file_type = entry.file_type().map_err(|error| error.to_string())?; - if file_type.is_dir() { - let name = entry.file_name().to_string_lossy().to_string(); - if is_skipped_directory(&name) { - continue; - } - collect_java_sources(root, &path, paths)?; - continue; - } - if file_type.is_file() { - let name = entry.file_name().to_string_lossy().to_string(); - if name.to_ascii_lowercase().ends_with(".java") { - if let Some(relative) = workspace_relative(root, &path) { - paths.push(relative); - } - } - } - } - Ok(()) -} - -fn is_skipped_directory(name: &str) -> bool { - name.starts_with('.') - || SKIPPED_DIRECTORIES - .iter() - .any(|value| value.eq_ignore_ascii_case(name)) -} - fn workspace_relative(root: &Path, path: &Path) -> Option { path.strip_prefix(root) .ok() @@ -2165,30 +2105,6 @@ fn resolve_working_directory(root: &Path, relative: &str) -> Result, - maven_java_home: Option<&str>, -) -> Option { - if let Some(text) = value.as_str() { - return Some(text.to_string()); - } - let object = value.as_object()?; - let toolchain = object.get("toolchain")?.as_str()?; - let property = object - .get("property") - .and_then(Value::as_str) - .unwrap_or("home"); - if property != "home" { - return None; - } - match toolchain { - "project-jdk" => java_home.map(str::to_string), - "project-maven" => maven_java_home.map(str::to_string), - _ => java_home.map(str::to_string), - } -} - fn lookup_on_path(name: &str) -> Option { let path = std::env::var_os("PATH")?; for directory in std::env::split_paths(&path) { @@ -2934,10 +2850,18 @@ mod tests { #[test] fn skipped_directories_include_build_outputs() { - assert!(is_skipped_directory("target")); - assert!(is_skipped_directory(".git")); - assert!(is_skipped_directory("node_modules")); - assert!(!is_skipped_directory("src")); + assert!(lithe_core::execution::is_run_scan_excluded_directory( + "target" + )); + assert!(lithe_core::execution::is_run_scan_excluded_directory( + ".git" + )); + assert!(lithe_core::execution::is_run_scan_excluded_directory( + "node_modules" + )); + assert!(!lithe_core::execution::is_run_scan_excluded_directory( + "src" + )); } #[test] @@ -3422,6 +3346,53 @@ mod tests { fs::remove_dir_all(root).ok(); } + #[test] + fn maven_launch_uses_selected_maven_jdk_in_real_host_resolution() { + let root = temp_project(); + struct Cleanup(PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + fs::remove_dir_all(&self.0).unwrap(); + } + } + let _cleanup = Cleanup(root.clone()); + let java = root.join("jdk8"); + let maven_java = root.join("jdk21"); + for home in [&java, &maven_java] { + fs::create_dir_all(home.join("bin")).unwrap(); + fs::write( + home.join(if cfg!(windows) { + "bin/java.exe" + } else { + "bin/java" + }), + b"fixture", + ) + .unwrap(); + } + let maven = root.join("mvn.cmd"); + fs::write(&maven, b"fixture").unwrap(); + let resolved = run_resolve_launch(ResolveLaunchArgs { + root: root.clone(), + executable: LaunchExecutable { + toolchain: Some("project-maven".into()), + command: None, + tool: None, + }, + working_directory: ".".into(), + java_home_path: java.to_string_lossy().into_owned(), + maven_java_home_path: maven_java.to_string_lossy().into_owned(), + maven_executable_path: maven.to_string_lossy().into_owned(), + runtime_executable_paths: HashMap::new(), + environment: Map::new(), + }) + .unwrap(); + assert_eq!( + resolved.environment.get("JAVA_HOME"), + Some(&normalize_path(&maven_java).to_string_lossy().into_owned()) + ); + } + #[test] fn selected_runtime_directories_are_prepended_to_launch_path() { let root = temp_project(); @@ -3455,20 +3426,6 @@ mod tests { fs::remove_dir_all(root).ok(); } - #[test] - fn launch_environment_resolves_java_home_from_toolchain_reference() { - let home = resolve_environment_value( - &json!({ "toolchain": "project-jdk", "property": "home" }), - Some(r"C:\jdk"), - Some(r"C:\maven-jdk"), - ); - assert_eq!(home.as_deref(), Some(r"C:\jdk")); - assert_eq!( - resolve_environment_value(&json!("debug"), Some(r"C:\jdk"), None).as_deref(), - Some("debug") - ); - } - #[test] fn batch_command_line_keeps_maven_goals_inside_one_cmd_string() { let line = batch_command_line( diff --git a/windows/tauri/src/features/run/components/run-pane.tsx b/windows/tauri/src/features/run/components/run-pane.tsx index 797a4e5573..de8731652e 100644 --- a/windows/tauri/src/features/run/components/run-pane.tsx +++ b/windows/tauri/src/features/run/components/run-pane.tsx @@ -163,6 +163,7 @@ export default function RunPane() { const freshnessDiagnostic = diagnostics.find((diagnostic) => diagnostic.code === "staleFingerprint" || diagnostic.code === "fingerprintCheckFailed" || + diagnostic.code === "incompleteProjectScan" || diagnostic.code === "staleJavaEntrypoint"); const isSelectedRunning = selection.isRunning; const output = selection.output; @@ -320,11 +321,13 @@ export default function RunPane() {
{blockingDiagnostic ? t("run.toolchainNeedsAttention") : - freshnessDiagnostic?.code === "fingerprintCheckFailed" ? t("run.freshnessCheckFailed") : + freshnessDiagnostic?.code === "incompleteProjectScan" ? t("run.incompleteProjectScanTitle") : + freshnessDiagnostic?.code === "fingerprintCheckFailed" ? t("run.freshnessCheckFailed") : t("run.staleConfigurations")}
- {blockingDiagnostic?.message ?? freshnessDiagnostic?.message} + {blockingDiagnostic?.message ?? (freshnessDiagnostic?.code === "incompleteProjectScan" + ? t("run.incompleteProjectScanMessage") : freshnessDiagnostic?.message)}
{blockingDiagnostic ? ( 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 d9c4018bad..48c24ccdad 100644 --- a/windows/tauri/src/features/run/utils/run-configuration.test.ts +++ b/windows/tauri/src/features/run/utils/run-configuration.test.ts @@ -272,6 +272,13 @@ describe("run configuration mapping", () => { }); }); + test("explicit user JAVA_HOME overrides a generated toolchain binding", () => { + expect(mergeLaunchEnvironment({ JAVA_HOME: "fixture/user-jdk" }, { + environment: { JAVA_HOME: { toolchain: "project-jdk", property: "home" } }, + env: { JAVA_HOME: "fixture/plan-jdk" }, + }).JAVA_HOME).toBe("fixture/user-jdk"); + }); + test("blocks only the configuration named by a toolchain diagnostic", () => { const diagnostics = [ { diff --git a/windows/tauri/src/features/run/utils/run-configuration.ts b/windows/tauri/src/features/run/utils/run-configuration.ts index 34ec624621..7495b62169 100644 --- a/windows/tauri/src/features/run/utils/run-configuration.ts +++ b/windows/tauri/src/features/run/utils/run-configuration.ts @@ -317,9 +317,9 @@ export function mergeLaunchEnvironment( plan: { environment?: Record; env?: Record }, ): Record { return { - ...configurationEnv, - ...(plan.env ?? {}), ...(plan.environment ?? {}), + ...(plan.env ?? {}), + ...configurationEnv, }; } diff --git a/windows/tauri/src/i18n/shared.generated.ts b/windows/tauri/src/i18n/shared.generated.ts index 4868d92be9..e3090dc26a 100644 --- a/windows/tauri/src/i18n/shared.generated.ts +++ b/windows/tauri/src/i18n/shared.generated.ts @@ -3724,6 +3724,8 @@ export const sharedCatalogs = { "run.identifyAgain": "Identify Again", "run.identifyAndGenerate": "Identify and Generate", "run.identifying": "Identifying project…", + "run.incompleteProjectScanMessage": "Some project folders could not be read. Existing run configurations were retained. Restore access, then regenerate configurations.", + "run.incompleteProjectScanTitle": "Project scan is incomplete", "run.infrastructure": "Docker services", "run.invalidTitle": "Project run configuration is invalid", "run.javaBuildAlwaysContinue": "Always Continue in This Workspace", @@ -8660,6 +8662,8 @@ export const sharedCatalogs = { "run.identifyAgain": "重新识别", "run.identifyAndGenerate": "识别并生成", "run.identifying": "正在识别项目…", + "run.incompleteProjectScanMessage": "部分项目目录无法读取,已有运行配置已保留。请恢复访问权限后重新识别运行配置。", + "run.incompleteProjectScanTitle": "项目扫描不完整", "run.infrastructure": "Docker 服务", "run.invalidTitle": "项目运行配置无效", "run.javaBuildAlwaysContinue": "对此工作区始终继续", From 113d689a94df3cb52b17d571a0ad4690b779df38 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Wed, 28 Oct 2026 00:21:08 -0700 Subject: [PATCH 2/2] fix(run): stop shared Java enumeration at its source limit --- ...026-09-22-run-panel-fingerprint-loading.md | 6 + .../src/execution/workspace_scan.rs | 204 ++++++++++++++---- shared/contracts/rust-core-api.md | 4 + .../features/run-discovery.json | 2 +- 4 files changed, 174 insertions(+), 42 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-09-22-run-panel-fingerprint-loading.md b/.agents/notes/implemented/bug-fix/2026-09-22-run-panel-fingerprint-loading.md index 6043569b6c..be3ba15d19 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-22-run-panel-fingerprint-loading.md +++ b/.agents/notes/implemented/bug-fix/2026-09-22-run-panel-fingerprint-loading.md @@ -94,6 +94,12 @@ Current File 当作最终配置执行。配置文档可读不等于运行项目 声明模块;只替换已有的启发式文件遍历。注入目录读取失败的测试覆盖兄弟目录、 必需根失败和链接不跟随,保留测试覆盖旧配置与输入摘要的未知/已删除区别。 +Java 文件枚举的数量上限也必须限制后续目录访问,不能完整扫描后再截取数组。 +共享遍历逐目录交付结果;Java 列表收集满上限后立即停止,不保留整个工作区的 +文件名快照。已遇到的不可读路径仍保留在遍历诊断中,完整指纹扫描不受这个 +列表上限影响。测试注入目录读取器,在达到 8,000 个 Java 文件后若继续打开 +后续目录就立即失败,并验证顺序稳定、零上限与完整扫描仍能继续访问。 + ## 考虑过的备选方案 - 持久化大小和修改时间以复用摘要:同大小文件可在保留时间戳时发生内容变化, diff --git a/rust/lithe-core/src/execution/workspace_scan.rs b/rust/lithe-core/src/execution/workspace_scan.rs index 584a508fad..8e59d1d5df 100644 --- a/rust/lithe-core/src/execution/workspace_scan.rs +++ b/rust/lithe-core/src/execution/workspace_scan.rs @@ -7,6 +7,7 @@ use crate::protocol::CoreError; use std::collections::BTreeSet; use std::fs; use std::io; +use std::ops::ControlFlow; use std::path::{Path, PathBuf}; /// Heuristic discovery budget; full fingerprint scans retain cancellation checks. @@ -75,18 +76,17 @@ pub(crate) fn scan_run_workspace( root: &Path, max_depth: Option, ) -> Result { - scan_with_reader(root, max_depth, &|path| { - fs::read_dir(path)? - .map(|entry| { - let entry = entry?; - Ok(( - entry.path(), - entry.file_type()?.is_dir(), - entry.file_type()?.is_symlink(), - )) - }) - .collect() - }) + scan_with_reader(root, max_depth, &read_directory) +} + +fn read_directory(path: &Path) -> io::Result> { + fs::read_dir(path)? + .map(|entry| { + let entry = entry?; + let kind = entry.file_type()?; + Ok((entry.path(), kind.is_dir(), kind.is_symlink())) + }) + .collect() } fn scan_with_reader( @@ -94,25 +94,41 @@ fn scan_with_reader( max_depth: Option, read: &impl Fn(&Path) -> io::Result>, ) -> Result { - let mut result = WorkspaceScan { - directories: Vec::new(), - unreadable_paths: BTreeSet::new(), - }; + let mut directories = Vec::new(); + let unreadable_paths = visit_with_reader(root, max_depth, read, |directory| { + directories.push(directory); + ControlFlow::Continue(()) + })?; + directories.sort_by(|left, right| left.path.cmp(&right.path)); + Ok(WorkspaceScan { + directories, + unreadable_paths, + }) +} + +// Consumers decide whether to retain each directory and when enough inputs have +// been found. Early stopping must not change full fingerprint scan semantics. +fn visit_with_reader( + root: &Path, + max_depth: Option, + read: &impl Fn(&Path) -> io::Result>, + mut visit: impl FnMut(ScannedDirectory) -> ControlFlow<()>, +) -> Result, CoreError> { + let mut unreadable_paths = BTreeSet::new(); + let mut directory_count = 0; let mut pending = vec![(root.to_owned(), 0)]; while let Some((path, depth)) = pending.pop() { crate::protocol::cancellation::check()?; - if max_depth.is_some() && result.directories.len() >= MAX_HEURISTIC_DIRECTORIES { - result.unreadable_paths.insert(relative(root, &path)); - result - .unreadable_paths - .extend(pending.iter().map(|(path, _)| relative(root, path))); + if max_depth.is_some() && directory_count >= MAX_HEURISTIC_DIRECTORIES { + unreadable_paths.insert(relative(root, &path)); + unreadable_paths.extend(pending.iter().map(|(path, _)| relative(root, path))); break; } let entries = match read(&path) { Ok(entries) => entries, Err(error) if path == root => return Err(CoreError::from(error)), Err(_) => { - result.unreadable_paths.insert(relative(root, &path)); + unreadable_paths.insert(relative(root, &path)); continue; } }; @@ -121,7 +137,7 @@ fn scan_with_reader( for (child, directory, symlink) in entries { crate::protocol::cancellation::check()?; let Some(name) = child.file_name().and_then(|name| name.to_str()) else { - result.unreadable_paths.insert(relative(root, &child)); + unreadable_paths.insert(relative(root, &child)); continue; }; if symlink { @@ -139,12 +155,12 @@ fn scan_with_reader( } children.sort_by(|left, right| right.0.cmp(&left.0)); pending.extend(children); - result.directories.push(ScannedDirectory { path, files }); + directory_count += 1; + if visit(ScannedDirectory { path, files }).is_break() { + break; + } } - result - .directories - .sort_by(|left, right| left.path.cmp(&right.path)); - Ok(result) + Ok(unreadable_paths) } fn relative(root: &Path, path: &Path) -> String { @@ -156,21 +172,33 @@ fn relative(root: &Path, path: &Path) -> String { /// Returns Java paths using the same scan policy as run generation. Failure below /// the root is tolerated; the subsequent Core fingerprint supplies diagnostics. +/// Stops before opening another directory once the limit is reached, retaining +/// only Java paths from the deterministic traversal rather than a full snapshot. pub fn list_run_java_sources(root: &Path, limit: usize) -> Result, CoreError> { - let scan = scan_run_workspace(root, None)?; - let mut paths = scan - .directories - .into_iter() - .flat_map(|directory| { - directory - .files - .into_iter() - .filter(|name| name.to_ascii_lowercase().ends_with(".java")) - .map(move |name| relative(root, &directory.path.join(name))) - }) - .collect::>(); + list_java_sources_with_reader(root, limit, &read_directory) +} + +fn list_java_sources_with_reader( + root: &Path, + limit: usize, + read: &impl Fn(&Path) -> io::Result>, +) -> Result, CoreError> { + let mut paths = Vec::new(); + if limit == 0 { + return Ok(paths); + } + visit_with_reader(root, None, read, |directory| { + for name in directory.files { + if name.to_ascii_lowercase().ends_with(".java") { + paths.push(relative(root, &directory.path.join(name))); + if paths.len() == limit { + return ControlFlow::Break(()); + } + } + } + ControlFlow::Continue(()) + })?; paths.sort(); - paths.truncate(limit); Ok(paths) } @@ -178,6 +206,100 @@ pub fn list_run_java_sources(root: &Path, limit: usize) -> Result, C mod tests { use super::*; + #[test] + fn java_source_limit_stops_directory_reads_and_keeps_deterministic_results() { + let root = Path::new("fixture"); + for limit in [1, 8_000] { + let visited = std::cell::RefCell::new(Vec::new()); + let paths = list_java_sources_with_reader(root, limit, &|path| { + visited.borrow_mut().push(path.to_owned()); + if path == root { + Ok(vec![ + (root.join("z-unvisited"), true, false), + (root.join("b-sources"), true, false), + (root.join("a-blocked"), true, false), + (root.join(".pytest_cache"), true, false), + (root.join("link"), true, true), + ]) + } else if path.ends_with("a-blocked") { + Err(io::Error::from(io::ErrorKind::PermissionDenied)) + } else if path.ends_with("b-sources") { + // More than the cap in one directory must neither exceed the + // result limit nor open a later directory. Reverse native + // enumeration order to protect deterministic selection. + Ok((0..=limit) + .rev() + .map(|index| (path.join(format!("Main{index:05}.java")), false, false)) + .chain([(path.join("nested"), true, false)]) + .collect()) + } else { + panic!("source budget must prevent reading {}", path.display()); + } + }) + .unwrap(); + assert_eq!( + paths, + (0..limit) + .map(|index| format!("b-sources/Main{index:05}.java")) + .collect::>() + ); + assert_eq!( + *visited.borrow(), + [ + root.to_owned(), + root.join("a-blocked"), + root.join("b-sources") + ] + ); + } + } + + #[test] + fn java_source_zero_limit_does_not_read_the_workspace() { + let paths = list_java_sources_with_reader(Path::new("fixture"), 0, &|_| { + panic!("zero budget must not read directories") + }) + .unwrap(); + assert!(paths.is_empty()); + } + + #[test] + fn early_stop_retains_observed_unreadable_paths_without_affecting_full_scans() { + let root = Path::new("fixture"); + let read = |path: &Path| { + if path == root { + Ok(vec![ + (root.join("a-blocked"), true, false), + (root.join("b-sources"), true, false), + (root.join("z-later"), true, false), + ]) + } else if path.ends_with("a-blocked") { + Err(io::Error::from(io::ErrorKind::PermissionDenied)) + } else { + Ok(vec![(path.join("Main.java"), false, false)]) + } + }; + let unknown = visit_with_reader(root, None, &read, |directory| { + if directory.files.contains("Main.java") { + ControlFlow::Break(()) + } else { + ControlFlow::Continue(()) + } + }) + .unwrap(); + assert_eq!(unknown, BTreeSet::from(["a-blocked".into()])); + let full = scan_with_reader(root, None, &read).unwrap(); + assert_eq!(full.unreadable_paths, unknown); + assert!(full + .directories + .iter() + .any(|dir| dir.path.ends_with("z-later"))); + assert_eq!( + list_java_sources_with_reader(root, 10, &read).unwrap(), + ["b-sources/Main.java", "z-later/Main.java"] + ); + } + #[test] fn unreadable_child_preserves_siblings_but_root_is_required() { let root = Path::new("fixture"); diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 0630b24f00..0d4115ed60 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -3066,6 +3066,10 @@ instead of claiming fingerprint freshness or inferred removals when either the current scan or saved baseline is incomplete. The persisted diagnostic also appears with `checkFingerprint: false`; a successful complete regeneration clears it. This does not replace declared build-system modules or JDT's entrypoint facts. +The bounded Java source list stops opening further directories once its source +limit is reached, retains only collected Java paths, and returns them sorted. +Full fingerprint scans remain uncapped by this list limit and keep reporting +unreadable inputs independently. `runConfig.inspect` also accepts optional schema-versioned `javaEntrypoints` from the current `lsp.request` result, independent of `checkFingerprint`. Core diff --git a/shared/platform-feature-matrix/features/run-discovery.json b/shared/platform-feature-matrix/features/run-discovery.json index 0015af9392..db07f98587 100644 --- a/shared/platform-feature-matrix/features/run-discovery.json +++ b/shared/platform-feature-matrix/features/run-discovery.json @@ -48,6 +48,6 @@ "verificationStatus": "pending" }, "owner": "Run", - "verification": "验证 Spring Boot、Java、Maven、Gradle、npm、Cargo、Go、Python 和 Docker Compose 入口识别;Maven 框架 plugin 只在父 POM 声明时,子模块沿 链继承后仍识别为对应服务,父 POM 按 groupId:artifactId:version 完整坐标匹配、仅同名 artifactId 或版本不符的不继承、含未解析 ${...} 表达式的坐标不作数,聚合但未声明 的模块不继承、false 的 plugin 不下传且对更深层级保持阻断、 声明保持不生效、框架 plugin 按完整坐标 groupId:artifactId 识别:每个框架的全部官方坐标组均被接受(Quarkus 的 io.quarkus 与平台再发布的 io.quarkus.platform,Micronaut 3.x 的 io.micronaut.build 与 4+ 的 io.micronaut.maven,Spring Boot 的 org.springframework.boot)、省略 的声明按 Maven 默认组 org.apache.maven.plugins 处理,其他组下的同名插件与含 ${...} 表达式的 group 不识别为框架,子模块的同名自定义 plugin 也不遮蔽父继承的正品;源码根与报告目录的提取同样按完整坐标匹配,build-helper 执行级源根与 Surefire/Failsafe 独立 reportsDirectory 均有官方组与自定义组对照;Java 运行入口缺少主类时 inspect 报 staleJavaEntrypoint 并提示重新生成,重新生成后由 JDT 答案恢复;多模块项目把服务工作目录改成模块目录后,服务仍留在列表中并以该目录启动;填写不存在的目录或 ${workspaceFolder} 等变量时,保存被拒绝并提示原因;在 Windows 确认重新扫描服务使用双箭头刷新图标,配置按语言生态、类型与实例呈现三栏布局,Java 下展示 Maven、Gradle 与 Spring Boot 等已识别类型,Node.js 下展示包管理器与框架;语言切换和外部选中能定位配置,空生态不显示,语言和配置栏不显示重复栏头,生态不显示配置数量;语言侧栏通过拖动阈值折叠和展开,收起与展开阈值不同以避免边界跳动,不显示专门折叠按钮;键盘可调整宽度及恢复展开,保存展开宽度与拖动折叠状态,拖动期间只更新局部宽度和紧凑标记,文字与图标对齐即时切换,结束后保存;语言行、类型行和实例行使用一致的紧凑行高,去掉列表上下留白和选中实例的圆角边距;窄面板自动缩成带提示的图标栏且扩大后恢复,右侧输出保留可用空间,窗口缩放或卸载时清理正在进行的拖动;所选配置的日志、运行状态、顶部停止按钮、清空输出与标准输入均指向其自身会话;保持选中 application 并后台启动服务后,顶部停止仍指向 primary,不停止后台服务,左侧使用 SVG 箭头和类型图标,基础设施默认折叠,选择配置自动展开所在分组,运行、停止、编辑和完整名称提示可用;根据 Core 提供的 npm、Gradle 和 Python 元数据展示明确识别的框架图标,构建与测试任务保留工具图标,未知类型回退通用图标;同名自定义 provider 与已知框架分别分组且折叠状态独立,相同已知框架跨 Maven/Gradle provider 合组,项目与基础设施不合组;Java 生态分组使用 Java 文件类型图标,Java 启动类实例使用原始 Class 与 RunnableMark SVG 同画布叠加图标;Maven 启动的框架服务使用 MavenRunConfigurationType 对应的 Maven 图标,Make 外部任务分组使用任务文件夹、实例使用 Shell 配置图标,均保留原始明暗 SVG,运行或失败不改变类型图标,深浅主题均可辨认;macOS Run 缺少配置且可重新生成时,检查空状态文字与链接在深浅主题下按 IDEA 工具窗口样式显示、窄面板不截断,并确认“识别并生成”仍触发原有生成流程;应用语言设为英文而系统语言为中文时,Run 空状态及其他本面板文案仍应显示英文;Run 顶部标题字号、颜色、41pt 高度和底部分隔线应与 Terminal、Git Log 等工具窗口一致;有项目配置或 Current File 正在运行/已有退出结果时,运行、重启、重新扫描和软换行按钮显示在独立的 36pt 操作栏,检查 16pt IDEA 图标、22pt 共享按钮的悬停、按下、选中及禁用状态;操作栏只保留一个运行/重运行动作:首次启动显示 Run,当前配置已运行过后显示 Rerun;右侧固定停止按钮,未运行禁用、运行中启用,两者始终指向当前选中配置;操作栏不重复显示 Running 圆点及状态文字,重运行按钮复用顶部工具栏的绿色 rerun_stroke 图标;标题栏菜单中的清空输出与原操作一致,收起按钮沿用共享样式;仅有 Current File 的项目启动后也可从 Run 操作栏停止,结束后保留重运行入口;Run 内容区不额外插入 Java 项目准备状态行,准备进度和失败入口保留在工作台状态栏;Run 标题、操作栏、配置列表标题、控制台标签底边及两侧纵向分割线统一使用工具窗口边框颜色和 1pt 绘制,左侧图标栏不出现 5pt 实心粗线,栏内分组线与 Git Log 一致使用共享 divider 颜色、22pt 宽度、1pt 高度及上下 3pt 间距;操作按钮组之间使用专用 ActionToolbar.separator,详情编辑按钮的边框由共享按钮样式负责,拖动命中范围保持不变;配置树直接显示分组与数量,不显示 Application types 标题;默认隐藏勾选框,通过更多菜单或右键进入批量选择,退出清空选择;配置单行显示名称,类型及服务地址放到悬停提示,行内仅运行/停止,编辑/固定/打开服务保留在共享右键菜单,固定后显示标记;分组与配置使用共享树行高、缩进及焦点选择样式;Run 范围筛选以固定图标栏呈现,应用范围筛选及当前运行导航使用中性 ToolWindowRun 图标,实际运行操作继续使用绿色 Execute 图标;全部采用 IDEA groups、服务采用 server 图标,固定、应用及任务沿用对应 IDEA 原始资源;检查所有图标的 16pt 尺寸、明暗 SVG 自动切换、提示和筛选,右键勾选范围和配置行勾选共享同一批量选择;配置行使用 IDEA 固定、编辑、运行及停止图标,悬停与点击维持箭头光标;配置过期提示使用共享中性通知表面及警告图标,操作仍可重新识别;Run 标题栏仅显示 Run,移除重复配置名与右侧状态胶囊;Console 和 Configuration details 标签位于运行操作按钮组右侧,配置列表选中项表示当前配置,内容区直接显示日志或详情;配置详情使用共享 13pt 字体、原生 Grid 自适应标签列、可选择且可换行的路径及值,编辑配置按钮复用共享 LitheCommitDialogButtonStyle 与 IDEA EditSource 图标,检查深浅主题、窄列及点击跳转设置,服务链接保留原动作;两个标签使用共享 LitheToolWindowTabStyle,检查 13pt 文字、28pt 标签、原始明暗 SVG、悬停及内容区聚焦/失焦状态,保留标签切换;Run 与 Debug 均启用共享 OutputTextView 的 IDEA 渲染入口,运行日志采用一致的 13pt 字体、1.2 倍行高和 ANSI 配色,不给普通 INFO 日志整行染蓝,源码链接继续跳转;Copy 与 Jump to latest 为共享图标按钮,复制不带 ANSI,向上滚动后可恢复跟随;其他配置恢复状态保留各自提示。 Current File 运行一次后修改未保存并切换编辑标签,重运行应先保存原运行文件再构建启动计划;原文件保存失败时提示错误并中止重启,当前焦点文档不被误保存;调整 Run 面板高度,外层横向操作栏固定为 36pt,剩余高度交给配置树和控制台。 缓存目录不可访问时仍可发现兄弟目录和隐藏源码;可选子目录不可读时保留其旧配置并显示 incompleteProjectScan,不误报删除或新鲜;根目录不可读仍失败,完整重新生成后解除未知基线。 两端 Run 面板显示扫描不完整的共享中英文提示;恢复访问权限后重新识别。", + "verification": "验证 Spring Boot、Java、Maven、Gradle、npm、Cargo、Go、Python 和 Docker Compose 入口识别;Maven 框架 plugin 只在父 POM 声明时,子模块沿 链继承后仍识别为对应服务,父 POM 按 groupId:artifactId:version 完整坐标匹配、仅同名 artifactId 或版本不符的不继承、含未解析 ${...} 表达式的坐标不作数,聚合但未声明 的模块不继承、false 的 plugin 不下传且对更深层级保持阻断、 声明保持不生效、框架 plugin 按完整坐标 groupId:artifactId 识别:每个框架的全部官方坐标组均被接受(Quarkus 的 io.quarkus 与平台再发布的 io.quarkus.platform,Micronaut 3.x 的 io.micronaut.build 与 4+ 的 io.micronaut.maven,Spring Boot 的 org.springframework.boot)、省略 的声明按 Maven 默认组 org.apache.maven.plugins 处理,其他组下的同名插件与含 ${...} 表达式的 group 不识别为框架,子模块的同名自定义 plugin 也不遮蔽父继承的正品;源码根与报告目录的提取同样按完整坐标匹配,build-helper 执行级源根与 Surefire/Failsafe 独立 reportsDirectory 均有官方组与自定义组对照;Java 运行入口缺少主类时 inspect 报 staleJavaEntrypoint 并提示重新生成,重新生成后由 JDT 答案恢复;多模块项目把服务工作目录改成模块目录后,服务仍留在列表中并以该目录启动;填写不存在的目录或 ${workspaceFolder} 等变量时,保存被拒绝并提示原因;在 Windows 确认重新扫描服务使用双箭头刷新图标,配置按语言生态、类型与实例呈现三栏布局,Java 下展示 Maven、Gradle 与 Spring Boot 等已识别类型,Node.js 下展示包管理器与框架;语言切换和外部选中能定位配置,空生态不显示,语言和配置栏不显示重复栏头,生态不显示配置数量;语言侧栏通过拖动阈值折叠和展开,收起与展开阈值不同以避免边界跳动,不显示专门折叠按钮;键盘可调整宽度及恢复展开,保存展开宽度与拖动折叠状态,拖动期间只更新局部宽度和紧凑标记,文字与图标对齐即时切换,结束后保存;语言行、类型行和实例行使用一致的紧凑行高,去掉列表上下留白和选中实例的圆角边距;窄面板自动缩成带提示的图标栏且扩大后恢复,右侧输出保留可用空间,窗口缩放或卸载时清理正在进行的拖动;所选配置的日志、运行状态、顶部停止按钮、清空输出与标准输入均指向其自身会话;保持选中 application 并后台启动服务后,顶部停止仍指向 primary,不停止后台服务,左侧使用 SVG 箭头和类型图标,基础设施默认折叠,选择配置自动展开所在分组,运行、停止、编辑和完整名称提示可用;根据 Core 提供的 npm、Gradle 和 Python 元数据展示明确识别的框架图标,构建与测试任务保留工具图标,未知类型回退通用图标;同名自定义 provider 与已知框架分别分组且折叠状态独立,相同已知框架跨 Maven/Gradle provider 合组,项目与基础设施不合组;Java 生态分组使用 Java 文件类型图标,Java 启动类实例使用原始 Class 与 RunnableMark SVG 同画布叠加图标;Maven 启动的框架服务使用 MavenRunConfigurationType 对应的 Maven 图标,Make 外部任务分组使用任务文件夹、实例使用 Shell 配置图标,均保留原始明暗 SVG,运行或失败不改变类型图标,深浅主题均可辨认;macOS Run 缺少配置且可重新生成时,检查空状态文字与链接在深浅主题下按 IDEA 工具窗口样式显示、窄面板不截断,并确认“识别并生成”仍触发原有生成流程;应用语言设为英文而系统语言为中文时,Run 空状态及其他本面板文案仍应显示英文;Run 顶部标题字号、颜色、41pt 高度和底部分隔线应与 Terminal、Git Log 等工具窗口一致;有项目配置或 Current File 正在运行/已有退出结果时,运行、重启、重新扫描和软换行按钮显示在独立的 36pt 操作栏,检查 16pt IDEA 图标、22pt 共享按钮的悬停、按下、选中及禁用状态;操作栏只保留一个运行/重运行动作:首次启动显示 Run,当前配置已运行过后显示 Rerun;右侧固定停止按钮,未运行禁用、运行中启用,两者始终指向当前选中配置;操作栏不重复显示 Running 圆点及状态文字,重运行按钮复用顶部工具栏的绿色 rerun_stroke 图标;标题栏菜单中的清空输出与原操作一致,收起按钮沿用共享样式;仅有 Current File 的项目启动后也可从 Run 操作栏停止,结束后保留重运行入口;Run 内容区不额外插入 Java 项目准备状态行,准备进度和失败入口保留在工作台状态栏;Run 标题、操作栏、配置列表标题、控制台标签底边及两侧纵向分割线统一使用工具窗口边框颜色和 1pt 绘制,左侧图标栏不出现 5pt 实心粗线,栏内分组线与 Git Log 一致使用共享 divider 颜色、22pt 宽度、1pt 高度及上下 3pt 间距;操作按钮组之间使用专用 ActionToolbar.separator,详情编辑按钮的边框由共享按钮样式负责,拖动命中范围保持不变;配置树直接显示分组与数量,不显示 Application types 标题;默认隐藏勾选框,通过更多菜单或右键进入批量选择,退出清空选择;配置单行显示名称,类型及服务地址放到悬停提示,行内仅运行/停止,编辑/固定/打开服务保留在共享右键菜单,固定后显示标记;分组与配置使用共享树行高、缩进及焦点选择样式;Run 范围筛选以固定图标栏呈现,应用范围筛选及当前运行导航使用中性 ToolWindowRun 图标,实际运行操作继续使用绿色 Execute 图标;全部采用 IDEA groups、服务采用 server 图标,固定、应用及任务沿用对应 IDEA 原始资源;检查所有图标的 16pt 尺寸、明暗 SVG 自动切换、提示和筛选,右键勾选范围和配置行勾选共享同一批量选择;配置行使用 IDEA 固定、编辑、运行及停止图标,悬停与点击维持箭头光标;配置过期提示使用共享中性通知表面及警告图标,操作仍可重新识别;Run 标题栏仅显示 Run,移除重复配置名与右侧状态胶囊;Console 和 Configuration details 标签位于运行操作按钮组右侧,配置列表选中项表示当前配置,内容区直接显示日志或详情;配置详情使用共享 13pt 字体、原生 Grid 自适应标签列、可选择且可换行的路径及值,编辑配置按钮复用共享 LitheCommitDialogButtonStyle 与 IDEA EditSource 图标,检查深浅主题、窄列及点击跳转设置,服务链接保留原动作;两个标签使用共享 LitheToolWindowTabStyle,检查 13pt 文字、28pt 标签、原始明暗 SVG、悬停及内容区聚焦/失焦状态,保留标签切换;Run 与 Debug 均启用共享 OutputTextView 的 IDEA 渲染入口,运行日志采用一致的 13pt 字体、1.2 倍行高和 ANSI 配色,不给普通 INFO 日志整行染蓝,源码链接继续跳转;Copy 与 Jump to latest 为共享图标按钮,复制不带 ANSI,向上滚动后可恢复跟随;其他配置恢复状态保留各自提示。 Current File 运行一次后修改未保存并切换编辑标签,重运行应先保存原运行文件再构建启动计划;原文件保存失败时提示错误并中止重启,当前焦点文档不被误保存;调整 Run 面板高度,外层横向操作栏固定为 36pt,剩余高度交给配置树和控制台。 缓存目录不可访问时仍可发现兄弟目录和隐藏源码;可选子目录不可读时保留其旧配置并显示 incompleteProjectScan,不误报删除或新鲜;根目录不可读仍失败,完整重新生成后解除未知基线。 两端 Run 面板显示扫描不完整的共享中英文提示;恢复访问权限后重新识别。 Java 文件枚举达到 8,000 条上限后不再访问后续目录,返回顺序稳定;不可读兄弟目录不阻断结果,完整指纹扫描仍继续遍历。", "notes": " Windows普通Java文件的main入口在工作区搜索遗漏时,先保存并让JDT对当前文件确认,再生成命名运行配置;拒绝过期入口、准备失败和工作区切换结果。Java入口源码按容错UTF-8读取(非法字节替换为占位符),GBK项目不再因单个非UTF-8文件导致整个运行列表生成失败,Spring Boot注解等ASCII分类标记不受影响。 macOS 运行就绪要求匹配快照且工具链和配置选择加载完成,加载中的清单不可作为启动许可;受控工具链与快照测试验证完成前后状态,以及 Current File 不被迟到的默认配置覆盖。" }