_PATH`、`PATH` 和常见系统目录;`gopls` 等 Go 工具还会检查 `GOBIN`、`GOPATH/bin`、`~/go/bin` 和 `~/.go/bin`。discovery 只查找,不自动安装软件。
+正式 macOS 与 Windows 安装包包含 JDTLS。发布构建根据 `third_party/jdtls/manifest.json` 下载固定版本,同时校验归档与 EPL-2.0 许可证的 SHA-256,再将产物放入应用资源目录的 `LanguageServers/jdtls`。平台 adapter 优先使用这个包内启动器;开发环境仍保留项目工具目录、显式覆盖和 `PATH` 等外部候选作为回退。下载只发生在构建阶段,应用运行时不会联网安装 JDTLS;Java 语义功能仍要求系统提供 JDK 17 或更高版本。
+
+JDTLS 使用独立于项目运行/调试配置的全局 JDK 偏好。空路径表示自动模式:平台探测本机 JDK,并只选择主版本 17 或更高的候选。用户也可以在语言服务器设置中选择单独的 JDK Home;平台会在保存时探测 `java -version`,并在每次启动 JDTLS 前再次校验路径和版本。这个偏好不会读取、覆盖或写回项目的 Java/Maven JDK 设置。
+
LSP 控制中心标题栏的工具设置会在用户偏好中保存每个 provider 的可执行文件覆盖路径。session 创建时先验证并使用该路径,路径失效时继续使用 catalog 候选进行自动探测。Homebrew formula 和官方兜底地址都来自 `languageServerInstallation`,Swift 不维护 provider ID 映射。安装仍由平台层以参数数组直接执行 `brew install`,不经过 shell;没有 Homebrew/formula 时只打开对应项目的 HTTPS 官方发布或安装页面,避免用一套不安全的通用解压逻辑处理不同项目的签名和包结构。
项目配置是可执行工具配置,只有打开受信任项目时才应启用。JSON 可以声明 executable name 和参数,但不能声明 shell、任意安装命令或关闭路径/URL 校验;进程创建、超时、可执行文件验证、Homebrew 调用方式和 HTTPS 限制仍属于平台安全边界。
diff --git a/docs/architecture/windows-development-plan.md b/docs/architecture/windows-development-plan.md
index 3b15fe30a..510055b60 100644
--- a/docs/architecture/windows-development-plan.md
+++ b/docs/architecture/windows-development-plan.md
@@ -33,8 +33,11 @@ platform contract and enabled in the UI only when the capability exists.
## Remaining product work
1. Align each Git feature API with the stable `git.*` command DTOs.
-2. Route workspace search, Local History, LSP, Java/Maven, and run
- configurations through the same dispatcher.
+2. Route workspace search, Local History, remaining non-Java LSP, Java/Maven,
+ and run configurations through the same dispatcher. Built-in Java LSP now
+ starts through the Windows host (`jdtls` + JDK discovery) and
+ `lsp.startServer` with `providerId: "java"`. Spring configuration, `@Value`,
+ and bean-injection navigation uses `spring.index` before falling back to LSP.
3. Implement Windows-owned process, debug, update, and secure-storage flows in
Rust where the current UI exposes them.
4. Hide or capability-gate future feature surfaces until their shared backend
diff --git a/docs/assets/screenshots/windows-git-added-single-column.png b/docs/assets/screenshots/windows-git-added-single-column.png
new file mode 100644
index 000000000..6202167f6
Binary files /dev/null and b/docs/assets/screenshots/windows-git-added-single-column.png differ
diff --git a/docs/assets/screenshots/windows-git-compact-split.png b/docs/assets/screenshots/windows-git-compact-split.png
new file mode 100644
index 000000000..8f1c4dd1d
Binary files /dev/null and b/docs/assets/screenshots/windows-git-compact-split.png differ
diff --git a/docs/assets/screenshots/windows-git-deleted-single-column.png b/docs/assets/screenshots/windows-git-deleted-single-column.png
new file mode 100644
index 000000000..afe253b11
Binary files /dev/null and b/docs/assets/screenshots/windows-git-deleted-single-column.png differ
diff --git a/docs/assets/screenshots/windows-git-log-tool-window.png b/docs/assets/screenshots/windows-git-log-tool-window.png
new file mode 100644
index 000000000..1159a12be
Binary files /dev/null and b/docs/assets/screenshots/windows-git-log-tool-window.png differ
diff --git a/docs/assets/screenshots/windows-git-split-addition.png b/docs/assets/screenshots/windows-git-split-addition.png
new file mode 100644
index 000000000..c83287138
Binary files /dev/null and b/docs/assets/screenshots/windows-git-split-addition.png differ
diff --git a/docs/assets/screenshots/windows-git-split-deletion.png b/docs/assets/screenshots/windows-git-split-deletion.png
new file mode 100644
index 000000000..b57616015
Binary files /dev/null and b/docs/assets/screenshots/windows-git-split-deletion.png differ
diff --git a/docs/assets/screenshots/windows-zh-localization.png b/docs/assets/screenshots/windows-zh-localization.png
new file mode 100644
index 000000000..4a16862e2
Binary files /dev/null and b/docs/assets/screenshots/windows-zh-localization.png differ
diff --git a/docs/superpowers/plans/2026-08-17-project-tab-bar.md b/docs/superpowers/plans/2026-08-17-project-tab-bar.md
new file mode 100644
index 000000000..1c561176a
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-17-project-tab-bar.md
@@ -0,0 +1,177 @@
+# Mac-Style Project Tab Bar Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task with verification checkpoints.
+
+**Goal:** Add a horizontal project tab bar below the Windows title bar so every project open in the current window is visible and can be activated with one click.
+
+**Architecture:** Keep project persistence and switching in the existing Zustand/file-system stores. Add a small pure model helper to normalize the active tab for deterministic tests, a focused `ProjectTabBar` presentation component for rendering and activation, and mount it from `MainLayout` directly below `TitleBarWithSettings`. The existing title-bar project dropdown remains unchanged as the project-management menu.
+
+**Tech Stack:** React 19, TypeScript, Zustand selectors, Base UI-compatible buttons, Tailwind semantic tokens, Bun tests, Vite Plus.
+
+---
+
+### Task 1: Normalize Project Tab Data
+
+**Files:**
+- Create: `windows/tauri/src/features/window/utils/project-tab-bar-model.ts`
+- Create: `windows/tauri/src/features/window/utils/project-tab-bar-model.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+Add a fixture with two project tabs and assert that `getProjectTabBarItems` preserves order, marks only the first active project, and repairs an invalid multiple-active input by keeping the first active tab.
+
+```ts
+test("preserves project order and exposes one active tab", () => {
+ const result = getProjectTabBarItems([
+ { id: "a", name: "Alpha", path: "D:/alpha", isActive: true, lastOpened: 1 },
+ { id: "b", name: "Beta", path: "D:/beta", isActive: true, lastOpened: 2 },
+ ]);
+
+ expect(result.map((tab) => tab.name)).toEqual(["Alpha", "Beta"]);
+ expect(result.map((tab) => tab.isActive)).toEqual([true, false]);
+});
+```
+
+- [ ] **Step 2: Run the focused test and confirm the expected failure**
+
+Run from `windows/tauri`:
+
+```powershell
+bun test src/features/window/utils/project-tab-bar-model.test.ts
+```
+
+Expected: the test fails because `project-tab-bar-model.ts` does not exist yet.
+
+- [ ] **Step 3: Implement the minimal model helper**
+
+Implement `getProjectTabBarItems(projectTabs)` by finding the first `isActive` tab and mapping the input in its original order, setting `isActive` only for that ID while preserving the other display fields.
+
+- [ ] **Step 4: Run the focused test and confirm it passes**
+
+Run the same Bun test. Expected: 1 test passes, 0 failures.
+
+### Task 2: Build The Project Tab Bar
+
+**Files:**
+- Create: `windows/tauri/src/features/window/components/project-tab-bar.tsx`
+- Create: `windows/tauri/src/features/window/components/project-tab-bar.test.ts`
+
+- [ ] **Step 1: Write the failing component contract test**
+
+Add a source-level contract test that requires the component to expose `role="tablist"`, `role="tab"`, `aria-selected`, `isSwitchingProject`, and `switchToProject`. This protects the accessibility and switching contract without introducing a new renderer test dependency.
+
+- [ ] **Step 2: Run the focused test and confirm the expected failure**
+
+Run:
+
+```powershell
+bun test src/features/window/components/project-tab-bar.test.ts
+```
+
+Expected: the test fails because the component file does not exist yet.
+
+- [ ] **Step 3: Implement the component**
+
+Implement the component with these exact behaviors:
+
+```tsx
+const projectTabs = useWorkspaceTabsStore.use.projectTabs();
+const switchToProject = useFileSystemStore((state) => state.switchToProject);
+const isSwitchingProject = useFileSystemStore((state) => state.isSwitchingProject);
+const projects = getProjectTabBarItems(projectTabs);
+
+if (projects.length === 0) return null;
+
+return (
+
+ {projects.map((project) => (
+
+ ))}
+
+);
+```
+
+Use the existing `FolderOpenIcon`, semantic surface/border/selected tokens, fixed 30px tab height, horizontal overflow, visible focus styles, and truncated names. Do not add a native drag region or duplicate project-management actions.
+
+- [ ] **Step 4: Run the focused component contract test**
+
+Run the same Bun test. Expected: 1 test passes, 0 failures.
+
+### Task 3: Mount The Bar In The Workbench
+
+**Files:**
+- Modify: `windows/tauri/src/features/layout/components/main-layout.tsx`
+
+- [ ] **Step 1: Add the import and mount point**
+
+Import `ProjectTabBar` from the window feature and render `` immediately after ``, before the root-folder conditional. The component itself returns `null` when no project is open, preserving the welcome screen layout.
+
+- [ ] **Step 2: Run focused tests and typecheck**
+
+Run:
+
+```powershell
+bun test src/features/window/utils/project-tab-bar-model.test.ts src/features/window/components/project-tab-bar.test.ts
+bun run typecheck
+```
+
+Expected: all focused tests pass and TypeScript exits with code 0.
+
+### Task 4: Verify The User Workflow
+
+**Files:**
+- No additional source files.
+
+- [ ] **Step 1: Run lint and frontend build**
+
+```powershell
+bunx vp lint src/features/layout/components/main-layout.tsx src/features/window/components/project-tab-bar.tsx src/features/window/components/project-tab-bar.test.ts src/features/window/utils/project-tab-bar-model.ts src/features/window/utils/project-tab-bar-model.test.ts
+bun run build
+git diff --check
+```
+
+Expected: all commands exit 0. Existing dependency warnings are acceptable if they do not introduce errors.
+
+- [ ] **Step 2: Rebuild and launch Windows Release**
+
+Stop the current preview process, run `.\scripts\build-windows.ps1 -Configuration Release` from the repository root, and launch `windows/tauri/src-tauri/target/x86_64-pc-windows-msvc/release/lithe-windows.exe`.
+
+- [ ] **Step 3: Verify the live tab interaction through CDP**
+
+Open two projects in the current window, assert a visible `[role="tablist"]` contains both project names, click the inactive `[role="tab"]`, and assert its `aria-selected` becomes `true` while the previous tab becomes `false`. Confirm the title-bar project label changes to the newly active project.
+
+- [ ] **Step 4: Run the final focused checks**
+
+```powershell
+bun test src/features/window/utils/project-tab-bar-model.test.ts src/features/window/components/project-tab-bar.test.ts
+bun run typecheck
+git diff --check
+```
+
+Expected: all tests pass, typecheck passes, and no whitespace errors are reported.
+
+### Task 5: Review And Commit
+
+**Files:**
+- Stage only the new project-tab-bar source/tests, `main-layout.tsx`, and the task plan/progress files.
+
+- [ ] **Step 1: Inspect the task diff and run an independent review**
+
+Check `git diff` and confirm unrelated pre-existing changes remain unstaged. Resolve any Critical or Important review findings before committing.
+
+- [ ] **Step 2: Create one focused implementation commit**
+
+```powershell
+git add -- windows/tauri/src/features/layout/components/main-layout.tsx windows/tauri/src/features/window/components/project-tab-bar.tsx windows/tauri/src/features/window/components/project-tab-bar.test.ts windows/tauri/src/features/window/utils/project-tab-bar-model.ts windows/tauri/src/features/window/utils/project-tab-bar-model.test.ts .planning/2026-08-17-project-tab-bar/task_plan.md .planning/2026-08-17-project-tab-bar/findings.md .planning/2026-08-17-project-tab-bar/progress.md docs/superpowers/plans/2026-08-17-project-tab-bar.md
+git commit -m "feat: add mac-style project tabs"
+```
diff --git a/docs/superpowers/specs/2026-08-17-cross-platform-logging-requirements.md b/docs/superpowers/specs/2026-08-17-cross-platform-logging-requirements.md
new file mode 100644
index 000000000..b688006ea
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-17-cross-platform-logging-requirements.md
@@ -0,0 +1,60 @@
+# Lithe macOS 日志目录设置需求
+
+## 背景
+
+macOS 设置界面没有告诉用户日志默认保存在哪里,也不能修改日志保存目录。
+
+本需求只完善 macOS 日志目录的查看和配置,并将应用现有的字体注册和性能基线诊断写入该目录,不涉及 Windows 端或业务日志扩展。
+
+## 目标
+
+在设置中增加日志目录设置,让用户能够:
+
+- 查看系统默认日志目录;
+- 查看当前选定的日志目录;
+- 选择新的日志目录;
+- 恢复默认日志目录;
+- 复制完整目录路径。
+
+## 目录行为
+
+### 默认目录
+
+默认目录是 macOS 用户日志目录下的 `Lithe` 目录。设置页面展示系统解析后的真实绝对路径,不能硬编码开发机路径。
+
+### 自定义目录
+
+用户通过系统目录选择器选择目录。选择成功后保存配置,应用诊断输出立即切换到该目录中的 `lithe.log`,应用重启后继续使用该选择。该设置不影响其他配置。
+
+如果自定义目录无法创建或打开,应用清除该选择并恢复默认目录,界面不得继续把失败目录显示为当前日志目录。
+
+“恢复默认目录”只清除自定义日志目录。“恢复全部默认设置”也应清除该配置。
+
+## 界面要求
+
+日志设置页面显示默认目录和当前选定目录,并提供“选择目录”“恢复默认”操作。
+
+长路径可以换行或缩略显示,但必须支持复制完整路径。
+
+## 不在范围内
+
+本次不做:
+
+- 新建结构化日志协议;
+- 新建结构化日志 writer、业务 sink 或导出逻辑;
+- 新增文件轮换和 ownership manifest;
+- 新增 Run、Build、Test、Debug、LSP、Terminal 生命周期日志;
+- 新增应用、窗口、工作区或文件事件日志;
+- 新增帧率采样或性能日志;
+- 修改日志格式、日志级别或日志内容;
+- 自动上传、云端查看或崩溃转储。
+
+## 验收标准
+
+1. 设置页显示真实默认目录和当前选定目录。
+2. 用户可以选择自定义目录,重启后配置仍然存在。
+3. 用户可以恢复默认目录。
+4. “恢复全部默认设置”会清除自定义日志目录。
+5. 用户可以复制完整路径。
+6. 现有字体注册和性能基线诊断写入当前目录的 `lithe.log`,切换目录后新输出写入新目录。
+7. 没有引入新的结构化日志协议、业务埋点或帧率监控。
diff --git a/docs/superpowers/specs/2026-08-17-project-tab-bar-design.md b/docs/superpowers/specs/2026-08-17-project-tab-bar-design.md
new file mode 100644
index 000000000..d32ef35d8
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-17-project-tab-bar-design.md
@@ -0,0 +1,28 @@
+# Mac-Style Project Tab Bar
+
+## Goal
+
+Show every project opened in the current Windows workbench in a compact tab bar below the title bar, matching the macOS workbench pattern. Clicking a tab activates that project immediately while the existing title-bar project menu remains the entry point for creating, opening, cloning, and selecting recent projects.
+
+## Design
+
+- Add a `ProjectTabBar` presentation component between `TitleBarWithSettings` and the workbench content in `MainLayout`.
+- Read project order and active state from `useWorkspaceTabsStore`; use `useFileSystemStore.switchToProject` for activation.
+- Render one semantic button per open project with its project badge/icon, name, active styling, and a full-path tooltip/accessible label.
+- Use a horizontal overflow container so the bar remains stable when many projects are open. Do not resize the title bar or use the tab bar as a native drag region.
+- Disable project buttons while `isSwitchingProject` is true, preserving the existing stale-switch protection.
+- Hide the bar when no project is open, so the welcome screen keeps its current vertical layout.
+- Preserve the existing `TitleProjectMenu` dropdown and all of its actions.
+
+## Accessibility And Visual Behavior
+
+- Use `role="tablist"` on the strip and `role="tab"` plus `aria-selected` on each project button.
+- Keep a visible focus ring and selected background/border using existing semantic design tokens.
+- Use the existing project badge/icon rules and Lucide-style folder icon; no new color palette or decorative artwork.
+- Keep the tab height and spacing fixed to prevent layout shift, and use text truncation with a tooltip for long names.
+
+## Testing
+
+- Add a pure model helper test that preserves project order and exposes exactly one active tab.
+- Add component-level source/behavior coverage for the tab button activation callback where the existing frontend test setup permits it.
+- Run focused Bun tests, typecheck, lint, frontend build, and a live Windows CDP check that opens two projects and activates the second tab.
diff --git a/rust/lithe-core/src/execution/configuration.rs b/rust/lithe-core/src/execution/configuration.rs
index 7e71ebe8b..49da3aab5 100644
--- a/rust/lithe-core/src/execution/configuration.rs
+++ b/rust/lithe-core/src/execution/configuration.rs
@@ -22,6 +22,9 @@ const SIDECAR_VERSION: u32 = 1;
/// Request to validate the layered configuration documents for a workspace.
pub struct InspectRequest {
pub root: String,
+ /// Host-owned local layer. When present, Core validates it instead of `.lithe/run/local.json`.
+ #[serde(default)]
+ pub local_document: Option,
}
#[derive(Debug, Deserialize)]
@@ -42,6 +45,9 @@ pub struct ResolveRequest {
pub root: String,
#[serde(default)]
pub toolchain_candidates: Vec,
+ /// Host-owned local layer. When present, Core uses it instead of `.lithe/run/local.json`.
+ #[serde(default)]
+ pub local_document: Option,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -71,6 +77,21 @@ pub struct LaunchPlanRequest {
pub class_path: Option,
#[serde(default)]
pub debug_port: Option,
+ /// Host-owned local layer. When present, Core uses it instead of `.lithe/run/local.json`.
+ #[serde(default)]
+ pub local_document: Option,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+/// Global machine toolchain stored at document level in the local layer.
+pub struct ToolchainPaths {
+ #[serde(default)]
+ pub java_home_path: String,
+ #[serde(default)]
+ pub maven_executable_path: String,
+ #[serde(default)]
+ pub maven_java_home_path: String,
}
#[derive(Debug, Deserialize)]
@@ -99,6 +120,13 @@ pub struct UpdateOptionsRequest {
pub maven_executable_path: String,
#[serde(default)]
pub maven_java_home_path: String,
+ /// When present, `updateOptions` writes the document-level global toolchain
+ /// into the local layer instead of patching one configuration.
+ #[serde(default)]
+ pub toolchain: Option,
+ /// Host-owned local layer used when `scope` is `local` and when resolving first.
+ #[serde(default)]
+ pub local_document: Option,
}
#[derive(Debug, Deserialize)]
@@ -318,6 +346,15 @@ pub fn inspect(request: InspectRequest) -> Result {
"toolchains/local.json",
"project.json",
] {
+ if relative == "run/local.json" {
+ if let Some(document) = request.local_document.as_ref() {
+ let mut migrated = document.clone();
+ migrate_document_value(&mut migrated);
+ validate_version_value(&migrated)?;
+ configuration_ids(&migrated)?;
+ continue;
+ }
+ }
if let Some(document) = read_document_value(&root, relative)? {
if relative.starts_with("run/") {
validate_version_value(&document)?;
@@ -414,6 +451,11 @@ pub fn generate(request: GenerateRequest) -> Result {
.filter(|value| value.is_spring_boot)
.map(|value| (value.path.clone(), value.qualified_name.clone()))
.collect::>();
+ let main_class_sources = scanned
+ .main_classes
+ .iter()
+ .map(|value| (value.qualified_name.clone(), value.path.clone()))
+ .collect::>();
let configurations = scanned
.configurations
.into_iter()
@@ -431,9 +473,27 @@ pub fn generate(request: GenerateRequest) -> Result {
.map(|path| maven_module_path(maven_relative_path, path))
.unwrap_or_else(|| ".".to_string());
maven.insert("module".to_string(), json!(module_path));
- if let Some(main_class) = value.main_class {
+ if let Some(main_class) = value.main_class.as_ref() {
maven.insert("mainClass".to_string(), json!(main_class));
}
+ let source_path = value
+ .main_class
+ .as_ref()
+ .and_then(|name| main_class_sources.get(name))
+ .cloned();
+ // A workspace without Maven still produces java.main entries. Binding
+ // project-maven there would force every plain Java class through mvn.
+ let uses_maven_toolchain = has_maven_project && provider != "java.current-file";
+ let mut toolchains = BTreeMap::new();
+ toolchains.insert("java".to_string(), "project-jdk".to_string());
+ if uses_maven_toolchain {
+ toolchains.insert("maven".to_string(), "project-maven".to_string());
+ }
+ let mut extensions = BTreeMap::new();
+ extensions.insert("maven".to_string(), Value::Object(maven));
+ if let Some(path) = source_path.as_ref() {
+ extensions.insert("java".to_string(), json!({ "source": path }));
+ }
RunConfiguration {
id,
name: value.name,
@@ -451,27 +511,14 @@ pub fn generate(request: GenerateRequest) -> Result {
},
env: BTreeMap::new(),
confidence: Confidence::Native,
- toolchains: if provider == "java.current-file" {
- [("java".to_string(), "project-jdk".to_string())]
- .into_iter()
- .collect()
- } else {
- [
- ("java".to_string(), "project-jdk".to_string()),
- ("maven".to_string(), "project-maven".to_string()),
- ]
- .into_iter()
- .collect()
- },
+ toolchains,
debug: (provider != "java.main").then(|| DebugCapability {
adapter: "jdwp".to_string(),
}),
members: Vec::new(),
- extensions: [("maven".to_string(), Value::Object(maven))]
- .into_iter()
- .collect(),
+ extensions,
disabled: false,
- source: None,
+ source: source_path,
}
})
.collect::>();
@@ -765,8 +812,7 @@ pub fn resolve(request: ResolveRequest) -> Result {
})?;
let team = read_document_value(&root, "run/configurations.json")?
.unwrap_or_else(|| json!({"version": VERSION, "configurations": []}));
- let local = read_document_value(&root, "run/local.json")?
- .unwrap_or_else(|| json!({"version": VERSION, "configurations": []}));
+ let local = local_layer_document(&root, request.local_document)?;
let manifest = read_document_value(&root, "project.json")?;
validate_version_value(&generated)?;
validate_version_value(&team)?;
@@ -789,6 +835,10 @@ pub fn resolve(request: ResolveRequest) -> Result {
}
}
let mut configurations = merge_values(&generated, &team, &local)?;
+ let global_toolchain = local.get("toolchain").cloned();
+ if let Some(toolchain) = global_toolchain.as_ref() {
+ apply_global_toolchain(&mut configurations, toolchain);
+ }
for configuration in &mut configurations {
validate_configuration(configuration)?;
if configuration.disabled {
@@ -853,17 +903,61 @@ pub fn resolve(request: ResolveRequest) -> Result {
"version": VERSION,
"configurations": configurations,
"diagnostics": diagnostics,
- "defaultRunConfiguration": default_run_configuration
+ "defaultRunConfiguration": default_run_configuration,
+ "toolchain": global_toolchain
}))
}
+fn apply_global_toolchain(configurations: &mut [RunConfiguration], toolchain: &Value) {
+ let java_home = toolchain["java"]["homePath"].as_str().unwrap_or("");
+ let maven_executable = toolchain["maven"]["executablePath"].as_str().unwrap_or("");
+ let maven_java_home = toolchain["maven"]["javaHomePath"].as_str().unwrap_or("");
+ for configuration in configurations {
+ if !configuration.toolchains.contains_key("java")
+ && !configuration.toolchains.contains_key("maven")
+ {
+ continue;
+ }
+ let java = configuration
+ .extensions
+ .entry("java".to_string())
+ .or_insert_with(|| json!({}));
+ if let Some(object) = java.as_object_mut() {
+ object.insert("homePath".to_string(), json!(java_home));
+ object.insert("mavenExecutablePath".to_string(), json!(maven_executable));
+ object.insert("mavenJavaHomePath".to_string(), json!(maven_java_home));
+ }
+ }
+}
+
/// Persists editable configuration options in the requested ownership layer.
pub fn update_options(request: UpdateOptionsRequest) -> Result {
let root = existing_root(&request.root)?;
+ if let Some(toolchain) = request.toolchain {
+ if request.scope != "local" {
+ return Err(CoreError::new(
+ ErrorCode::InvalidRequest,
+ "Toolchain paths can only be saved in the local layer",
+ ));
+ }
+ let mut document = local_layer_document(&root, request.local_document)?;
+ validate_version_value(&document)?;
+ document["toolchain"] = json!({
+ "java": { "homePath": toolchain.java_home_path },
+ "maven": {
+ "executablePath": toolchain.maven_executable_path,
+ "javaHomePath": toolchain.maven_java_home_path
+ }
+ });
+ return Ok(json!({
+ "document": serde_json::to_string_pretty(&document).expect("document should encode")
+ }));
+ }
let relative = scope_document(&request.scope)?;
let resolved = resolve(ResolveRequest {
root: request.root.clone(),
toolchain_candidates: Vec::new(),
+ local_document: request.local_document.clone(),
})?;
let provider = resolved["configurations"]
.as_array()
@@ -877,6 +971,12 @@ pub fn update_options(request: UpdateOptionsRequest) -> Result
CoreError::new(ErrorCode::InvalidRequest, "Run configuration was not found")
})?;
let uses_maven_capability = is_maven_backed(provider);
+ let java_home_path =
+ normalize_scoped_toolchain_path(&root, &request.scope, &request.java_home_path)?;
+ let maven_executable_path =
+ normalize_scoped_toolchain_path(&root, &request.scope, &request.maven_executable_path)?;
+ let maven_java_home_path =
+ normalize_scoped_toolchain_path(&root, &request.scope, &request.maven_java_home_path)?;
let working_directory = normalize_project_directory(
&root,
if request.working_directory.trim().is_empty() {
@@ -886,8 +986,12 @@ pub fn update_options(request: UpdateOptionsRequest) -> Result
},
false,
)?;
- let mut document = read_document_value(&root, relative)?
- .unwrap_or_else(|| json!({"version": VERSION, "configurations": []}));
+ let mut document = if request.scope == "local" {
+ local_layer_document(&root, request.local_document)?
+ } else {
+ read_document_value(&root, relative)?
+ .unwrap_or_else(|| json!({"version": VERSION, "configurations": []}))
+ };
validate_version_value(&document)?;
let configurations = document["configurations"]
.as_array_mut()
@@ -897,6 +1001,19 @@ pub fn update_options(request: UpdateOptionsRequest) -> Result
"cwd": working_directory,
"env": request.environment
});
+ let mut java_extension = serde_json::Map::new();
+ if !java_home_path.is_empty() {
+ java_extension.insert("homePath".to_string(), json!(java_home_path));
+ }
+ if !maven_executable_path.is_empty() {
+ java_extension.insert(
+ "mavenExecutablePath".to_string(),
+ json!(maven_executable_path),
+ );
+ }
+ if !maven_java_home_path.is_empty() {
+ java_extension.insert("mavenJavaHomePath".to_string(), json!(maven_java_home_path));
+ }
if uses_maven_capability {
patch["extensions"] = json!({
"maven": {
@@ -904,16 +1021,10 @@ pub fn update_options(request: UpdateOptionsRequest) -> Result
"programArguments": split_arguments(&request.arguments),
"profiles": request.maven_profiles.into_iter().collect::>()
},
- "java": {
- "homePath": request.java_home_path,
- "mavenExecutablePath": request.maven_executable_path,
- "mavenJavaHomePath": request.maven_java_home_path
- }
- });
- } else if !request.java_home_path.is_empty() {
- patch["extensions"] = json!({
- "java": { "homePath": request.java_home_path }
+ "java": java_extension
});
+ } else if !java_extension.is_empty() {
+ patch["extensions"] = json!({ "java": java_extension });
} else {
patch["args"] = json!(split_arguments(&request.arguments));
}
@@ -1056,6 +1167,7 @@ pub fn create_launch_plan(request: LaunchPlanRequest) -> Result Result Result Result Result Result<(), CoreEr
Ok(())
}
+fn local_layer_document(root: &Path, provided: Option) -> Result {
+ if let Some(mut document) = provided {
+ // A host-owned local layer may still carry the v1 shape, mirroring the
+ // migration applied to the on-disk document below.
+ migrate_document_value(&mut document);
+ validate_version_value(&document)?;
+ configuration_ids(&document)?;
+ return Ok(document);
+ }
+ Ok(read_document_value(root, "run/local.json")?
+ .unwrap_or_else(|| json!({"version": VERSION, "configurations": []})))
+}
+
fn scope_document(scope: &str) -> Result<&'static str, CoreError> {
match scope {
"local" => Ok("run/local.json"),
@@ -1612,6 +1763,22 @@ fn scope_document(scope: &str) -> Result<&'static str, CoreError> {
}
}
+fn normalize_scoped_toolchain_path(
+ root: &Path,
+ scope: &str,
+ value: &str,
+) -> Result {
+ let value = value.trim();
+ if value.is_empty() {
+ return Ok(String::new());
+ }
+ if scope == "project" {
+ normalize_project_directory(root, value, true)
+ } else {
+ Ok(value.to_string())
+ }
+}
+
fn normalize_project_directory(
root: &Path,
value: &str,
diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs
index 88a9a0d76..a17e28088 100644
--- a/rust/lithe-core/src/git/mod.rs
+++ b/rust/lithe-core/src/git/mod.rs
@@ -12,6 +12,8 @@ use crate::protocol::{
use serde::{Deserialize, Serialize};
use std::io::Read;
use std::io::Write;
+#[cfg(target_os = "windows")]
+use std::os::windows::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::thread;
@@ -526,7 +528,7 @@ fn execute_git_with_options(
disable_optional_locks: bool,
) -> Result {
crate::protocol::cancellation::check()?;
- let mut process = Command::new("git");
+ let mut process = git_process();
process.args(arguments).current_dir(root);
if disable_optional_locks {
process.env("GIT_OPTIONAL_LOCKS", "0");
@@ -599,6 +601,21 @@ fn execute_git_with_options(
})
}
+fn git_process() -> Command {
+ let mut process = Command::new("git");
+ #[cfg(target_os = "windows")]
+ process.creation_flags(git_process_creation_flags());
+ process
+}
+
+#[cfg(target_os = "windows")]
+fn git_process_creation_flags() -> u32 {
+ // Git runs as an IDE background task; attaching a console can briefly open
+ // the user's default terminal whenever status or repository data refreshes.
+ const CREATE_NO_WINDOW: u32 = 0x08000000;
+ CREATE_NO_WINDOW
+}
+
/// Builds a structured working-tree, staged, untracked, or commit diff.
pub fn diff(request: GitDiffRequest) -> Result {
if request.pathspecs.is_empty() || request.pathspecs.iter().any(|path| !is_safe_pathspec(path))
@@ -2752,7 +2769,7 @@ fn run_git(directory: &Path, arguments: &[&str]) -> Result Vec {
texts
.iter()
diff --git a/rust/lithe-core/src/lsp/interface/process.rs b/rust/lithe-core/src/lsp/interface/process.rs
index 2f59aa1dc..d557d1305 100644
--- a/rust/lithe-core/src/lsp/interface/process.rs
+++ b/rust/lithe-core/src/lsp/interface/process.rs
@@ -9,10 +9,15 @@
use crate::protocol::{CoreError, ErrorCode};
use std::collections::BTreeMap;
use std::io::{Read, Write};
+#[cfg(target_os = "windows")]
+use std::path::Path;
use std::path::PathBuf;
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::{Arc, Mutex};
+#[cfg(target_os = "windows")]
+use std::os::windows::process::CommandExt;
+
/// Everything needed to start a language server, after provider adaptation has
/// already rewritten the arguments.
pub struct LspProcessSpec {
@@ -67,14 +72,13 @@ pub struct SystemProcessLauncher;
impl LspProcessLauncher for SystemProcessLauncher {
fn launch(&self, spec: LspProcessSpec) -> Result {
- let mut command = Command::new(&spec.executable);
+ let mut command = language_server_command(&spec);
command
- .args(&spec.arguments)
.current_dir(&spec.working_directory)
- .envs(&spec.environment)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
+ apply_language_server_creation_flags(&mut command);
let mut child = command.spawn().map_err(|error| {
CoreError::new(
ErrorCode::ProcessStartFailed,
@@ -102,6 +106,98 @@ impl LspProcessLauncher for SystemProcessLauncher {
}
}
+fn language_server_command(spec: &LspProcessSpec) -> Command {
+ #[cfg(target_os = "windows")]
+ if is_windows_batch_script(&spec.executable) {
+ let mut command = Command::new("cmd.exe");
+ command.envs(&spec.environment);
+ // Environment expansion is single-pass, so quoted values reach the batch
+ // file without cmd.exe interpreting path metacharacters or percent pairs.
+ command.env(
+ WINDOWS_BATCH_EXECUTABLE_ENV,
+ windows_batch_env_value(&spec.executable.to_string_lossy()),
+ );
+ for (index, argument) in spec.arguments.iter().enumerate() {
+ command.env(
+ windows_batch_argument_env(index),
+ windows_batch_env_value(argument),
+ );
+ }
+ command.raw_arg("/D");
+ command.raw_arg("/S");
+ command.raw_arg("/V:OFF");
+ command.raw_arg("/C");
+ command.raw_arg(windows_batch_command_line(spec.arguments.len()));
+ return command;
+ }
+
+ let mut command = Command::new(&spec.executable);
+ command.args(&spec.arguments).envs(&spec.environment);
+ command
+}
+
+#[cfg(target_os = "windows")]
+fn is_windows_batch_script(executable: &Path) -> bool {
+ matches!(
+ executable.extension().and_then(|extension| extension.to_str()),
+ Some(extension) if extension.eq_ignore_ascii_case("bat") || extension.eq_ignore_ascii_case("cmd")
+ )
+}
+
+#[cfg(target_os = "windows")]
+const WINDOWS_BATCH_EXECUTABLE_ENV: &str = "LITHE_LSP_BATCH_EXECUTABLE";
+
+#[cfg(target_os = "windows")]
+fn windows_batch_argument_env(index: usize) -> String {
+ format!("LITHE_LSP_BATCH_ARGUMENT_{index}")
+}
+
+#[cfg(target_os = "windows")]
+fn windows_batch_command_line(argument_count: usize) -> String {
+ let mut command_line = format!("\"%{WINDOWS_BATCH_EXECUTABLE_ENV}%\"");
+ for index in 0..argument_count {
+ command_line.push_str(&format!(" \"%{}%\"", windows_batch_argument_env(index)));
+ }
+ format!("\"{command_line}\"")
+}
+
+#[cfg(target_os = "windows")]
+fn windows_batch_env_value(value: &str) -> String {
+ let mut escaped = String::new();
+ let mut backslashes = 0;
+ for character in value.chars() {
+ match character {
+ '\\' => backslashes += 1,
+ '"' => {
+ escaped.push_str(&"\\".repeat(backslashes * 2 + 1));
+ escaped.push('"');
+ backslashes = 0;
+ }
+ _ => {
+ escaped.push_str(&"\\".repeat(backslashes));
+ escaped.push(character);
+ backslashes = 0;
+ }
+ }
+ }
+ escaped.push_str(&"\\".repeat(backslashes * 2));
+ escaped
+}
+
+fn apply_language_server_creation_flags(command: &mut Command) {
+ #[cfg(target_os = "windows")]
+ command.creation_flags(language_server_process_creation_flags());
+
+ #[cfg(not(target_os = "windows"))]
+ let _ = command;
+}
+
+#[cfg(target_os = "windows")]
+fn language_server_process_creation_flags() -> u32 {
+ const CREATE_NO_WINDOW: u32 = 0x0800_0000;
+ CREATE_NO_WINDOW
+}
+
struct SystemProcess {
input: Mutex