diff --git a/.agents/notes/implemented/architecture/2026-09-13-resizable-ui-performance-boundaries.md b/.agents/notes/implemented/architecture/2026-09-13-resizable-ui-performance-boundaries.md index 3a8b796ff..fed9b5542 100644 --- a/.agents/notes/implemented/architecture/2026-09-13-resizable-ui-performance-boundaries.md +++ b/.agents/notes/implemented/architecture/2026-09-13-resizable-ui-performance-boundaries.md @@ -8,7 +8,7 @@ 的布局组件,不要在业务页面里重新拼一套拖动逻辑。拖动过程中要保证界面 稳定、面板不会缩成不可用的尺寸,也不能因为鼠标移动就重建整个页面。 macOS 和 Windows 可以用不同的 UI 技术实现,但用户看到的交互行为和基本 -约束必须一致。 +约束必须一致。Windows 分隔条使用无高亮的间隙和调整光标,macOS 保留原生风格高亮;这项视觉差异不改变尺寸与性能约束。 ## 问题 @@ -32,8 +32,13 @@ macOS 和 Windows 可以用不同的 UI 技术实现,但用户看到的交互 中,避免每次拖动都重新计算整个功能页面。 - 为面板设置最小尺寸、最大尺寸和可用空间约束。窗口缩放、面板隐藏和 面板恢复后,都不能产生负尺寸或布局溢出。 -- 交互应与现有 Git、编辑器和工具窗口保持一致,包括悬停/拖动高亮、 - 平台习惯的调整光标、帮助文本和无障碍标签。 +- 同一平台的分隔条交互应保持一致。Windows 工作台、文件导航和 Git Log 列边缘使用 + 无高亮间隙以及 `ew-resize` / `ns-resize` 光标,避免连续面板边界出现额外蓝色线条; + macOS 的 `SplitHandleView` 保留悬停/拖动高亮。两端仍须保留帮助文本、无障碍标签、 + 尺寸限制和拖动结束后持久化。不要为了视觉统一删除这些交互约束。 +- Windows Git Log 列宽复用 `utils/document-resize-session.ts`,每帧只写局部 CSS 变量, + 结束或卸载时提交一次宽度并释放监听。列的最小和最大宽度限制后,空间不足通过 + 表格横向滚动处理,不挤掉提交说明。 - 如果尺寸需要在重启后保留,只在拖动结束时通过现有布局持久化机制保存 最终尺寸,不要在拖动过程中持续写入持久化存储。 - Windows 编辑器标签栏的滚轮处理和活动标签可见性判断必须使用内层可滚动 @@ -78,6 +83,11 @@ macOS 和 Windows 可以用不同的 UI 技术实现,但用户看到的交互 外框还包含固定操作按钮,其宽度不等于标签列表可视宽度。读取外框的滚动 范围会漏掉内层溢出;因此保留外框用于拖动命中判断,滚动使用独立的内层引用。 +### 强制两端使用同一种分隔条高亮 + +不采用。两端的窗口呈现方式不同,Windows 无描边面板依靠间隙和调整光标识别边界, +macOS 继续使用既有原生风格。视觉差异仅限这些分隔条,不放宽生命周期、性能或可访问性约束。 + ## 后果 - 收益:拖动行为更稳定,面板不会轻易出现负尺寸或溢出,页面重建范围更小。 @@ -94,7 +104,9 @@ macOS 和 Windows 可以用不同的 UI 技术实现,但用户看到的交互 - Windows 标签滚轮行为测试: `cd windows/tauri && bun test src/features/tabs/hooks/use-tab-wheel-scroll.test.tsx` Windows CI 显式运行整个 `src/features/tabs` 测试目录,并生成逐项计时报告。 -- 代码审查时确认拖动不会导致高频整页重绘 +- Git Log 列宽、日期和布局测试由 Windows CI 的 Git Log 验证步骤运行并输出逐项计时报告。 +- 代码审查时确认拖动不会导致高频整页重绘;Windows 人工验证间隙无高亮、光标正确, + macOS 人工确认 `SplitHandleView` 高亮保持不变。 ## 适用范围 diff --git a/.agents/notes/implemented/architecture/2026-09-15-macos-monaco-feasibility-probe.md b/.agents/notes/implemented/architecture/2026-09-15-macos-monaco-feasibility-probe.md index 7ba3c5ee0..b8621be71 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-macos-monaco-feasibility-probe.md +++ b/.agents/notes/implemented/architecture/2026-09-15-macos-monaco-feasibility-probe.md @@ -374,6 +374,17 @@ Monaco 视图。语法 token 仍使用统一的 `color-mappings.json` 与共享 Monaco 会给未指定背景的概览标尺填入默认分词背景色,在壁纸模式下形成黑色竖条; 显式传入透明色后,概览标尺与编辑区一起露出工作台图片。 +中文输入法的组合输入(候选文字尚未确认的阶段)有一个例外:Monaco 会把 +当前行的一部分放入临时文本输入框,覆盖原来的代码行。Monaco 0.55.1 默认把 +当前行高亮色直接用作输入框底色,而原生配色的高亮只有 3.5% 不透明度,下面 +的字形就会透出并形成重影。macOS 的 `ime-input.css` 仅在组合输入期间把高亮、 +编辑器底色和不透明的主题浮层底色叠合,保证临时输入框遮住原文字。背景图模式 +也只在这个临时矩形内使用实体底色;结束组合输入后恢复原有透明输入框。 +不要通过隐藏输入框文字、改写候选输入事件或强制定时重绘来遮掩问题,否则会 +影响输入法反馈或输入状态。真实 Monaco 的组合事件测试验证明暗主题、有无壁纸 +及输入框退出后的样式恢复;原生输入法候选、删除和 WebKit 实际绘制仍需 macOS +实机验证。 + ### 只读差异视图 维护者验收后不接受 macOS 的 Monaco 默认 diff 外观,因此 macOS 历史记录、分支比较、 @@ -412,6 +423,46 @@ working-tree review 的显式身份,不回退到可能已经切换的工作区 错误通过现有本地化提示呈现。纯逻辑回归覆盖稀疏分块、两种操作及这些生命周期边界, 并纳入 Windows Git 随机顺序 CI;真实 WebView2 的按钮与 Git 状态联动仍需宿主验收。 +Windows 单文件工作区 Diff 还要能展开省略的未改动代码(#557)。稀疏补丁里根本 +没有这些行,所以“展开”无从谈起;之前强行关闭折叠,结果连展开按钮都不显示。 +现在单文件打开和刷新时请求整份文件作为上下文(`contextLines` 取 Git 接受的 +最大值 2147483647),结果标记为完整上下文(`is_full_context`),只有这种补丁 +才开启 Monaco 的未改动区域折叠。多文件、历史和提交比较仍用稀疏补丁且不折叠, +因为它们缺少的行同样无法展示。 + +Windows 工作区差异还能按块回滚(#688),与 macOS 的“Discard this change block” +对齐。回滚按钮和暂存按钮放在同一个分块操作区,由共享渲染器的多 action 机制 +渲染,没有改动共享渲染器;分隔带上的箭头需要在两侧编辑器之间额外绘制并跟随 +Monaco 的对齐与折叠,收益不足以承担 macOS 对照入口的回归风险,暂不复刻。 +按钮回传的仍是原始分块身份,Windows 适配器取出同一份补丁,通过 +`git_discard_hunk` 翻译为 `git.apply` 的 `discard` 模式(`git apply --reverse`, +只写工作区)。回滚只在满足以下条件时出现:未暂存的单文件 review、有工作区刷新 +目标、文件不是新增/删除/重命名/二进制,且 Git 状态里该文件没有已暂存改动。 +未暂存单文件 review 用的是 HEAD 到工作区的快照,若文件同时有暂存改动, +补丁会包含暂存块,反向应用到工作区会失败或误删,所以这时只保留暂存按钮; +刷新时按最新状态重新判断。多文件 review 没有刷新目标,回滚后旧补丁会留在 +屏幕上,因此也不提供。点击后先走现有“丢弃前确认”设置(`confirmBeforeDiscard`) +的确认框;确认框打开期间同一份补丁拒绝其他操作,补丁在等待期间被刷新替换时, +用户的回答直接作废。成功后照常等待 Git 变更事件刷新,最后一块回滚、文件 +不再出现在状态中时 review 关闭。Core 用真实仓库验证重建补丁(含完整上下文 +切出的分块)只恢复所选块、重复点击的旧补丁不写入、暂存区保持不变。 + +这是受限的首版能力,不能视为所有工作区 Diff 都支持块级回滚。现有前端补丁解析 +会丢失行尾 `\r` 和文件末尾无换行标记:`core.autocrlf=false` 的 CRLF 文件, +以及涉及末尾无换行的修改块,重建补丁可能被 Git 拒绝。拒绝时文件不变,用户只能 +退回文件级回滚;后续完善应保留原始补丁信息并覆盖暂存和回滚两条路径,不能通过 +忽略补丁错误或直接按显示行号改写文件来绕过校验。 + +整份文件只有一个 `@@`,直接拿来暂存会把整个文件一次写进索引。因此 Windows +适配器按 Git 默认的 3 行上下文重新切出分块,自己生成 `@@` 范围;单测用同一次 +修改的真实 `git diff` 输出逐块比对。完整上下文没有 `@@` 行,渲染器改用行上的 +`actionAnchor` 标记放置按钮,并按每一侧实际显示的行数计算位置,左右两侧保持对齐。 + +Monaco 会把旧的折叠状态带到新内容上:挂载时的空模型没有未改动区域,第一份 +真实文本的所有区域都会被当作“已展开”。渲染器在文本被替换时重新挂接模型, +让折叠状态从头计算;文本相同的刷新不重挂,读者已展开的区域保留。 +真实 WebKit 回归覆盖首次折叠、单个按钮和搜索揭示,去掉重挂会失败。 + 预览借用主编辑器时还保存查找栏的查询、选项和显隐状态。关闭预览必须恢复 主编辑器原先的查找状态,不能把替换预览的查询栏留在原文件中。 diff --git a/.agents/notes/implemented/architecture/2026-09-21-maven-settings-reach-the-language-server.md b/.agents/notes/implemented/architecture/2026-09-21-maven-settings-reach-the-language-server.md index 98fb78d09..58d4c2c4c 100644 --- a/.agents/notes/implemented/architecture/2026-09-21-maven-settings-reach-the-language-server.md +++ b/.agents/notes/implemented/architecture/2026-09-21-maven-settings-reach-the-language-server.md @@ -233,8 +233,41 @@ Maven、版本、路径和来源,例如「自动 → JDK 21.0.4 · 路径 · 版本比较。运行面板保留一步进入「设置 → 运行配置」并定位当前服务的入口;两端设置窗口 每次分类请求都会重新定位,重复请求同一分类同样生效。 -「自动」本身的选择规则(是否按项目要求版本挑选、Windows 候选排序)属于 #815,不在此处 -改变。 +### 自动 JDK 选择(#815) + +自动模式先满足项目已有的 Java 最低版本要求,再按来源选择。显式选择的项目 JDK、 +服务 JDK 或 Maven JDK 不会被自动替换。两端共用 Core `runConfig.selectJava`,版本 +比较复用现有需求诊断中的逻辑,兼容 Java 8 的 `1.8` 表达;不要在 Swift/TypeScript +各写一套版本比较,也不要重新解析 POM 或建立另一个项目模型。JDT LS 仍拥有语言 +服务项目状态,本次只消费既有生成文档中的最低版本,选择机器上的启动工具。 + +来源顺序为 JAVA_HOME、PATH、普通安装、Windows 项目内安装。来源相同时先按数值 +版本降序,再按候选标识排序,避免目录枚举顺序左右结果。macOS 没有版本要求时 +保留原有 JAVA_HOME/首个发现候选回退;Windows 无要求时仍按来源选取,但修正原本 +覆盖 JAVA_HOME 优先级的字符串排序。没有任何兼容版本时继续保留回退选择,同时在 +生效值旁显示安装兼容 JDK 的提示。损坏或未来版本的需求文件明确报错,不按无要求处理。 + +例如项目要求 Java 17、JAVA_HOME 为 8、PATH 为 17、普通目录有 21 时,自动选择 +PATH 的 17;显式选择 8 仍保留 8,由既有启动诊断报告不兼容。不要仅让设置页显示 +17,而启动仍取候选列表第一个 8。Windows 的发现结果也把实际自动候选放在首位, +保证 Run 诊断与宿主启动一致。Windows 设置与服务表单在项目发现结果更新后重新解析 +生效值,即使自动路径字段仍为空;旧请求不能覆盖刷新后的结果。macOS 的 Run、Maven、 +测试和设置经过同一个选择入口。 + +考虑过只改 Windows 排序,但这不能解决两端忽略项目要求的问题。也没有直接选最高 +版本:兼容的 JAVA_HOME/PATH 是用户已有环境意图,应优先保留。选择策略只读取现有 +工作区需求文档,不生成文件、探测进程或写入安装包。macOS 启动复用当前项目已经 +探测的候选;尚未完成完整发现时只探测 Java 并保留到项目切换或显式刷新,不能为了 +选择 Java 连带探测 Maven。无版本要求且 JAVA_HOME 可用时保留不探测的快速路径。 +PATH 仅用于识别已有候选的来源(平台负责解析符号链接),不额外扩大默认候选集合;代价是需求变化后需要重新解析 +选择,不能把上一次项目的结果缓存成全局默认值。 + +回归验证使用共享 `automatic-java-selection.json`,覆盖枚举反序、数值补丁版本、 +旧版 Java 格式、兼容候选筛选、无要求/无匹配回退、空候选及损坏文档。macOS 的 +`automaticJavaUsesProjectMinimumAndPreservesExplicitSelections` 验证设置选择、Maven +继承、显式覆盖与项目切换;Windows 的 +`automatic_java_selection_reads_requirement_changes_without_probing` 验证同一工作区 +需求变化和显式路径旁路。原生实机验收仍需覆盖 Run、Maven 目标和测试运行。 ### Maven 设置页的检测边界(#844) diff --git a/.agents/notes/implemented/bug-fix/2026-09-20-oversized-java-launch-command.md b/.agents/notes/implemented/bug-fix/2026-09-20-oversized-java-launch-command.md index a51982121..996a213ec 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-20-oversized-java-launch-command.md +++ b/.agents/notes/implemented/bug-fix/2026-09-20-oversized-java-launch-command.md @@ -11,6 +11,8 @@ Java 项目现在由项目 JDK 直接启动,JDT 解析出的运行时 classpat `execution.planLaunchCommand` JSON 命令自动采用该策略,不需要 Windows 专属配置。Windows 集成终端对 `cmd.exe` 和 PowerShell 的超长多行输入改用临时脚本调用,脚本执行后自删除,终端关闭时也会清理未执行文件;这不修改系统 Shell 配置。同时启动失败的真实原因必须能显示出来,不能再被兜底文案吞掉。 +JDK 8 不认识 argfile,Windows 普通 Run 改用 classpath JAR(只含 `META-INF/MANIFEST.MF` +的临时 JAR,靠 manifest 的 `Class-Path` 列出全部条目);Core 生成 manifest,宿主写入并沿用同一套临时文件生命周期(#955)。 ## 问题 @@ -49,13 +51,29 @@ Shell 配置。同时启动失败的真实原因必须能显示出来,不能 不能因为 JDK 18+ 就写 UTF-8。Windows 宿主按实际 ANSI 代码页转换 Core 返回的 Unicode 文本,并回转校验,不能表示的字符报错,不允许替换成问号。 UTF-8 系统代码页才使用 UTF-8 字节;普通中文系统代码页使用对应的中文编码。 -5. **失败必须可见。** 宿主的失败消息带上系统错误、可执行文件和命令长度; +5. **JDK 8 用 classpath JAR,规则同样在 Core。** 已确认 JDK 版本低于 9 时,Windows 宿主调用 + `lithe_core::execution::plan_classpath_jar_launch`。它使用同一长度预算,只替换启动器 + 最终生效的那个 `-cp`/`-classpath` 值(多次出现时最后一个生效),值换成临时 JAR 路径; + JVM 选项、主类和程序参数不动。manifest 规则: + - 每个条目先按工作目录转成绝对路径,因为 manifest 里的相对 URL 以 JAR 所在目录为基准, + 而命令行里的相对路径以工作目录为基准;空条目按启动器语义视为工作目录。 + - 绝对路径转成 `file:` URL:盘符写成 `file:/C:/...`,UNC 写成 `file:////host/share/...`; + 非 `[A-Za-z0-9-._~/:]` 的字节一律按 UTF-8 百分号编码(空格、中文、`#`、`%`、`+` 都包括在内)。 + 所以 manifest 是纯 ASCII,和 Windows 系统代码页无关,比 argfile 更不容易因中文路径失败。 + - 目录条目必须以 `/` 结尾,否则类加载器把它当成 JAR 打开。目录判断是文件系统事实, + 由宿主通过回调回答,Core 不读磁盘。 + - 每行最多 72 字节,续行以一个空格开头,换行用 CRLF,最后用空行结束主段。 + - 不能等价表达的情况保持原命令:`lib/*` 通配符(只在命令行展开)、`C:lib` 这类依赖 + 每盘当前目录的路径、`-jar` 启动(会忽略 `-cp`)、版本未知。 + JAR 只含一个不压缩的 manifest 条目,写在 `temp_dir()/lithe-run/launch--.classpath.jar`, + 和 argfile 共用 `create_new` 独占创建与 RAII 清理,绝不写进 JDK 或安装目录。 +6. **失败必须可见。** 宿主的失败消息带上系统错误、可执行文件和命令长度; 前端把非 `Error` 的拒绝值也转成可读文本,并通过 `frontendTrace` 写日志。 -6. **macOS 复用同一份规划。** macOS 组合根把 `RustCoreBridge` 注入 +7. **macOS 复用同一份规划。** macOS 组合根把 `RustCoreBridge` 注入 `LitheExecutionModule.RunService` 的 `JavaLaunchArgumentPreparing` 端口。适配器读取 JDK `release` 版本、调用 `execution.planLaunchCommand`、以 UTF-8 写入自己的临时文件,并把一个 lease 保留到 Java 进程退出;RunService 不直接操作文件系统。 -7. **终端只在 IDE 输入边界做短调用转换。** Windows `cmd.exe` 和 PowerShell 的单行输入有独立长度上限。 +8. **终端只在 IDE 输入边界做短调用转换。** Windows `cmd.exe` 和 PowerShell 的单行输入有独立长度上限。 终端连接收到超过 7000 个 UTF-16 单元且包含换行的文本时,在系统临时目录使用 `create_new` 创建 `.cmd` 或 `.ps1`, 把原文本写入脚本,再向 PTY 写入短的 `call "路径"` 或 `& '路径'`。短文本、仍处于编辑状态的超长单行文本、二进制输入、 WSL 和 Git Bash 不做转换,避免改变交互式 Shell 的解析语义。脚本自身负责正常执行后的删除,连接关闭或写入失败时由连接对象尽力删除剩余文件。 @@ -75,9 +93,13 @@ Shell 配置。同时启动失败的真实原因必须能显示出来,不能 - **改用 `CLASSPATH` 环境变量**:被否。Windows 对单个环境变量和整个环境块同样有 32767 的限制,只是把同一个上限换了个地方。 -- **生成 pathing jar**(用 manifest 的 `Class-Path` 间接引用):暂不采用。它能兼容 - JDK 8,但要处理相对 URL 编码和路径空格,复杂度明显高于 argfile。等真的出现 - JDK 8 大工程需求再做。 +- **所有 JDK 都用 pathing jar**(classpath JAR):被否。#955 出现 JDK 8 需求后,classpath JAR + 只用于 JDK 8。JDK 9+ 继续用 argfile,因为 argfile 还能搬 `--module-path`, + 也不会改变 `java.class.path` 系统属性(classpath JAR 下该属性只剩 JAR 本身, + 依赖它扫描类路径的少数框架行为会变)。 +- **JDK 8 用 `CLASSPATH` 环境变量**:被否,理由同上,环境块同样受 32767 限制。 +- **manifest 写相对 URL**:被否。临时 JAR 和项目通常不在同一盘符,相对 URL 无法表达; + 绝对 `file:` URL 对所有盘符和 UNC 路径都成立。 - **退回 Maven 启动**:被否。那会把 `.agents/notes/implemented/architecture/2026-09-18-java-project-build-and-launch-boundary.md` 里已经解决的 `ClassNotFoundException` 重新带回来。 @@ -85,6 +107,16 @@ Shell 配置。同时启动失败的真实原因必须能显示出来,不能 - **修改 PowerShell profile、注册表或全局环境变量**:被否。该问题只属于 Lithe 生成的输入,系统级修改会影响 IDE 之外的程序, 也不能可靠消除不同 Shell 的长度和解析差异。 +Windows 的目录探测、JDK release 读取、临时文件写入和进程创建在后台阻塞任务中执行, +不能让慢速 UNC 路径冻结 UI。每次启动先登记独立的准备请求,停止会取消该请求, +重新运行会替换它;准备完成后必须在与停止共用的锁内检查归属,再发布进程。 +例如 A 准备期间启动 B,即使 A 最后才完成,也必须释放 A 的临时文件而不启动它。 +不能只把同步命令改成后台执行而省略归属检查,否则停止后旧进程可能重新出现。 + +临时 argfile 与 classpath JAR 在 `scripts/worktree-resources.json` 的 +`java-launch-temporaries` 中登记为不可复用资源,复用脚本直接拒绝。它们属于单次 +执行,没有构建身份 stamp,任何阶段都不能跨工作树复制,也不写发行目录。 + ## 后果 - 大型多模块 Maven 项目在 Windows 上可以启动,命令行只剩下一个 `@文件` 引用。 @@ -94,12 +126,18 @@ Shell 配置。同时启动失败的真实原因必须能显示出来,不能 - 每次执行用 `create_new` 独占创建一个参数文件,不按窗口或会话名复用。文件由 RAII 所有者管理(离开作用域时自动清理):写入或启动失败时删除,成功后交给 该进程的退出线程删除;旧执行的清理不能影响替代它的新执行。 - JVM 已读取参数后,外部清理文件不影响该进程;后续执行会创建新文件。 -- `@argfile` 需要 JDK 9 以上。JDK 8 上的超长 classpath 仍然无解,但那种情况 - 今天本来就会失败,所以不构成回退。 + `@argfile` 在 JVM 启动时读取;classpath JAR 可能在后续类加载时仍被访问, + 因此两者统一保留到进程退出,后续执行创建新文件。 +- `@argfile` 需要 JDK 9 以上。JDK 8 的普通 Run 现在由 classpath JAR 覆盖。代价: + JDK 8 缩短后 `System.getProperty("java.class.path")` 只返回临时 JAR 路径; + 只依赖类加载器的代码不受影响。JDK 8 的 `--module-path` 不存在,所以不需要处理。 + macOS 的 `ARG_MAX` 远大于 Windows 上限,JDK 8 直接启动不会超限,因此 macOS 不调用 + 这条规划,也没有 JSON 命令。 +- JDK 8 缩短不覆盖:Java 测试调试(Java Debug Server 自己拉起 JVM)、Maven 目标、 + 集成终端里手动执行的命令。它们都不经过 `run_start_process`。 - 参数文件使用 Windows 实际系统代码页,JDK 9-17 的中文路径也可在能够无损表示 - 它们的系统代码页下使用。不支持该字符的系统代码页仍会明确失败;pathing jar - 可以作为后续兼容方案。不能把更改 `file.encoding` 当作启动器编码的修复。 + 它们的系统代码页下使用。不支持该字符的系统代码页仍会明确失败;JDK 9+ 是否也改用 + classpath JAR 规避代码页问题,需要先权衡 `java.class.path` 语义变化,本次未做。不能把更改 `file.encoding` 当作启动器编码的修复。 - Windows 集成终端只为 `cmd.exe` 和 PowerShell 的超长多行文本创建一次性脚本;WSL、Git Bash 和普通交互输入保留原始写入。 这解决 IDE 生成命令的 Shell 输入上限,不改变系统 Shell 的全局限制。 - Java **测试**的调试仍由 Java Debug Server 自己拉起 JVM,它的 @@ -113,8 +151,12 @@ Shell 配置。同时启动失败的真实原因必须能显示出来,不能 以及可配置上限;`execution.planLaunchCommand` 复用同一规划器。 - macOS Swift:`swift build --target Lithe` 覆盖模块边界和组合根注入;临时文件 lease 在启动失败、停止和正常退出路径释放。 +- Rust Core:`cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib classpath_jar` + 覆盖 JDK 8 触发、JDK 9+/未知版本/短命令保持直接启动、空格中文和保留字符的百分号编码、 + 目录结尾 `/`、相对/UNC/空条目、72 字节折行、只替换生效的 `-cp`,以及通配符和 `-jar` 不改写。 - Windows 宿主:`cargo test --manifest-path windows/tauri/src-tauri/Cargo.toml run::` - 覆盖写入并删除 argfile、普通启动不写文件、失败消息包含系统原因。该 crate 需要 + 覆盖写入并删除 argfile、普通启动不写文件、失败消息包含系统原因;JDK 8 生成只含 manifest 的 + 临时 JAR、目录条目带 `/`、所有者释放后删除,JDK 9+ 仍走 argfile,版本未知不写文件。该 crate 需要 Windows 或具备 GTK 依赖的环境才能编译。 - Windows 终端 crate:`cargo test --manifest-path windows/tauri/crates/terminal/Cargo.toml` 覆盖短输入和单行输入不转换、 `cmd.exe`/PowerShell 脚本内容与自删除命令;`cargo check --target x86_64-pc-windows-msvc` 检查 Windows 条件编译。 @@ -123,7 +165,8 @@ Shell 配置。同时启动失败的真实原因必须能显示出来,不能 ## 适用范围 -- Rust Core:`rust/lithe-core/src/execution/launch_command.rs` +- Rust Core:`rust/lithe-core/src/execution/launch_command.rs`、 + `rust/lithe-core/src/execution/classpath_jar.rs` - Windows:`windows/tauri/src-tauri/src/run.rs`、 `windows/tauri/src-tauri/src/run/launch_arguments.rs`、 `windows/tauri/src/features/run/stores/run.store.ts`、 diff --git a/.agents/notes/implemented/process/2026-10-01-issue-form-enforcement.md b/.agents/notes/implemented/process/2026-10-01-issue-form-enforcement.md new file mode 100644 index 000000000..864c46c4d --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-01-issue-form-enforcement.md @@ -0,0 +1,109 @@ +# Agent 笔记:非维护者必须通过 Issue 表单提交 + +状态:已实现 + +## 先说结论 + +`blank_issues_enabled: false` 只隐藏网页上的空白 Issue 入口,挡不住 API、`gh issue create` 和第三方客户端。现在每个 Issue 表单都带一个 `issue-form:*` 标签,`.github/workflows/lithe-issue-form-gate.yml` 在 Issue 创建或重新打开时检查:对仓库有 write、maintain 或 admin 权限的作者直接放行;其他作者没有这类标签就收到中英双语说明,Issue 被关闭。任何拿不准的情况都保留 Issue,宁可漏关,不误关。 + +## 问题 + +#988 是一个正文为空、没有经过表单的 Issue。只检查正文是否为空不够:通过 API 随便写一句话,同样绕过了表单,表单里的平台、模块、优先级等字段都缺失,`lithe-issue-priority.yml` 也无法自动分类。 + +需要一个普通用户无法伪造、只有表单才会产生的信号。 + +## 决策 + +### 用表单标签作为来源证明 + +Issue 表单(Issue Form,`.github/ISSUE_TEMPLATE/*.yml` 定义的结构化模板)顶层的 `labels:` 会在提交时自动加到 Issue 上,与提交者权限无关。REST API 创建 Issue 时,没有 push 权限的用户传入的 labels 会被 GitHub 静默丢弃;网页 URL 里的 `labels=` 参数同样要求打标签权限。所以普通用户只有通过表单才能得到 `issue-form:*` 标签。 + +允许的标签和对应模板集中在 `.github/lithe-issue-form/logic.mjs` 的 `ISSUE_FORMS`。新增表单时,在表单的 `labels:` 加一个 `issue-form:<类型>`,同时把它加入 `ISSUE_FORMS`,并在仓库里创建同名标签。不要只改其中一处;测试会检查每个登记的模板都声明了自己的标签。 + +### 判定顺序 + +`decideIssueFormGate` 返回 `allow`、`close` 或 `skip`,工作流只在 `close` 时评论并关闭: + +1. 不是开放状态的 Issue、Pull Request、缺少作者信息:`skip`。 +2. 作者是 Bot:`allow`。只有维护者安装的 App 或工作流才能以 Bot 身份建 Issue。 +3. 触发者不是作者(例如维护者转移或重新打开 Issue):`skip`。 +4. 读取作者权限失败:`skip`。 +5. 作者权限是 write、maintain、admin(`role_name` 或旧 `permission` 字段任一命中,后者覆盖自定义角色):`allow`。 +6. Issue 带任一允许的 `issue-form:*` 标签:`allow`。 +7. 仓库里缺少任一允许的标签,或任一标签已归档:`skip`。表单无法应用不存在或已归档的标签,此时无法区分“来自表单”和“绕过表单”。 +8. 其余情况:`close`,`state_reason` 为 `not_planned`。 + +权限以 `GET /repos/{owner}/{repo}/collaborators/{username}/permission` 为准,不以 `author_association` 为准:`COLLABORATOR` 可能只有 read 或 triage,`MEMBER` 也不代表对本仓库有写权限。工作流级 `if` 只跳过 `OWNER`、Bot 和 Pull Request,省掉一次 runner 启动,其余作者都由脚本查询真实权限。 + +### 标签时序 + +表单标签不一定和 Issue 同一时刻写入:#987 的 `bug` 标签比创建时间晚约 1 秒,事件记录的操作者是作者本人。所以工作流合并事件里的标签和重新读取的标签,并在真正关闭前再读一次 Issue、重新判定;只要此时出现表单标签,或 Issue 已被关闭,就不再处理。 + +### 评论与幂等 + +关闭前的评论带隐藏标记 ``,重跑工作流时如果已经有机器人发的同标记评论就不再重复评论。评论只引用作者 login 和固定的模板链接,不回显 Issue 标题或正文,避免把用户输入带进机器人内容。脚本通过 `actions/github-script` 的 API 客户端读取事件数据,不把标题或正文插入 shell 或 `${{ }}` 表达式,因此不存在脚本注入面。 + +### 和现有 Issue 自动化的关系 + +- `lithe-issue-priority.yml` 在 `opened` 和 `edited` 时只增删自己管理的 `bug`、`enhancement`、`P*`、`platform:*`、`area:*`,`issue-form:*` 会被保留。它和本工作流并行运行;禁止用旧快照执行 `setLabels` 整体替换,否则会抹掉读取后 GitHub 才补上的来源标签。使用 `addLabels` 和 `removeLabel`,让非托管标签始终保留。 +- `lithe-issue-claim.yml` 只处理评论事件,被关闭的 Issue 仍可由维护者重新打开后认领。 +- 维护者手动创建的 Issue 不需要 `issue-form:*` 标签。 + +### 正确做法 + +- 新增表单:模板 `labels:` 加 `issue-form:<类型>`,`ISSUE_FORMS` 登记,先在仓库创建标签再合并。 +- 误关时:由维护者重新打开 Issue,触发者与作者不同会跳过门禁。作者自己重新打开会再次检查,不能通过“立即关闭 → 等待门禁跳过 → 重新打开”绕过。 + +### 不要这样做 + +- 不要改成只检查正文是否为空,API 写一句话就能绕过。 +- 不要用 `author_association` 判断维护者,它不等于仓库写权限。 +- 不要在 `run:` 步骤里用 `${{ github.event.issue.title }}` 或正文拼 shell 命令。 +- 不要在缺少或归档标签时“先关再说”,那会关闭所有正常的表单 Issue。 + +## 考虑过的备选方案 + +### 只拦截空正文 + +实现最简单,也能处理 #988。但 API 或 CLI 写任意一句话即可绕过,而这类 Issue 同样缺少表单字段,所以没有采用。 + +### 按正文里的表单标题结构识别 + +表单正文有固定的 `### 字段名` 结构,不需要额外标签。但普通用户可以照抄这种 Markdown 结构伪造,字段文案调整时还要同步修改判定,所以没有采用。 + +### 仓库级只允许协作者创建 Issue + +服务端彻底限制,但普通用户将完全无法反馈问题,违背开放反馈的目标,所以没有采用。 + +### 缺少标签时由工作流自动创建 + +能让门禁“自己修好”,但工作流会因此需要隐式改仓库配置,也会在标签被维护者有意删除后悄悄恢复,所以改为缺少时放行并在日志里警告。 + +## 后果 + +- 收益:非维护者无论从哪个入口提交,都会留下完整表单字段,自动分类可以生效;被关闭的作者能看到双语说明和正确入口。 +- 代价:每个非 OWNER 作者的新 Issue 都会启动一次短时 runner(作业超时 5 分钟)。普通用户从其他仓库转移进来的 Issue、维护者手动删掉 `issue-form:*` 标签的 Issue 不受门禁约束。 +- 代价:如果 GitHub 改变“非 push 用户的 labels 静默丢弃”的行为,或允许 read 角色通过 URL 参数打标签,这个信号就能被伪造,需要重新评估。 +- 重新评估触发条件:GitHub 提供原生的“必须使用表单”设置;新增表单或改名模板文件;评论或关闭策略改变。 + +## 验证 + +```bash +node --test --test-timeout=10000 scripts/test-lithe-issue-*.mjs +actionlint .github/workflows/lithe-issue-form-gate.yml .github/workflows/lithe-issue-priority.yml .github/workflows/verify-issue-automation.yml +./scripts/verify-agent-notes.sh +``` + +测试覆盖 Bug、Feature 表单放行,空白 Issue 和带正文但无表单标签的 Issue 被关闭,write、maintain、admin 与自定义角色放行,以及权限未知、标签缺失、转移等不确定情况不关闭。工作流编排测试执行实际的 github-script 脚本,以模拟 API 控制“分类读取 → GitHub 补标签 → 分类写入 → 门禁检查”的顺序,同时覆盖作者重新打开、维护者重新打开、标签归档及权限读取失败。所有测试均不访问网络、不依赖真实延时。`verify-issue-automation.yml` 在相关 PR 和 main/preview 推送时执行这些测试。 + +## 适用范围 + +- `.github/workflows/lithe-issue-form-gate.yml` +- `.github/lithe-issue-form/logic.mjs` +- `.github/ISSUE_TEMPLATE/bug_report.yml` +- `.github/ISSUE_TEMPLATE/feature_request.yml` +- `scripts/test-lithe-issue-form-gate.mjs` + +- `scripts/test-lithe-issue-workflows.mjs` +- `.github/workflows/verify-issue-automation.yml` +- `.github/workflows/lithe-issue-priority.yml` diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index b432d8960..a4c394275 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -3,6 +3,9 @@ description: Report a reproducible problem in Lithe / 报告可复现的问题 title: "[Bug] " labels: - bug + # Marks issues created from this form; .github/workflows/lithe-issue-form-gate.yml + # closes non-maintainer issues without an issue-form:* label. + - issue-form:bug body: - type: markdown attributes: diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 36bebe5ad..eae440a25 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -3,6 +3,9 @@ description: Suggest an improvement or a new capability / 建议改进现有能 title: "[Feature] " labels: - enhancement + # Marks issues created from this form; .github/workflows/lithe-issue-form-gate.yml + # closes non-maintainer issues without an issue-form:* label. + - issue-form:feature body: - type: markdown attributes: diff --git a/.github/lithe-issue-form/logic.mjs b/.github/lithe-issue-form/logic.mjs new file mode 100644 index 000000000..394a32164 --- /dev/null +++ b/.github/lithe-issue-form/logic.mjs @@ -0,0 +1,104 @@ +// Decision logic for .github/workflows/lithe-issue-form-gate.yml. +// Note: 决策记录见 .agents/notes/implemented/process/2026-10-01-issue-form-enforcement.md + +// Each supported Issue Form declares exactly one of these labels in its +// `labels:` list. GitHub applies form labels regardless of the author's +// permission, while labels passed through the REST API or `gh issue create` +// are silently dropped for users without push access, so the label is a +// marker that non-maintainers cannot forge. +export const ISSUE_FORMS = Object.freeze([ + Object.freeze({ label: 'issue-form:bug', template: 'bug_report.yml', title: 'Bug 报告 / Bug report' }), + Object.freeze({ label: 'issue-form:feature', template: 'feature_request.yml', title: '功能建议 / Feature request' }), +]); + +export const ALLOWED_LABELS = Object.freeze(ISSUE_FORMS.map((form) => form.label)); + +// Hidden marker that identifies the gate's own comment, so a re-run does not +// post a duplicate explanation. +export const GATE_MARKER = ''; + +const MAINTAINER_PERMISSIONS = new Set(['admin', 'maintain', 'write']); + +export function labelNames(labels = []) { + return labels + .map((label) => (typeof label === 'string' ? label : label?.name)) + .filter(Boolean); +} + +// `role_name` reports admin/maintain/write/triage/read or a custom role name; +// the legacy `permission` field still carries the base level for custom roles. +export function isMaintainerPermission(permission) { + if (!permission) return false; + return MAINTAINER_PERMISSIONS.has(permission.role_name) || MAINTAINER_PERMISSIONS.has(permission.permission); +} + +export function missingAllowedLabels(existingRepoLabels, allowedLabels = ALLOWED_LABELS) { + const existing = new Set(existingRepoLabels); + return allowedLabels.filter((label) => !existing.has(label)); +} + +// Returns { action, reason } where action is: +// - 'allow': the issue stays open; +// - 'close': comment and close the issue; +// - 'skip': the gate cannot decide safely and leaves the issue untouched. +// Every uncertain input fails open so that a form-created issue is never +// closed because of a configuration gap or a transient API failure. +export function decideIssueFormGate({ + issue, + sender, + authorPermission, + missingLabels = [], + allowedLabels = ALLOWED_LABELS, +}) { + if (!issue) return { action: 'skip', reason: 'missing-issue' }; + if (issue.pull_request) return { action: 'skip', reason: 'pull-request' }; + if (issue.state && issue.state !== 'open') return { action: 'skip', reason: 'not-open' }; + const author = issue.user?.login; + if (!author) return { action: 'skip', reason: 'missing-author' }; + // Bot accounts can only create issues here through an app or workflow that + // a maintainer installed on this repository. + if (issue.user.type === 'Bot') return { action: 'allow', reason: 'bot-author' }; + // Preserve another actor's transfer or reopen decision. Author reopens + // still pass through the gate, so closing and reopening cannot bypass it. + if (sender?.login && sender.login !== author) return { action: 'skip', reason: 'opened-by-another-actor' }; + if (authorPermission === null || authorPermission === undefined) return { action: 'skip', reason: 'permission-unknown' }; + if (isMaintainerPermission(authorPermission)) return { action: 'allow', reason: 'maintainer' }; + const labels = labelNames(issue.labels); + if (labels.some((label) => allowedLabels.includes(label))) return { action: 'allow', reason: 'issue-form' }; + // Issue Forms cannot apply missing or archived labels. Until every + // allowed label exists, an unlabeled issue may still come from a form. + if (missingLabels.length > 0) return { action: 'skip', reason: 'form-labels-missing' }; + return { action: 'close', reason: 'not-from-issue-form' }; +} + +export function hasGateComment(comments = []) { + return comments.some((comment) => + comment.user?.type === 'Bot' && + comment.user?.login === 'github-actions[bot]' && + (comment.body ?? '').includes(GATE_MARKER) + ); +} + +export function issueFormLinks(serverUrl, owner, repo) { + const base = `${serverUrl}/${owner}/${repo}/issues/new`; + return { + chooser: `${base}/choose`, + forms: ISSUE_FORMS.map((form) => ({ title: form.title, url: `${base}?template=${encodeURIComponent(form.template)}` })), + }; +} + +// The author login comes from GitHub's account model (alphanumerics and +// hyphens); no title or body text is echoed back into the comment. +export function gateComment({ login, serverUrl, owner, repo }) { + const links = issueFormLinks(serverUrl, owner, repo); + const formLines = links.forms.map((form) => `- ${form.title}:${form.url}`).join('\n'); + return [ + GATE_MARKER, + `@${login} 感谢反馈。本仓库要求通过 Issue 表单提交 Issue。这个 Issue 没有经过表单创建(例如空白 Issue,或通过 API、CLI、第三方客户端直接创建),因此已自动关闭。请通过下面的入口重新提交:`, + '', + formLines, + `- 全部模板 / All templates:${links.chooser}`, + '', + 'Thanks for the report. This repository only accepts issues created from its issue forms. This issue was not created from a form (for example a blank issue, or one created through the API, CLI, or a third-party client), so it was closed automatically. Please resubmit it using one of the links above.', + ].join('\n'); +} diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index 3f1ec085a..6553e28f5 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -228,6 +228,10 @@ jobs: bun test src/features/file-explorer/lib/java-clipboard-class.test.ts src/features/file-explorer/lib/paste-java-class-from-clipboard.test.ts node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-file-watcher.json -- src/features/file-system/services/file-watcher-listener.test.ts src/features/file-system/stores/file-watcher.store.test.ts + - name: Test Git gutter changes and inline review lifecycle + shell: pwsh + run: node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-git-gutter.json -- src/features/git/utils/git-gutter-changes.test.ts src/features/editor/engines/monaco/git-gutter-peek-session.test.ts + - name: Test editor tab scrolling and lifecycle shell: pwsh run: node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-tabs.json -- src/features/tabs @@ -254,6 +258,10 @@ jobs: shell: pwsh run: node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-git-paths.json -- src/features/git/api/git-repo-api.test.ts src/utils/path-helpers.test.ts + - name: Test Git commit messages and asynchronous diff ownership + shell: pwsh + run: node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-git-commit-messages.json -- src/features/git/api/git-commits-api.test.ts src/features/git/components/log/git-commit-details.test.tsx src/features/git/hooks/use-git-diff-actions.test.tsx + - name: Test Git query recovery and isolation shell: pwsh timeout-minutes: 5 @@ -280,6 +288,15 @@ jobs: node $runner --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-git-execution-journal.json -- src/features/git/services/git-execution-journal.test.ts if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Test Git Log previews, dates, columns, and workbench layout + shell: pwsh + run: | + $runner = ".agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs" + node $runner --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-git-log-preview.json -- src/features/git/hooks/use-commit-file-preview.test.tsx + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + node $runner --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-git-log-layout.json -- src/features/git/hooks/use-git-log-column-resize.test.tsx src/features/git/components/log/git-log-date-cell.test.tsx src/features/git/components/log/git-commit-file-tree.test.ts src/features/git/utils/git-log-columns.test.ts src/features/git/utils/git-log-date.test.ts src/features/git/utils/multi-file-diff.test.ts src/utils/document-resize-session.test.ts src/features/layout/components/plugin-activity-rail.test.ts src/features/layout/components/workbench-panel-frame.test.ts src/features/layout/config/item-order.test.ts src/features/keymaps/defaults/default-keymaps.git-log.test.ts src/features/terminal/stores/terminal.store.test.ts + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Test workspace Git commit adapters and staging shell: pwsh run: | diff --git a/.github/workflows/lithe-issue-form-gate.yml b/.github/workflows/lithe-issue-form-gate.yml new file mode 100644 index 000000000..a00360899 --- /dev/null +++ b/.github/workflows/lithe-issue-form-gate.yml @@ -0,0 +1,94 @@ +name: Lithe issue form gate + +on: + issues: + types: [opened, reopened] + +permissions: + contents: read + issues: write + +concurrency: + group: lithe-issue-form-gate-${{ github.event.issue.number }} + cancel-in-progress: false + +jobs: + gate: + # Skip pull requests, bots, and repository owners; all other authors + # are checked against their actual repository permission. + if: >- + github.event.issue.pull_request == null && + github.event.issue.user.type != 'Bot' && + github.event.issue.author_association != 'OWNER' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 1 + persist-credentials: false + - uses: actions/github-script@v7 + env: + ISSUE_FORM_LOGIC: ${{ github.workspace }}/.github/lithe-issue-form/logic.mjs + with: + github-token: ${{ github.token }} + script: | + const logic = await import(process.env.ISSUE_FORM_LOGIC); + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + const payloadIssue = context.payload.issue; + const login = payloadIssue.user.login; + + // Re-read the issue so a maintainer action that already closed it + // is respected; keep the labels delivered with the opened event too. + const current = (await github.rest.issues.get({ owner, repo, issue_number })).data; + const labels = [...new Set([...logic.labelNames(payloadIssue.labels), ...logic.labelNames(current.labels)])]; + const issue = { ...current, user: current.user ?? payloadIssue.user, labels }; + + // Non-collaborators receive a 200 response with read/none, so any + // error means the permission is unknown and the issue stays open. + let authorPermission = null; + try { + authorPermission = (await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username: login })).data; + } catch (error) { + core.warning(`Unable to read permission for ${login}; leaving the issue open: ${error.message}`); + } + + // Issue Forms drop labels that do not exist, so a missing or + // unreadable allowed label disables closing instead of misfiring. + const existingLabels = []; + for (const name of logic.ALLOWED_LABELS) { + try { + const label = (await github.rest.issues.getLabel({ owner, repo, name })).data; + if (label.archived_at == null) existingLabels.push(name); + } catch (error) { + if (error.status !== 404) core.warning(`Unable to read label ${name}: ${error.message}`); + } + } + const missingLabels = logic.missingAllowedLabels(existingLabels); + if (missingLabels.length) core.warning(`Issue form labels are missing or archived (${missingLabels.join(', ')}); the gate will not close unlabeled issues until they exist.`); + + const decision = logic.decideIssueFormGate({ issue, sender: context.payload.sender, authorPermission, missingLabels }); + core.info(`Issue #${issue_number} by ${login}: ${decision.action} (${decision.reason})`); + if (decision.action !== 'close') return; + + // Form labels can be written a moment after the issue is created, + // and a maintainer may act meanwhile; re-check right before closing. + const latest = (await github.rest.issues.get({ owner, repo, issue_number })).data; + const recheck = logic.decideIssueFormGate({ + issue: { ...latest, user: latest.user ?? payloadIssue.user, labels: [...labels, ...logic.labelNames(latest.labels)] }, + sender: context.payload.sender, + authorPermission, + missingLabels, + }); + if (recheck.action !== 'close') { + core.info(`Issue #${issue_number} recheck: ${recheck.action} (${recheck.reason})`); + return; + } + + const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number, per_page: 100 }); + if (!logic.hasGateComment(comments)) { + const body = logic.gateComment({ login, serverUrl: context.serverUrl, owner, repo }); + await github.rest.issues.createComment({ owner, repo, issue_number, body }); + } + await github.rest.issues.update({ owner, repo, issue_number, state: 'closed', state_reason: 'not_planned' }); diff --git a/.github/workflows/lithe-issue-priority.yml b/.github/workflows/lithe-issue-priority.yml index e3909d4ab..ec7d208ac 100644 --- a/.github/workflows/lithe-issue-priority.yml +++ b/.github/workflows/lithe-issue-priority.yml @@ -71,11 +71,11 @@ jobs: const currentLabels = issue.labels .map((label) => typeof label === 'string' ? label : label.name) .filter(Boolean); - const nextLabels = currentLabels.filter((label) => !managedLabels.includes(label)); - nextLabels.push(...selectedLabels); - const unchanged = currentLabels.length === nextLabels.length && - currentLabels.every((label, index) => label === nextLabels[index]); - if (unchanged) return; + // Only mutate labels owned by this workflow. Replacing the whole + // list can erase form labels applied after the snapshot was read. + const removedLabels = currentLabels.filter((label) => managedLabels.includes(label) && !selectedLabels.includes(label)); + const addedLabels = selectedLabels.filter((label) => !currentLabels.includes(label)); + if (!removedLabels.length && !addedLabels.length) return; for (const name of selectedLabels) { try { await github.rest.issues.getLabel({ owner, repo, name }); @@ -86,4 +86,13 @@ jobs: }); } } - await github.rest.issues.setLabels({ owner, repo, issue_number, labels: nextLabels }); + for (const name of removedLabels) { + try { + await github.rest.issues.removeLabel({ owner, repo, issue_number, name }); + } catch (error) { + if (error.status !== 404) throw error; + } + } + if (addedLabels.length) { + await github.rest.issues.addLabels({ owner, repo, issue_number, labels: addedLabels }); + } diff --git a/.github/workflows/verify-issue-automation.yml b/.github/workflows/verify-issue-automation.yml new file mode 100644 index 000000000..799ddc36b --- /dev/null +++ b/.github/workflows/verify-issue-automation.yml @@ -0,0 +1,35 @@ +name: Verify issue automation + +on: + pull_request: + paths: + - '.github/ISSUE_TEMPLATE/**' + - '.github/lithe-issue-*/**' + - '.github/workflows/lithe-issue-*.yml' + - '.github/workflows/verify-issue-automation.yml' + - 'scripts/test-lithe-issue-*.mjs' + push: + branches: [main, preview] + paths: + - '.github/ISSUE_TEMPLATE/**' + - '.github/lithe-issue-*/**' + - '.github/workflows/lithe-issue-*.yml' + - '.github/workflows/verify-issue-automation.yml' + - 'scripts/test-lithe-issue-*.mjs' + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-node@v6 + with: + node-version: '24' + - name: Test issue decisions and workflow orchestration + run: node --test --test-timeout=10000 scripts/test-lithe-issue-*.mjs diff --git a/docs/assets/contact/wechat-group-qr-2026-09.png b/docs/assets/contact/wechat-group-qr-2026-09.png index 81dc3d270..ff9f71b51 100644 Binary files a/docs/assets/contact/wechat-group-qr-2026-09.png and b/docs/assets/contact/wechat-group-qr-2026-09.png differ diff --git a/docs/ci-builds.md b/docs/ci-builds.md index 3ad394f31..5eec402cb 100644 --- a/docs/ci-builds.md +++ b/docs/ci-builds.md @@ -176,6 +176,14 @@ SHA-256;Cargo、SwiftPM 和 Bun 使用各自的 lockfile、版本与完整性 以下目录不应直接复制或跨工作树共享: +- Java 启动临时文件:`/lithe-run/launch--.argfile` + 和同目录的 `.classpath.jar` 由平台启动 adapter 为单次执行独占创建,包含该次 + 执行的绝对类路径、工作目录、JDK 版本及编码语义,没有可复用的版本、平台、架构 + 或工具链 identity stamp。准备失败、准备完成前已取消、启动失败或进程退出后由 + 所有者删除;系统临时目录由平台解析,不写安装包或 JDK,不影响签名或增量更新。 + `excludedResources.java-launch-temporaries` 经复用脚本的排除路由直接拒绝,任何 + 复制阶段都不得共享;内容哈希相同也不能转移进程所有权。 + - Agent CLI 的用户级安装与下载缓存:npm 的 global prefix/cache、Homebrew 的 Cellar/Caskroom/cache、用户目录下 `.local/share/claude/versions`。它们由运行时 `PATH` 和原安装器决定,不属于工作树;包版本、平台与架构由原安装器校验, diff --git a/docs/development/platform-parity-matrix.csv b/docs/development/platform-parity-matrix.csv index d639d80ab..241d81460 100644 --- a/docs/development/platform-parity-matrix.csv +++ b/docs/development/platform-parity-matrix.csv @@ -18,9 +18,10 @@ workspace-open-switch,工作区,工作区生命周期,打开工作区与切换 workspace-files,工作区,工作区生命周期,文件树与文件操作,已实现,待验证,已实现,待验证,Workspace,新建、移动、重命名、删除文件和目录,并确认相对路径与错误提示一致。,,macos/Sources/Lithe/Views/Workspace,windows/tauri/src/features/file-system workspace-document-sync,工作区,工作区生命周期,脏状态、保存与外部修改,已实现,待验证,已实现,待验证,Workspace,编辑未保存文件、外部修改文件并重启应用,确认冲突、保存和恢复行为。,,macos/Sources/Lithe/Models/Editor; macos/Sources/Lithe/Services/Workspace,windows/tauri/src/features/editor; windows/tauri/src/features/file-system workspace-tabs-projects,工作区,工作区生命周期,多项目与多标签,已实现,待验证,已实现,待验证,Workspace,同时打开多个项目和文件,确认标签、项目上下文和关闭恢复行为。 Windows 重复打开同一本地目录(含大小写、分隔符和目录链接别名)时,恢复并聚焦已有窗口及项目标签;连续打开只产生一个窗口,关闭或打开失败后可以重试。,,macos/Sources/Lithe/Views/Workspace; macos/Sources/Lithe/Views/Editor,windows/tauri/src/features/workspace; windows/tauri/src/features/tabs; windows/tauri/src-tauri/src/project_windows.rs; windows/tauri/src-tauri/src/project_window_registry.rs; windows/tauri/src/features/window/services/project-window-router.ts -editor-text-tabs,编辑器,文本编辑,文本编辑与标签生命周期,已实现,待验证,已实现,待验证,Editor,打开、编辑、保存、关闭和恢复多个文本文件,确认光标、脏状态和标签状态;在 macOS 打开无扩展名文本文件,确认标签与项目树图标一致;从外部改为二进制后折叠并展开项目树,确认两处图标更新一致。,,macos/Sources/Lithe/Views/Editor; macos/Sources/Lithe/Models/Editor,windows/tauri/src/features/editor; windows/tauri/src/features/tabs +editor-text-tabs,编辑器,文本编辑,文本编辑与标签生命周期,已实现,待验证,已实现,待验证,Editor,打开、编辑、保存、关闭和恢复多个文本文件,确认光标、脏状态和标签状态;右键标签页确认关闭分组排在其他操作之前;在 macOS 打开无扩展名文本文件,确认标签与项目树图标一致;从外部改为二进制后折叠并展开项目树,确认两处图标更新一致。,,macos/Sources/Lithe/Views/Editor; macos/Sources/Lithe/Models/Editor,windows/tauri/src/features/editor; windows/tauri/src/features/tabs; windows/tauri/src/features/tabs/components/tab-context-menu.tsx; windows/tauri/src/features/tabs/components/tab-context-menu.test.ts editor-pointer-selection,编辑器,文本编辑,鼠标与触控板点击、选词和拖选,已实现,待验证,已实现,待验证,Editor,macOS 分别启用微信输入法和系统 ABC,快速轻点不同位置后松手移动,确认不扩展选区、不移动文字;验证同位置双击选词、Shift 点击扩选、按住拖选、选中文字拖放、主编辑器和分屏及 diff。运行 scripts/probe-macos-monaco.sh --workbench-tests;Windows 验证同样的正常鼠标交互。,macOS 在宿主修正 WebKit 零按钮按下事件,保留 Monaco 选择语义。Linux Chromium 真实 Monaco 控制事件回放通过,仍待 macOS 微信输入法与 Windows WebView2 实机验证。,macos/EditorFrontend/main.ts; macos/EditorFrontend/mouse-input.ts; macos/Experiments/Monaco/mouse-input.integration.ts,windows/tauri/src/features/editor/components/monaco-editor.tsx editor-language-basics,编辑器,文本编辑,语法高亮与编辑器模型,已实现,待验证,已实现,待验证,Editor,分别打开 Java、Markdown 和普通文本,确认语言识别、语法高亮和模型切换;开启缩略图并滚动,确认滚动条轨道不透出编辑器代码。,,macos/Sources/Lithe/Views/Editor; macos/Sources/Lithe/Models/Editor,windows/tauri/src/features/editor; frontend/editor; windows/tauri/src/features/editor/engines/monaco/theme.ts +editor-macos-ime-presentation,编辑器,文本编辑,macOS 中文组合输入层遮住原文字,输入和删除候选文字时不透出整行重影,已实现,待验证,平台专属,不适用,Editor,运行 scripts/probe-macos-monaco.sh --workbench-tests。在 macOS 使用系统拼音与微信输入法,在含中文和英文的同一行中间输入、删减候选拼音、确认上屏,再按删除键;检查整行无重影、候选与光标正常、撤销恢复文本。覆盖明暗主题、背景图开关和分屏,结束输入后背景恢复。,修正 macOS 原生主题半透明当前行底色作为 Monaco 输入法覆盖层底色时的字形透出。Linux Chromium 的真实 Monaco 组合事件样式回归通过;macOS 原生输入法及删除键实机验证待完成。Windows 不加载此 macOS 样式。,macos/EditorFrontend/ime-input.css; macos/EditorFrontend/main.ts; macos/Experiments/Monaco/ime-input.integration.ts,windows/tauri/src/features/editor/components/monaco-editor.tsx editor-wallpaper-overview-ruler,编辑器,编辑器外观,macOS 工作台壁纸模式下,编辑器概览标尺与代码缩略图随编辑器背景透明,不显示黑色竖条;普通主题仍保持实体背景,已实现,待验证,平台专属,不适用,Editor,在 macOS 选择工作台背景并打开文本文件,分别关闭和打开右侧 Agent 面板,确认编辑器最右侧概览标尺不出现黑色竖条;关闭背景图后确认普通主题底色保持不变。运行真实 WebKit 工作台主题集成测试。Windows 不使用 macOS 原生工作台壁纸。,,frontend/editor/src/theme.ts; macos/Sources/Lithe/Platform/MacOS/MonacoWorkbenchEditor.swift; macos/Experiments/Monaco/workbench.integration.ts,windows/tauri/src/features/editor/engines/monaco/theme.ts editor-semantic-highlighting,编辑器,文本编辑,LSP 语义高亮,已实现,待验证,已实现,待验证,Editor / Language Tooling,打开普通 Java 文件并等待 JDTLS 就绪,开启语义高亮,确认字段、方法等按协商图例上色;编辑、切换、关闭文档和重连服务器后不应用旧结果;服务器 refresh 后重新请求;关闭设置或大文件降级时保留基础语法高亮。,Windows 复用 Core semanticTokens 和共享 fixture,修复 #675;Linux 上的适配器与模型测试不替代 Windows WebView2/JDTLS 实机验收。,macos/Sources/Lithe/Models/AppModel/AppModel+LanguageEditing.swift; macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift; frontend/editor/src/semantic-tokens.ts,windows/tauri/src/platform/lsp-core-adapter.ts; windows/tauri/src/features/editor/lsp/lsp-client.ts; windows/tauri/src/features/editor/engines/monaco/semantic-token-provider.ts; windows/tauri/src/features/editor/engines/monaco/semantic-token-provider.test.ts; windows/tauri/src/platform/lsp-core-adapter.test.ts editor-multiline-tabs,编辑器,文本编辑,多行标签与标签导航,已实现,待验证,已实现,待验证,Editor,打开足够多文件触发多行标签,确认滚动、切换、关闭和活动文件保持。,,macos/Sources/Lithe/Views/Editor,windows/tauri/src/features/tabs @@ -31,8 +32,8 @@ search-replace,搜索,搜索与导航,项目替换与取消,已实现,待验证, git-status-commit,版本控制,Git,状态、暂存与提交,已实现,待验证,已实现,待验证,Git,修改、暂存、取消暂存并提交文件,确认状态、提交消息和错误回显。 Windows 另验证原生 UNC/verbatim 输入、中文/空格/长路径仓库往返及 linked worktree;末尾点/空格必须明确拒绝,外部提交/切换须触发元数据刷新。,,macos/Sources/Lithe/Views/Git; macos/Sources/Lithe/Services,windows/tauri/src/features/git; shared/contracts/application-boundary.md; windows/tauri/src/features/git/api/git-repository-path.ts; rust/lithe-core/tests/git_path_roundtrip.rs; shared/fixtures/git/windows-paths.json git-multi-repository-change-groups,版本控制,Git,多仓库变更折叠分组,已实现,待验证,已实现,待验证,Git,在同一工作区打开多个 Git 仓库,确认变更按仓库折叠分组;只剩一个仓库有变更时仍显示仓库名。仓库级和文件级勾选与 Git 暂存区同步;子模块只有未提交文件时显示提示,不能勾选未变化的引用。,两端直接消费共享 Rust Core 的计划、依赖排序、执行结果与重试。平台负责真实暂存勾选、确认与结果界面、认证取消和项目生命周期。原生界面实测待平台验收。,macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift; macos/Sources/Lithe/Views/Git/GitChangeSectionsCache.swift; macos/Sources/LitheGitModule/Models/GitModels.swift; rust/lithe-core/src/git/mod.rs,windows/tauri/src/features/git/components/status/git-status-panel.tsx; windows/tauri/src/features/git/components/git-commit-panel.tsx; windows/tauri/src/features/git/components/git-workspace-commit-review.tsx; windows/tauri/src/features/git/services/git-workspace-commit-workflow.ts; windows/tauri/src/features/git/services/git-workspace-commit-workflow.test.ts; windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx; windows/tauri/src-tauri/src/platform.rs; shared/fixtures/git/workspace-commit-workflow-v1.json git-branches-remotes,版本控制,Git,分支、标签与远程,已实现,待验证,已实现,待验证,Git,创建、切换、合并分支并查看标签和远程,确认冲突与认证失败可恢复。,,macos/Sources/Lithe/Views/Git,windows/tauri/src/features/git -git-diff-review,版本控制,Git,Diff 与变更审查,已实现,待验证,已实现,待验证,Git,验证新增、删除、重命名、二进制和多文件 Diff 的展示与定位;从源代码管理打开已修改和未跟踪文件的工作区 Diff 后保持静止,确认 Diff 不会自动关闭,且只在文件不再出现在 Git 状态中时关闭。,,macos/Sources/Lithe/Views/Git; macos/Sources/Lithe/Views/Diff,windows/tauri/src/features/git; windows/tauri/src/features/viewer -git-history,版本控制,Git,提交历史与图谱,已实现,待验证,已实现,待验证,Git,分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端保留各自调色板,不要求 RGB 值一致。,,macos/Sources/Lithe/Views/Git; macos/Sources/LitheGitModule/Services/GitGraphLayoutService.swift; macos/Sources/LitheGitModule/Services/GitGraphHeadOrdering.swift; macos/Sources/Lithe/Views/Git/GitGraphColor.swift,windows/tauri/src/features/git; windows/tauri/src/features/git/utils/git-graph-layout.ts; windows/tauri/src/features/git/utils/git-graph-colors.ts; windows/tauri/src/features/git/utils/git-graph-layout.test.ts +git-diff-review,版本控制,Git,Diff 与变更审查,已实现,待验证,已实现,待验证,Git,验证新增、删除、重命名、二进制和多文件 Diff 的展示与定位;从源代码管理打开已修改和未跟踪文件的工作区 Diff 后保持静止,确认 Diff 不会自动关闭,且只在文件不再出现在 Git 状态中时关闭;在 Windows 单文件工作区 Diff 中点击折叠条展开省略的未改动代码,确认 Diff 保持打开且每个修改块仍可单独暂存;在 Windows 未暂存单文件 Diff 中点击某个修改块的“回滚”,确认弹出确认框、取消不改文件、确认后只恢复该块且暂存区不变,最后一块回滚后文件从状态中消失时 Diff 关闭;暂存区、历史、分支比较与新增/未跟踪文件不显示回滚按钮。,,macos/Sources/Lithe/Views/Git; macos/Sources/Lithe/Views/Diff,windows/tauri/src/features/git; windows/tauri/src/features/viewer +git-history,版本控制,Git,提交历史与图谱,已实现,待验证,已实现,待验证,Git,分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;在 Windows Git Log 选中带多行正文的提交,确认右侧详情在标题下方完整显示正文(保留空行与缩进),复制提交信息和打开提交 Diff 时也包含正文;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端保留各自调色板,不要求 RGB 值一致。 读取提交正文期间切换仓库、重新打开另一提交或关闭面板,确认迟到结果不会打开旧 Diff 或覆盖新请求的加载状态。 Windows 另验证:单击提交后编辑区预览该提交第一个文件的 diff;单击或双击 Commit files 中的文件只显示该文件的 diff(不含提交说明,也不显示文件列表),并复用同一个预览标签;Git Log 刚打开、尚未点击提交时不会自动打开 diff;日期列对与本机同一时区的提交显示相对时间(刚刚、N 分钟前、今天/昨天 + 12 小时制,中文为上午/下午),其他时区或未知时区的提交只显示日期,鼠标移入行时刷新相对时间;作者/日期列可在行内列边缘拖动调整宽度并在重启后保留。 非连续多选提交修改同一路径时,预览保留每个提交的 diff 区段及提交标识;延迟请求返回前取消选择、隐藏 Git Log、切换仓库或卸载工具窗,确认旧请求不会重新打开或激活预览标签。,,macos/Sources/Lithe/Views/Git; macos/Sources/LitheGitModule/Services/GitGraphLayoutService.swift; macos/Sources/LitheGitModule/Services/GitGraphHeadOrdering.swift; macos/Sources/Lithe/Views/Git/GitGraphColor.swift,windows/tauri/src/features/git; windows/tauri/src/features/git/utils/git-graph-layout.ts; windows/tauri/src/features/git/utils/git-graph-colors.ts; windows/tauri/src/features/git/utils/git-graph-layout.test.ts; windows/tauri/src/features/git/components/log/git-commit-details.tsx; windows/tauri/src/features/git/components/log/git-commit-details.test.tsx; windows/tauri/src/features/git/hooks/use-git-diff-actions.test.tsx; windows/tauri/src/features/git/hooks/use-commit-file-preview.ts; windows/tauri/src/features/git/hooks/use-commit-file-preview.test.tsx git-rebase-stash,版本控制,Git,Rebase 与 Stash,已实现,待验证,已实现,待验证,Git,执行交互式 Rebase 和 Stash 保存/恢复,确认中断、冲突和继续操作。,,macos/Sources/Lithe/Views/Git,windows/tauri/src/features/git git-worktrees,版本控制,Git,Worktree 管理,已实现,待验证,已实现,待验证,Git,列出、创建、切换和删除 Worktree,确认路径、分支和安全检查。,,macos/Sources/Lithe/Views/Git,windows/tauri/src/features/git git-multi-repository-references,版本控制,Git,多仓库引用面板:分组、配色与引用操作,已实现,待验证,已实现,待验证,Git,在多仓库工作区打开 Git Log:确认按仓库分组、仓库配色、非活动仓库分组只读,点其它仓库的引用会切换活动仓库且只加载一次,Pull 弹窗可选择远程分支与策略。,,macos/Sources/Lithe/Views/Git,windows/tauri/src/features/git @@ -42,7 +43,7 @@ ai-commit-generation,AI,AI 提交信息,Provider 配置与提交信息生成,已 ai-chat-session,AI,AI 对话,对话会话与流式响应,未实现,不适用,已实现,待验证,AI,Windows 验证新建会话、流式输出、取消和失败恢复;macOS 需要先定义产品范围。,,macos/Sources/Lithe/Platform/MacOS/AI,windows/tauri/src/features/ai/components/chat/ai-chat.tsx; windows/tauri/src/features/ai/services/ai-chat-service.ts ai-chat-history,AI,AI 对话,对话历史、置顶与归档,未实现,不适用,已实现,待验证,AI,Windows 验证历史加载、重命名、置顶、归档、删除和重启后持久化。,,macos/Sources/Lithe/Platform/MacOS/AI,windows/tauri/src/features/ai/services/ai-chat-history-service.ts; windows/tauri/src/features/layout/components/sidebar/sidebar-history.tsx ai-chat-providers,AI,AI 对话,多 Provider 与工作区范围,未实现,不适用,已实现,待验证,AI,Windows 验证 Provider 切换、凭据边界、工作区范围和模型错误提示。,,macos/Sources/Lithe/Platform/MacOS/AI,windows/tauri/src/features/ai/services/providers; windows/tauri/src/features/ai/lib/ai-workspace-scope.ts -java-runtime-discovery,Java,Java/Maven,JDK 与 Maven 工具链发现,已实现,待验证,已实现,待验证,Java / Maven,配置多个 JDK/Maven 候选,验证发现、选择、版本不匹配和错误提示。,,macos/Sources/Lithe/Services/Java; macos/Sources/Lithe/Views/Run,windows/tauri/src/features/maven; windows/tauri/src/features/workspace +java-runtime-discovery,Java,Java/Maven,JDK 与 Maven 工具链发现及按项目要求自动选择 JDK,已实现,待验证,已实现,待验证,Java / Maven,两端安装 JDK 8/17/21:项目要求 17 且 JAVA_HOME 为 8 时验证自动选择兼容版本;核对设置、Run、Maven 目标、测试使用同一 JDK。验证显式选择不变、没有兼容版本时回退并提示,以及无要求时的平台默认顺序。,,macos/Sources/Lithe/Services/Java; macos/Sources/Lithe/Views/Run; macos/Sources/Lithe/Core/Ports/RuntimeLocator.swift; macos/Tests/LitheTests/MavenRuntimeTests.swift; rust/lithe-core/src/execution/java_selection.rs,windows/tauri/src/features/maven; windows/tauri/src/features/workspace; windows/tauri/src-tauri/src/run.rs; shared/fixtures/run-configuration/automatic-java-selection.json java-maven-project-settings,Java,Java/Maven,按项目配置 Maven 工具链、settings.xml 与本地仓库,已实现,待验证,已实现,待验证,Java / Maven,在 macOS 项目设置和 Windows Maven 设置页分别切换两个项目,配置 Maven、JDK、settings.xml 与本地仓库,验证保存重开、自动检测展示及项目间隔离;Windows 还需验证写盘失败后可直接重试。 Windows 自定义 settings.xml 后,仓库留空应显示该文件中的目录;验证 XML 实体和 CDATA、继承的项目 JDK/Maven、切换项目丢弃未保存草稿;冷启动 Wrapper 不应触发下载,版本探测卡住时设置窗口保持响应并在超时后清理探测进程;Run 配置显式选择的 Maven/JDK 优先于项目设置,未设置的字段分别继承 Maven 设置;确认主任务与前置任务使用相同工具链,Maven 设置上下文缺失时仍保留显式覆盖,均未配置时交由平台自动发现。,,macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift; macos/Sources/Lithe/Services/Java/ProjectRuntimeService.swift,windows/tauri/src/features/settings/components/tabs/maven-settings-panel.tsx; windows/tauri/src/features/maven/stores/maven.store.ts; windows/tauri/src-tauri/src/maven.rs; windows/tauri/src-tauri/src/maven/settings_xml.rs; windows/tauri/src/features/maven/components/maven-detected-value.test.tsx; windows/tauri/src/features/settings/components/tabs/maven-settings-panel.test.tsx; windows/tauri/src/features/run/stores/run.store.ts; windows/tauri/src/features/run/stores/run-maven-context.test.ts java-maven-project,Java,Java/Maven,项目根、模块与源码集识别,已实现,待验证,已实现,待验证,Java / Maven,使用单模块、多模块和非标准源码目录 fixture 对比项目模型。,,macos/Sources/Lithe/Services/Java; shared/contracts/application-boundary.md,windows/tauri/src/features/maven; windows/tauri/src/features/workspace java-maven-profiles-dependencies,Java,Java/Maven,Profile 与依赖树,已实现,待验证,已实现,待验证,Java / Maven,"切换 Maven profile 并刷新依赖树,确认模块归属、顺序、版本冲突/重复/依赖管理标注和错误边界;在依赖节点超过 6,000 个的项目中确认依赖树完整加载。 Windows 在 Maven 面板展开「依赖项」应直接加载依赖树,失败时显示宿主返回的真实原因,而不是通用的“无法加载 Maven 依赖”(#970)。",,macos/Sources/Lithe/Views/Run; macos/Sources/Lithe/Services/Java; macos/Sources/LitheExecutionModule/Services/MavenService.swift; macos/Sources/Lithe/Platform/MacOS/Persistence/MacMavenDependencyOutputStore.swift; rust/lithe-core/src/project/maven_dependency_tree.rs,windows/tauri/src/features/maven; windows/tauri/src-tauri/src/maven.rs; rust/lithe-core/src/project/maven_dependency_tree.rs @@ -60,11 +61,11 @@ lsp-navigation-edits,Java,语言服务,跳转、引用、语义标记与编辑, run-discovery,运行与调试,运行配置,入口点与运行配置发现,已实现,待验证,已实现,待验证,Run,验证 Spring Boot、Java、Maven、Gradle、npm、Cargo、Go、Python 和 Docker Compose 入口识别;多模块项目把服务工作目录改成模块目录后,服务仍留在列表中并以该目录启动;填写不存在的目录或 ${workspaceFolder} 等变量时,保存被拒绝并提示原因;在 Windows 确认重新扫描服务使用双箭头刷新图标。,,macos/Sources/Lithe/Views/Run; shared/fixtures/run-configuration,windows/tauri/src/features/run; shared/fixtures/run-configuration run-save-toolchain,运行与调试,运行配置,保存前同步与工具链解析,已实现,待验证,已实现,待验证,Run,修改未保存文件后运行,验证同步、JDK/Maven 选择和版本不匹配诊断。,,macos/Sources/Lithe/Views/Run; macos/Sources/Lithe/Services/Java,windows/tauri/src/features/run; windows/tauri/src/features/maven run-java-test,运行与调试,运行配置,Java main 与测试运行,已实现,待验证,已实现,待验证,Run,运行 main、单测试和测试类,验证参数、输出、失败状态和终端策略。 Windows Run 运行输出使用 Geist Mono 显示 ====、==>、=>、!=、>=,确认禁用连字后逐字符显示且复制文本与原始输出一致;检查有输出与空态、ANSI 颜色、自动换行切换及横向滚动。,,macos/Sources/Lithe/Views/Run; shared/fixtures/debug,windows/tauri/src/features/run; shared/fixtures/debug; windows/tauri/src/features/run/components/run-pane.tsx; windows/tauri/src/features/run/components/run-output-text.tsx -run-java-long-classpath,运行与调试,运行配置,超长 Java 类路径自动缩短,已实现,待验证,已实现,待验证,Run,使用包含大量依赖的 Java 项目运行 main,确认超长类路径自动写入参数文件、进程可启动且参数文件在退出后清理。,,macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacJavaLaunchArgumentPreparer.swift; macos/Sources/LitheExecutionModule/Services/RunService.swift; rust/lithe-core/src/execution/launch_command.rs,windows/tauri/src-tauri/src/run/launch_arguments.rs; rust/lithe-core/src/execution/launch_command.rs +run-java-long-classpath,运行与调试,运行配置,超长 Java 类路径自动缩短,已实现,待验证,已实现,待验证,Run,使用包含大量依赖的 Java 项目运行 main,确认超长类路径自动写入参数文件、进程可启动且参数文件在退出后清理。 Windows 上分别用 JDK 8 和 JDK 17 运行同一个依赖很多、路径含空格和中文的项目 main:JDK 8 应在临时目录生成只含 META-INF/MANIFEST.MF 的 .classpath.jar 并以 -cp 引用,JDK 17 仍使用 @argfile;两者都能启动,进程退出或启动失败后临时文件被删除,安装目录与 JDK 目录不新增文件。 在慢速网络盘 classpath 准备期间确认工作台可交互;停止或重新运行后,旧准备结果不得启动进程,旧 executionId 的停止请求不得影响新执行。,,macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacJavaLaunchArgumentPreparer.swift; macos/Sources/LitheExecutionModule/Services/RunService.swift; rust/lithe-core/src/execution/launch_command.rs,windows/tauri/src-tauri/src/run/launch_arguments.rs; rust/lithe-core/src/execution/launch_command.rs; rust/lithe-core/src/execution/classpath_jar.rs; windows/tauri/src-tauri/src/run.rs debug-breakpoints,运行与调试,调试器,启动调试与断点,已实现,待验证,部分实现,待验证,Debug,设置、命中、禁用和重新定位断点,确认调试会话生命周期。,Windows 真实调试产品链路仍在 #466 跟进。,macos/Sources/Lithe/Views/Debug; shared/fixtures/debug,windows/tauri/src/features/debugger; shared/fixtures/debug debug-state,运行与调试,调试器,变量、异常与断开策略,已实现,待验证,部分实现,待验证,Debug,验证变量分页、异常信息、step filters、暂停/继续和 disconnect policy。,Windows 真实调试产品链路仍在 #466 跟进。,macos/Sources/Lithe/Views/Debug; shared/fixtures/debug,windows/tauri/src/features/debugger; shared/fixtures/debug terminal-shell,工作台,终端,Shell 发现与配置,已实现,待验证,已实现,待验证,Terminal,验证设置页默认 Shell 选项与终端「新建终端」菜单检测到的 Shell 一致、可选 Git Bash 并生效,以及环境变量、工作目录和不可用 Shell 的提示。,,macos/Sources/Lithe/Views/Terminal; macos/Sources/Lithe/Views/App/SettingsView.swift; macos/Sources/Lithe/Services,windows/tauri/src/features/terminal; windows/tauri/src/features/settings/components/macos-settings-panels.tsx; windows/tauri/src/features/settings/lib/default-shell-options.ts; windows/tauri/src-tauri -terminal-sessions,工作台,终端,多会话与标签,已实现,待验证,已实现,待验证,Terminal,创建多个终端会话并切换、重命名、关闭,确认子进程生命周期和资源清理;Windows 横向标签栏的新建终端与 Shell 配置按钮紧跟最后一个标签,标签溢出时按钮保持可见;标签栏右侧最小化按钮隐藏终端面板且会话继续运行,重新打开时恢复为停靠状态。,,macos/Sources/Lithe/Views/Terminal,windows/tauri/src/features/terminal +terminal-sessions,工作台,终端,多会话与标签,已实现,待验证,已实现,待验证,Terminal,创建多个终端会话并切换、重命名、关闭,确认子进程生命周期和资源清理;Windows 横向标签栏的新建终端与 Shell 配置按钮紧跟最后一个标签,标签溢出时按钮保持可见;标签栏右侧最小化按钮隐藏终端面板且会话继续运行,重新打开时恢复为停靠状态;Windows 打开底部终端面板时默认通栏(与 IDEA 一致):面板位于左右两条活动栏之间并横跨侧边栏与编辑器下方(活动栏保持通高,底部的活动栏按钮位置不上移),侧边栏让出纵向空间,且仍可通过面板菜单切换为编辑器宽度。,,macos/Sources/Lithe/Views/Terminal,windows/tauri/src/features/terminal terminal-interaction,工作台,终端,复制粘贴、搜索与调整大小,已实现,待验证,已实现,待验证,Terminal,验证复制粘贴、终端搜索、调整面板大小和高频输入不丢失。,,macos/Sources/Lithe/Views/Terminal,windows/tauri/src/features/terminal terminal-long-command-input,工作台,终端,Windows Shell 超长命令输入,平台专属,不适用,已实现,待验证,Terminal,在 Windows cmd.exe 和 PowerShell 中执行超过 Shell 单行限制的 IDE 多行命令,确认脚本执行、原始命令内容和终端关闭后的临时文件清理。,仅在 IDE 后端为 cmd.exe/PowerShell 的超长多行文本创建一次性脚本;不修改系统 Shell 配置。,macos/Sources/Lithe/Views/Terminal,windows/tauri/crates/terminal/src/connection.rs; windows/tauri/src-tauri/src/terminal.rs markdown-rendering,编辑器,Markdown,Markdown 预览与富文本渲染,已实现,待验证,已实现,待验证,Editor,使用代码高亮、表格、Mermaid、链接和相对路径 fixture 对比渲染结果。,,macos/Sources/Lithe/Views/Editor/MarkdownPreviewView.swift; macos/Sources/Lithe/Core/Ports/MarkdownRendering.swift,windows/tauri/src/features/editor/markdown @@ -87,6 +88,7 @@ plugin-extension-management,扩展,插件与主题,插件、语言服务与主 onboarding-welcome,启动与窗口,首次启动,欢迎页、项目入口与首次启动引导,已实现,待验证,已实现,待验证,Onboarding,清空首次启动状态,验证打开项目、克隆项目、最近项目和跳过引导;在 macOS 深色与浅色模式检查欢迎页布局、颜色和交互,确认默认及最小内容尺寸均为 800×650、可向屏幕可用区域放大,左下角齿轮菜单采用设置页下拉框配色并可进入设置与检查更新。,,macos/Sources/Lithe/Views/App/WelcomeView.swift; macos/Sources/Lithe/Views/Workspace,windows/tauri/src/features/onboarding; windows/tauri/src/features/bootstrap command-palette,工作台,命令与布局,命令面板与全局动作,已实现,待验证,已实现,待验证,Workbench,搜索并执行打开面板、运行、Git 和设置命令,确认快捷键和不可用命令状态。,,macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift; macos/Sources/Lithe/Views/Search/SearchEverywhereView.swift,windows/tauri/src/features/command-palette workbench-compact-menu-bar,工作台,命令与布局,紧凑菜单栏可在标题栏左侧原位展开完整水平菜单,并在点击外部或按 Escape 后收起,不遮挡工作区,平台专属,不适用,已实现,待验证,Workbench,在 Windows 工作台点击标题栏左端菜单按钮,确认汉堡按钮由“文件”菜单项取代,“文件”项处于选中状态且其下拉菜单立即展开;菜单与标题栏同高,不覆盖编辑区域,项目/分支控件在展开时隐藏。检查常驻品牌标识与下方活动栏图标列对齐且不可选中,并确认项目菜单显示当前项目图标(无自定义图标时显示项目缩写)。点击其他菜单项时菜单条保持展开并执行动作;点击工作区、标题栏空白和右侧控件或按 Escape 后收起;按 Escape 后确认焦点返回菜单入口;窄窗口确认菜单在标题栏内水平滚动且不挤压窗口控件。macOS 保持系统菜单栏行为。,,macos/Sources/Lithe/LitheApp.swift,windows/tauri/src/features/window/components/title-bar/title-bar.tsx; windows/tauri/src/features/window/components/title-bar/title-project-menu.tsx; windows/tauri/src/features/window/components/window-menu-bar.tsx; windows/tauri/src/features/window/hooks/use-compact-menu-bar-dismissal.ts; windows/tauri/src/features/window/hooks/use-compact-menu-bar-dismissal.test.tsx +workbench-settings-entry,工作台,命令与布局,从工作台 chrome 打开设置:Windows 点击标题栏搜索按钮右侧的齿轮,macOS 通过应用菜单及面板内入口,已实现,待验证,已实现,待验证,Workbench,在 Windows 打开工作区,确认左侧活动栏底部不再显示设置齿轮,活动栏右键“在活动栏中显示”列表不包含设置项;点击标题栏右上搜索按钮右侧的齿轮打开设置对话框并再次关闭;携带旧版设置文件(活动栏排序或隐藏列表含 settings)启动后确认遗留条目被清理且活动栏显示正常。macOS 通过应用菜单和各面板入口打开设置保持不变。,,macos/Sources/Lithe/Models/LitheAction.swift; macos/Sources/Lithe/Views/App/SettingsView.swift,windows/tauri/src/features/window/components/title-bar/title-bar.tsx; windows/tauri/src/features/layout/components/sidebar/sidebar-pane-selector.tsx; windows/tauri/src/features/layout/config/item-order.ts window-controls,工作台,窗口管理,标题栏控制窗口最小化、最大化/还原与关闭;命令面板可最小化、最大化及切换全屏,已实现,待验证,已实现,待验证,Workbench,在 macOS 检查系统标题栏控制及全屏入口;在 Windows 验证标题栏最小化、最大化/还原和关闭,以及命令面板的窗口操作,确认图标语义、最大化/还原状态与按钮标签一致,并确认悬停关闭按钮背景贴合窗口右边缘。,,macos/Sources/Lithe/LitheApp.swift; macos/Sources/Lithe/Views/App/RootView.swift,windows/tauri/src/features/window/components/title-bar/window-controls.tsx; windows/tauri/src/features/command-palette/constants/window-actions.tsx; windows/tauri/src/features/window/utils/window-actions.ts workbench-panes,工作台,命令与布局,分栏、面板与工具窗布局,已实现,待验证,已实现,待验证,Workbench,打开、关闭、移动和调整面板,验证布局持久化及高频拖动稳定性;在 macOS 启用背景图后检查项目栏、编辑器、底部及右侧工具窗之间是否仍有清晰的分割区间;将 macOS 左侧工具窗缩至 30pt 并确认目录树始终贴左、不随宽度向右移动、内容不溢出、右上和右下圆角仍可见,关闭并重新打开项目后仍保持窄侧栏及其他布局尺寸,重新展开后布局正常;在 Maven 项目产生构建输出时确认左侧模块按钮可滚动、设置按钮始终完整显示;在亮色主题且有未读通知时确认未打开的铃铛图标清晰可见、打开通知面板后白色图标与蓝色背景成对出现;在深色 Lithe 主题下对照外框渐变、面板对比度、背景图片回退、左右工具窗的 40pt 按钮槽、30pt 选中块及 IDEA Community 图标对比度,悬停工具窗按钮时确认箭头光标,检查项目栏标题颜色和弹窗主按钮主题色;检查 macOS 40pt 顶栏 Logo 与系统窗口按钮垂直居中,项目切换器使用 20pt 项目字母头像和 30pt 悬停块,项目与分支切换块悬停保持箭头光标,红绿灯右侧留白与 IDEA 接近,项目头像、欢迎列表及切换菜单按标准化项目路径共享稳定颜色(超过 9 个项目、移除后重开仍一致),顶栏使用 IDEA Islands 主题项目色并按 85% 混色、600pt 水平和 300pt 垂直范围渐变,深色窗口分隔条四角与底栏缺口无浅色亮斑,缩放窗口及从欢迎页进入工作区再返回后仍对齐。,,macos/Sources/Lithe/Views/Workbench; macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift; macos/Sources/Lithe/Views/Components/LitheSplitPaneView.swift; macos/Sources/Lithe/Services/Workbench/WorkbenchLayoutStore.swift; macos/Sources/Lithe/Views/App/RootView.swift; macos/Sources/Lithe/LitheApp.swift; macos/Sources/Lithe/Theme/LitheTheme.swift; macos/Resources/IDEAIcons/expui; macos/Resources/IDEAIcons/terminal,windows/tauri/src/features/panes; windows/tauri/src/features/layout workbench-activity-tooltips,工作台,工作台布局,活动栏图标的稳定悬停说明,已实现,待验证,已实现,待验证,Workbench,逐一悬停左上导航、左下工具入口及右侧通知/插件/Maven,快速切换图标并移出活动栏;确认始终只显示当前图标说明,面板更新和滚动后不残留旧提示,禁用入口可显示说明,短文字自适应且提示不超出窗口边缘。,,macos/Sources/Lithe/Views/Workbench/WorkbenchHoverTooltip.swift; macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift; macos/Tests/LitheTests/WorkbenchHoverTooltipTests.swift,windows/tauri/src/features/layout/components/sidebar/sidebar-pane-selector.tsx; windows/tauri/src/features/layout/components/footer/footer-tab-control.tsx; windows/tauri/src/features/layout/components/plugin-activity-rail.tsx @@ -114,3 +116,6 @@ editor-unicode-text-classification,编辑器,文本编辑,中文 properties 与 ide-api-plugins,插件与扩展,IDE API / MCP,插件调用已授权项目的环境、Maven 与运行 API,已实现,待验证,已实现,待验证,IDE API,双端插件枚举显式授权的项目,读取环境并调用已允许的操作;撤销项目权限后拒绝调用,Windows 未声明 permissions.ide 的 Worker 无法访问。,复用 MCP 的同一应用功能和参数校验,不扩张语言包权限;原生运行验收待完成。,macos/Sources/LitheModuleAPI/Plugins/IDECapabilities.swift; macos/Sources/Lithe/Application/Composition/IdeCapabilityRegistry.swift,Plugins/win/SDK/ide-capabilities.ts; windows/tauri/src/extensions/ui/services/ide-extension-host.ts; windows/tauri/src/extensions/ui/services/ide-extension-host.test.ts java-service-devtools-update,Run/Debug,Java 服务更新,运行中的 Spring Boot DevTools 服务手动保存并编译更新,已实现,待验证,已实现,待验证,Run/Debug,在双端运行 Spring Boot HTTP 服务,修改方法体并更新,检查响应、PID、DevTools 日志与断点暂停状态;覆盖编译失败、JVM 拒绝结构修改、连续点击、停止/重启、依赖路径变化。对比运行前后安装目录清单/哈希。Linux 仅完成共享 Core 和前端测试,原生验证待完成。,,macos/Sources/Lithe/Views/Run/RunView.swift,windows/tauri/src/features/run/components/run-pane.tsx java-service-hot-code-replace,Run/Debug,Java 服务更新,本地 Java Debug 服务手动编译并热替换代码,已实现,待验证,已实现,待验证,Run/Debug,在双端运行 Spring Boot HTTP 服务,修改方法体并更新,检查响应、PID、DevTools 日志与断点暂停状态;覆盖编译失败、JVM 拒绝结构修改、连续点击、停止/重启、依赖路径变化。对比运行前后安装目录清单/哈希。Linux 仅完成共享 Core 和前端测试,原生验证待完成。,,macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift,windows/tauri/src/features/debugger/components/java-service-update.tsx +editor-git-gutter-peek,编辑器,Git 变更,编辑器 Git 行变更标记、点击预览与变更导航,已实现,待验证,已实现,待验证,Editor / Git,在 Windows 打开已跟踪文件,新增、修改、删除内容并保存,点击标记查看差异、切换前后变更、按 Escape 关闭;确认多仓库同名文件互不影响。切换浅色、深色及自定义主色,检查面板边框、焦点和 Git 语义色;关闭 Git 标记设置后标记消失。macOS 检查对应行变更入口。Linux 单元测试不替代目标平台运行验证。,,macos/Sources/Lithe/Views/Editor/CodeEditorView.swift,windows/tauri/src/features/editor/engines/monaco/git-gutter-controller.ts; windows/tauri/src/features/editor/engines/monaco/git-gutter-peek-widget.ts; windows/tauri/src/features/git/utils/git-gutter-changes.test.ts +editor-git-gutter-stage,编辑器,Git 变更,从编辑器行变更入口暂存单处修改,已实现,待验证,已实现,待验证,Editor / Git,保存含多处增删改的已跟踪文件,单独暂存后一处修改,检查 index 只包含选中修改,邻近与前方未暂存修改保留;未保存或只读时禁用暂存。Windows 与 macOS 均需实机验证。,,macos/Sources/Lithe/Views/Editor/CodeEditorView.swift,windows/tauri/src/features/git/services/git-gutter-actions.ts; windows/tauri/src/features/git/utils/git-gutter-changes.test.ts +editor-git-gutter-setting,编辑器,Git 变更,设置中开关编辑器 Git 变更标记,平台专属,不适用,已实现,待验证,Editor / Git,Windows 默认显示 Git 变更标记;在 Git 设置中关闭时清除标记和预览,重新启用后恢复,重启后保留选择。此项记录 Windows 新增的独立开关。,,macos/Sources/Lithe/Views/Editor/CodeEditorView.swift,windows/tauri/src/features/settings/components/tabs/git-settings.tsx; windows/tauri/src/features/settings/config/default-settings.ts diff --git a/docs/development/platform-parity-matrix.md b/docs/development/platform-parity-matrix.md index 6e05a4cde..4305bb633 100644 --- a/docs/development/platform-parity-matrix.md +++ b/docs/development/platform-parity-matrix.md @@ -4,9 +4,9 @@ - 最后复核:2026-09-29 - 盘点状态:initial-static-inventory(根据 macOS Views/Application/Services、Windows features/extensions 和共享契约的代码入口进行初版盘点;未替代真实运行验收。) -- 功能项:115 -- macOS:实现:✅ 101 已实现,🟡 3 部分实现,❌ 7 未实现,🧩 4 平台专属;验证:✔️ 0 已验证,🔍 104 待验证,— 11 不适用 -- Windows:实现:✅ 98 已实现,🟡 11 部分实现,❌ 5 未实现,🧩 1 平台专属;验证:✔️ 0 已验证,🔍 109 待验证,— 6 不适用 +- 功能项:120 +- macOS:实现:✅ 105 已实现,🟡 3 部分实现,❌ 7 未实现,🧩 5 平台专属;验证:✔️ 0 已验证,🔍 108 待验证,— 12 不适用 +- Windows:实现:✅ 102 已实现,🟡 11 部分实现,❌ 5 未实现,🧩 2 平台专属;验证:✔️ 0 已验证,🔍 113 待验证,— 7 不适用 ## 实现状态定义 @@ -72,13 +72,14 @@
-编辑器 · 15 个能力点 +编辑器 · 19 个能力点 | 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | | --- | --- | --- | --- | --- | --- | --- | -| 文本编辑 | **文本编辑与标签生命周期**
editor-text-tabs | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Editor`、`macos/Sources/Lithe/Models/Editor` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/editor`、`windows/tauri/src/features/tabs` | Editor | 打开、编辑、保存、关闭和恢复多个文本文件,确认光标、脏状态和标签状态;在 macOS 打开无扩展名文本文件,确认标签与项目树图标一致;从外部改为二进制后折叠并展开项目树,确认两处图标更新一致。 | | +| 文本编辑 | **文本编辑与标签生命周期**
editor-text-tabs | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Editor`、`macos/Sources/Lithe/Models/Editor` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/editor`、`windows/tauri/src/features/tabs`、`windows/tauri/src/features/tabs/components/tab-context-menu.tsx`、`windows/tauri/src/features/tabs/components/tab-context-menu.test.ts` | Editor | 打开、编辑、保存、关闭和恢复多个文本文件,确认光标、脏状态和标签状态;右键标签页确认关闭分组排在其他操作之前;在 macOS 打开无扩展名文本文件,确认标签与项目树图标一致;从外部改为二进制后折叠并展开项目树,确认两处图标更新一致。 | | | 文本编辑 | **鼠标与触控板点击、选词和拖选**
editor-pointer-selection | ✅ 已实现
🔍 待验证
`macos/EditorFrontend/main.ts`、`macos/EditorFrontend/mouse-input.ts`、`macos/Experiments/Monaco/mouse-input.integration.ts` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/editor/components/monaco-editor.tsx` | Editor | macOS 分别启用微信输入法和系统 ABC,快速轻点不同位置后松手移动,确认不扩展选区、不移动文字;验证同位置双击选词、Shift 点击扩选、按住拖选、选中文字拖放、主编辑器和分屏及 diff。运行 scripts/probe-macos-monaco.sh --workbench-tests;Windows 验证同样的正常鼠标交互。 | macOS 在宿主修正 WebKit 零按钮按下事件,保留 Monaco 选择语义。Linux Chromium 真实 Monaco 控制事件回放通过,仍待 macOS 微信输入法与 Windows WebView2 实机验证。 | | 文本编辑 | **语法高亮与编辑器模型**
editor-language-basics | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Editor`、`macos/Sources/Lithe/Models/Editor` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/editor`、`frontend/editor`、`windows/tauri/src/features/editor/engines/monaco/theme.ts` | Editor | 分别打开 Java、Markdown 和普通文本,确认语言识别、语法高亮和模型切换;开启缩略图并滚动,确认滚动条轨道不透出编辑器代码。 | | +| 文本编辑 | **macOS 中文组合输入层遮住原文字,输入和删除候选文字时不透出整行重影**
editor-macos-ime-presentation | ✅ 已实现
🔍 待验证
`macos/EditorFrontend/ime-input.css`、`macos/EditorFrontend/main.ts`、`macos/Experiments/Monaco/ime-input.integration.ts` | 🧩 平台专属
— 不适用
`windows/tauri/src/features/editor/components/monaco-editor.tsx` | Editor | 运行 scripts/probe-macos-monaco.sh --workbench-tests。在 macOS 使用系统拼音与微信输入法,在含中文和英文的同一行中间输入、删减候选拼音、确认上屏,再按删除键;检查整行无重影、候选与光标正常、撤销恢复文本。覆盖明暗主题、背景图开关和分屏,结束输入后背景恢复。 | 修正 macOS 原生主题半透明当前行底色作为 Monaco 输入法覆盖层底色时的字形透出。Linux Chromium 的真实 Monaco 组合事件样式回归通过;macOS 原生输入法及删除键实机验证待完成。Windows 不加载此 macOS 样式。 | | 编辑器外观 | **macOS 工作台壁纸模式下,编辑器概览标尺与代码缩略图随编辑器背景透明,不显示黑色竖条;普通主题仍保持实体背景**
editor-wallpaper-overview-ruler | ✅ 已实现
🔍 待验证
`frontend/editor/src/theme.ts`、`macos/Sources/Lithe/Platform/MacOS/MonacoWorkbenchEditor.swift`、`macos/Experiments/Monaco/workbench.integration.ts` | 🧩 平台专属
— 不适用
`windows/tauri/src/features/editor/engines/monaco/theme.ts` | Editor | 在 macOS 选择工作台背景并打开文本文件,分别关闭和打开右侧 Agent 面板,确认编辑器最右侧概览标尺不出现黑色竖条;关闭背景图后确认普通主题底色保持不变。运行真实 WebKit 工作台主题集成测试。Windows 不使用 macOS 原生工作台壁纸。 | | | 文本编辑 | **LSP 语义高亮**
editor-semantic-highlighting | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Models/AppModel/AppModel+LanguageEditing.swift`、`macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift`、`frontend/editor/src/semantic-tokens.ts` | ✅ 已实现
🔍 待验证
`windows/tauri/src/platform/lsp-core-adapter.ts`、`windows/tauri/src/features/editor/lsp/lsp-client.ts`、`windows/tauri/src/features/editor/engines/monaco/semantic-token-provider.ts`、`windows/tauri/src/features/editor/engines/monaco/semantic-token-provider.test.ts`、`windows/tauri/src/platform/lsp-core-adapter.test.ts` | Editor / Language Tooling | 打开普通 Java 文件并等待 JDTLS 就绪,开启语义高亮,确认字段、方法等按协商图例上色;编辑、切换、关闭文档和重连服务器后不应用旧结果;服务器 refresh 后重新请求;关闭设置或大文件降级时保留基础语法高亮。 | Windows 复用 Core semanticTokens 和共享 fixture,修复 #675;Linux 上的适配器与模型测试不替代 Windows WebView2/JDTLS 实机验收。 | | 文本编辑 | **多行标签与标签导航**
editor-multiline-tabs | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Editor` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/tabs` | Editor | 打开足够多文件触发多行标签,确认滚动、切换、关闭和活动文件保持。 | | @@ -91,6 +92,9 @@ | 文本编辑 | **按指定编码重新打开文本文件**
editor-file-encoding-reopen | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift`、`macos/Sources/Lithe/Views/Editor/StandaloneEditorView.swift`、`macos/Sources/Lithe/Application/Features/DocumentFeatureModel.swift`、`macos/Sources/Lithe/Platform/MacOS/FileSystem/MacDocumentEncoding.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/command-palette/components/encoding-picker.tsx`、`windows/tauri/src/features/editor/services/document-encoding-workflow.ts`、`windows/tauri/crates/project/src/document_file.rs` | Editor | 在 macOS 和 Windows 实机验证 UTF-8、UTF-8 BOM 与 GBK/GB18030 的自动识别,并分别验证 Shift JIS、Windows-1252 的指定编码重新打开、编码转换保存、脏文件选择和外部修改保护。 | | | 文本编辑 | **按指定编码保存文本文件**
editor-file-encoding-save | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift`、`macos/Sources/Lithe/Views/Editor/StandaloneEditorView.swift`、`macos/Sources/Lithe/Application/Features/DocumentFeatureModel.swift`、`macos/Sources/Lithe/Platform/MacOS/FileSystem/MacDocumentEncoding.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/command-palette/components/encoding-picker.tsx`、`windows/tauri/src/features/editor/stores/editor-app.store.ts`、`windows/tauri/crates/project/src/document_file.rs` | Editor | 在 macOS 和 Windows 实机验证 UTF-8、UTF-8 BOM 与 GBK/GB18030 的自动识别,并分别验证 Shift JIS、Windows-1252 的指定编码重新打开、编码转换保存、脏文件选择和外部修改保护。 | | | 文本编辑 | **中文 properties 与未知扩展名文本的一致打开和恢复**
editor-unicode-text-classification | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Core/Rust/RustTextContentPolicy.swift`、`macos/Tests/LitheTests/TextContentPolicyTests.swift`、`frontend/editor/src/language-contributions.ts` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/file-system/controllers/workspace-session-restore.ts`、`windows/tauri/src/features/file-system/controllers/workspace-session-restore.test.ts`、`frontend/editor/src/language-contributions.ts` | Editor | 双端打开高中文比例的 .properties 和未知扩展名 UTF-8 文件,编辑保存后恢复会话并验证内容及编码;验证 properties 高亮、emoji 跨边界、NUL 和控制字符拒绝、读取错误独立报告,并执行 text-content-v1.json 共享规则样例。 | | +| Git 变更 | **编辑器 Git 行变更标记、点击预览与变更导航**
editor-git-gutter-peek | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Editor/CodeEditorView.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/editor/engines/monaco/git-gutter-controller.ts`、`windows/tauri/src/features/editor/engines/monaco/git-gutter-peek-widget.ts`、`windows/tauri/src/features/git/utils/git-gutter-changes.test.ts` | Editor / Git | 在 Windows 打开已跟踪文件,新增、修改、删除内容并保存,点击标记查看差异、切换前后变更、按 Escape 关闭;确认多仓库同名文件互不影响。切换浅色、深色及自定义主色,检查面板边框、焦点和 Git 语义色;关闭 Git 标记设置后标记消失。macOS 检查对应行变更入口。Linux 单元测试不替代目标平台运行验证。 | | +| Git 变更 | **从编辑器行变更入口暂存单处修改**
editor-git-gutter-stage | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Editor/CodeEditorView.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git/services/git-gutter-actions.ts`、`windows/tauri/src/features/git/utils/git-gutter-changes.test.ts` | Editor / Git | 保存含多处增删改的已跟踪文件,单独暂存后一处修改,检查 index 只包含选中修改,邻近与前方未暂存修改保留;未保存或只读时禁用暂存。Windows 与 macOS 均需实机验证。 | | +| Git 变更 | **设置中开关编辑器 Git 变更标记**
editor-git-gutter-setting | 🧩 平台专属
— 不适用
`macos/Sources/Lithe/Views/Editor/CodeEditorView.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/settings/components/tabs/git-settings.tsx`、`windows/tauri/src/features/settings/config/default-settings.ts` | Editor / Git | Windows 默认显示 Git 变更标记;在 Git 设置中关闭时清除标记和预览,重新启用后恢复,重启后保留选择。此项记录 Windows 新增的独立开关。 | |
@@ -114,8 +118,8 @@ | Git | **状态、暂存与提交**
git-status-commit | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git`、`macos/Sources/Lithe/Services` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git`、`shared/contracts/application-boundary.md`、`windows/tauri/src/features/git/api/git-repository-path.ts`、`rust/lithe-core/tests/git_path_roundtrip.rs`、`shared/fixtures/git/windows-paths.json` | Git | 修改、暂存、取消暂存并提交文件,确认状态、提交消息和错误回显。 Windows 另验证原生 UNC/verbatim 输入、中文/空格/长路径仓库往返及 linked worktree;末尾点/空格必须明确拒绝,外部提交/切换须触发元数据刷新。 | | | Git | **多仓库变更折叠分组**
git-multi-repository-change-groups | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift`、`macos/Sources/Lithe/Views/Git/GitChangeSectionsCache.swift`、`macos/Sources/LitheGitModule/Models/GitModels.swift`、`rust/lithe-core/src/git/mod.rs` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git/components/status/git-status-panel.tsx`、`windows/tauri/src/features/git/components/git-commit-panel.tsx`、`windows/tauri/src/features/git/components/git-workspace-commit-review.tsx`、`windows/tauri/src/features/git/services/git-workspace-commit-workflow.ts`、`windows/tauri/src/features/git/services/git-workspace-commit-workflow.test.ts`、`windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx`、`windows/tauri/src-tauri/src/platform.rs`、`shared/fixtures/git/workspace-commit-workflow-v1.json` | Git | 在同一工作区打开多个 Git 仓库,确认变更按仓库折叠分组;只剩一个仓库有变更时仍显示仓库名。仓库级和文件级勾选与 Git 暂存区同步;子模块只有未提交文件时显示提示,不能勾选未变化的引用。 | 两端直接消费共享 Rust Core 的计划、依赖排序、执行结果与重试。平台负责真实暂存勾选、确认与结果界面、认证取消和项目生命周期。原生界面实测待平台验收。 | | Git | **分支、标签与远程**
git-branches-remotes | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git` | Git | 创建、切换、合并分支并查看标签和远程,确认冲突与认证失败可恢复。 | | -| Git | **Diff 与变更审查**
git-diff-review | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git`、`macos/Sources/Lithe/Views/Diff` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git`、`windows/tauri/src/features/viewer` | Git | 验证新增、删除、重命名、二进制和多文件 Diff 的展示与定位;从源代码管理打开已修改和未跟踪文件的工作区 Diff 后保持静止,确认 Diff 不会自动关闭,且只在文件不再出现在 Git 状态中时关闭。 | | -| Git | **提交历史与图谱**
git-history | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git`、`macos/Sources/LitheGitModule/Services/GitGraphLayoutService.swift`、`macos/Sources/LitheGitModule/Services/GitGraphHeadOrdering.swift`、`macos/Sources/Lithe/Views/Git/GitGraphColor.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git`、`windows/tauri/src/features/git/utils/git-graph-layout.ts`、`windows/tauri/src/features/git/utils/git-graph-colors.ts`、`windows/tauri/src/features/git/utils/git-graph-layout.test.ts` | Git | 分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端保留各自调色板,不要求 RGB 值一致。 | | +| Git | **Diff 与变更审查**
git-diff-review | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git`、`macos/Sources/Lithe/Views/Diff` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git`、`windows/tauri/src/features/viewer` | Git | 验证新增、删除、重命名、二进制和多文件 Diff 的展示与定位;从源代码管理打开已修改和未跟踪文件的工作区 Diff 后保持静止,确认 Diff 不会自动关闭,且只在文件不再出现在 Git 状态中时关闭;在 Windows 单文件工作区 Diff 中点击折叠条展开省略的未改动代码,确认 Diff 保持打开且每个修改块仍可单独暂存;在 Windows 未暂存单文件 Diff 中点击某个修改块的“回滚”,确认弹出确认框、取消不改文件、确认后只恢复该块且暂存区不变,最后一块回滚后文件从状态中消失时 Diff 关闭;暂存区、历史、分支比较与新增/未跟踪文件不显示回滚按钮。 | | +| Git | **提交历史与图谱**
git-history | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git`、`macos/Sources/LitheGitModule/Services/GitGraphLayoutService.swift`、`macos/Sources/LitheGitModule/Services/GitGraphHeadOrdering.swift`、`macos/Sources/Lithe/Views/Git/GitGraphColor.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git`、`windows/tauri/src/features/git/utils/git-graph-layout.ts`、`windows/tauri/src/features/git/utils/git-graph-colors.ts`、`windows/tauri/src/features/git/utils/git-graph-layout.test.ts`、`windows/tauri/src/features/git/components/log/git-commit-details.tsx`、`windows/tauri/src/features/git/components/log/git-commit-details.test.tsx`、`windows/tauri/src/features/git/hooks/use-git-diff-actions.test.tsx`、`windows/tauri/src/features/git/hooks/use-commit-file-preview.ts`、`windows/tauri/src/features/git/hooks/use-commit-file-preview.test.tsx` | Git | 分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;在 Windows Git Log 选中带多行正文的提交,确认右侧详情在标题下方完整显示正文(保留空行与缩进),复制提交信息和打开提交 Diff 时也包含正文;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端保留各自调色板,不要求 RGB 值一致。 读取提交正文期间切换仓库、重新打开另一提交或关闭面板,确认迟到结果不会打开旧 Diff 或覆盖新请求的加载状态。 Windows 另验证:单击提交后编辑区预览该提交第一个文件的 diff;单击或双击 Commit files 中的文件只显示该文件的 diff(不含提交说明,也不显示文件列表),并复用同一个预览标签;Git Log 刚打开、尚未点击提交时不会自动打开 diff;日期列对与本机同一时区的提交显示相对时间(刚刚、N 分钟前、今天/昨天 + 12 小时制,中文为上午/下午),其他时区或未知时区的提交只显示日期,鼠标移入行时刷新相对时间;作者/日期列可在行内列边缘拖动调整宽度并在重启后保留。 非连续多选提交修改同一路径时,预览保留每个提交的 diff 区段及提交标识;延迟请求返回前取消选择、隐藏 Git Log、切换仓库或卸载工具窗,确认旧请求不会重新打开或激活预览标签。 | | | Git | **Rebase 与 Stash**
git-rebase-stash | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git` | Git | 执行交互式 Rebase 和 Stash 保存/恢复,确认中断、冲突和继续操作。 | | | Git | **Worktree 管理**
git-worktrees | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git` | Git | 列出、创建、切换和删除 Worktree,确认路径、分支和安全检查。 | | | Git | **多仓库引用面板:分组、配色与引用操作**
git-multi-repository-references | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git` | Git | 在多仓库工作区打开 Git Log:确认按仓库分组、仓库配色、非活动仓库分组只读,点其它仓库的引用会切换活动仓库且只加载一次,Pull 弹窗可选择远程分支与策略。 | | @@ -140,7 +144,7 @@ | 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | | --- | --- | --- | --- | --- | --- | --- | -| Java/Maven | **JDK 与 Maven 工具链发现**
java-runtime-discovery | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Services/Java`、`macos/Sources/Lithe/Views/Run` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/maven`、`windows/tauri/src/features/workspace` | Java / Maven | 配置多个 JDK/Maven 候选,验证发现、选择、版本不匹配和错误提示。 | | +| Java/Maven | **JDK 与 Maven 工具链发现及按项目要求自动选择 JDK**
java-runtime-discovery | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Services/Java`、`macos/Sources/Lithe/Views/Run`、`macos/Sources/Lithe/Core/Ports/RuntimeLocator.swift`、`macos/Tests/LitheTests/MavenRuntimeTests.swift`、`rust/lithe-core/src/execution/java_selection.rs` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/maven`、`windows/tauri/src/features/workspace`、`windows/tauri/src-tauri/src/run.rs`、`shared/fixtures/run-configuration/automatic-java-selection.json` | Java / Maven | 两端安装 JDK 8/17/21:项目要求 17 且 JAVA_HOME 为 8 时验证自动选择兼容版本;核对设置、Run、Maven 目标、测试使用同一 JDK。验证显式选择不变、没有兼容版本时回退并提示,以及无要求时的平台默认顺序。 | | | Java/Maven | **按项目配置 Maven 工具链、settings.xml 与本地仓库**
java-maven-project-settings | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift`、`macos/Sources/Lithe/Services/Java/ProjectRuntimeService.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/settings/components/tabs/maven-settings-panel.tsx`、`windows/tauri/src/features/maven/stores/maven.store.ts`、`windows/tauri/src-tauri/src/maven.rs`、`windows/tauri/src-tauri/src/maven/settings_xml.rs`、`windows/tauri/src/features/maven/components/maven-detected-value.test.tsx`、`windows/tauri/src/features/settings/components/tabs/maven-settings-panel.test.tsx`、`windows/tauri/src/features/run/stores/run.store.ts`、`windows/tauri/src/features/run/stores/run-maven-context.test.ts` | Java / Maven | 在 macOS 项目设置和 Windows Maven 设置页分别切换两个项目,配置 Maven、JDK、settings.xml 与本地仓库,验证保存重开、自动检测展示及项目间隔离;Windows 还需验证写盘失败后可直接重试。 Windows 自定义 settings.xml 后,仓库留空应显示该文件中的目录;验证 XML 实体和 CDATA、继承的项目 JDK/Maven、切换项目丢弃未保存草稿;冷启动 Wrapper 不应触发下载,版本探测卡住时设置窗口保持响应并在超时后清理探测进程;Run 配置显式选择的 Maven/JDK 优先于项目设置,未设置的字段分别继承 Maven 设置;确认主任务与前置任务使用相同工具链,Maven 设置上下文缺失时仍保留显式覆盖,均未配置时交由平台自动发现。 | | | Java/Maven | **项目根、模块与源码集识别**
java-maven-project | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Services/Java`、`shared/contracts/application-boundary.md` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/maven`、`windows/tauri/src/features/workspace` | Java / Maven | 使用单模块、多模块和非标准源码目录 fixture 对比项目模型。 | | | Java/Maven | **Profile 与依赖树**
java-maven-profiles-dependencies | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Run`、`macos/Sources/Lithe/Services/Java`、`macos/Sources/LitheExecutionModule/Services/MavenService.swift`、`macos/Sources/Lithe/Platform/MacOS/Persistence/MacMavenDependencyOutputStore.swift`、`rust/lithe-core/src/project/maven_dependency_tree.rs` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/maven`、`windows/tauri/src-tauri/src/maven.rs`、`rust/lithe-core/src/project/maven_dependency_tree.rs` | Java / Maven | 切换 Maven profile 并刷新依赖树,确认模块归属、顺序、版本冲突/重复/依赖管理标注和错误边界;在依赖节点超过 6,000 个的项目中确认依赖树完整加载。 Windows 在 Maven 面板展开「依赖项」应直接加载依赖树,失败时显示宿主返回的真实原因,而不是通用的“无法加载 Maven 依赖”(#970)。 | | @@ -166,7 +170,7 @@ | 运行配置 | **入口点与运行配置发现**
run-discovery | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Run`、`shared/fixtures/run-configuration` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/run`、`shared/fixtures/run-configuration` | Run | 验证 Spring Boot、Java、Maven、Gradle、npm、Cargo、Go、Python 和 Docker Compose 入口识别;多模块项目把服务工作目录改成模块目录后,服务仍留在列表中并以该目录启动;填写不存在的目录或 ${workspaceFolder} 等变量时,保存被拒绝并提示原因;在 Windows 确认重新扫描服务使用双箭头刷新图标。 | | | 运行配置 | **保存前同步与工具链解析**
run-save-toolchain | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Run`、`macos/Sources/Lithe/Services/Java` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/run`、`windows/tauri/src/features/maven` | Run | 修改未保存文件后运行,验证同步、JDK/Maven 选择和版本不匹配诊断。 | | | 运行配置 | **Java main 与测试运行**
run-java-test | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Run`、`shared/fixtures/debug` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/run`、`shared/fixtures/debug`、`windows/tauri/src/features/run/components/run-pane.tsx`、`windows/tauri/src/features/run/components/run-output-text.tsx` | Run | 运行 main、单测试和测试类,验证参数、输出、失败状态和终端策略。 Windows Run 运行输出使用 Geist Mono 显示 ====、==>、=>、!=、>=,确认禁用连字后逐字符显示且复制文本与原始输出一致;检查有输出与空态、ANSI 颜色、自动换行切换及横向滚动。 | | -| 运行配置 | **超长 Java 类路径自动缩短**
run-java-long-classpath | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacJavaLaunchArgumentPreparer.swift`、`macos/Sources/LitheExecutionModule/Services/RunService.swift`、`rust/lithe-core/src/execution/launch_command.rs` | ✅ 已实现
🔍 待验证
`windows/tauri/src-tauri/src/run/launch_arguments.rs`、`rust/lithe-core/src/execution/launch_command.rs` | Run | 使用包含大量依赖的 Java 项目运行 main,确认超长类路径自动写入参数文件、进程可启动且参数文件在退出后清理。 | | +| 运行配置 | **超长 Java 类路径自动缩短**
run-java-long-classpath | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacJavaLaunchArgumentPreparer.swift`、`macos/Sources/LitheExecutionModule/Services/RunService.swift`、`rust/lithe-core/src/execution/launch_command.rs` | ✅ 已实现
🔍 待验证
`windows/tauri/src-tauri/src/run/launch_arguments.rs`、`rust/lithe-core/src/execution/launch_command.rs`、`rust/lithe-core/src/execution/classpath_jar.rs`、`windows/tauri/src-tauri/src/run.rs` | Run | 使用包含大量依赖的 Java 项目运行 main,确认超长类路径自动写入参数文件、进程可启动且参数文件在退出后清理。 Windows 上分别用 JDK 8 和 JDK 17 运行同一个依赖很多、路径含空格和中文的项目 main:JDK 8 应在临时目录生成只含 META-INF/MANIFEST.MF 的 .classpath.jar 并以 -cp 引用,JDK 17 仍使用 @argfile;两者都能启动,进程退出或启动失败后临时文件被删除,安装目录与 JDK 目录不新增文件。 在慢速网络盘 classpath 准备期间确认工作台可交互;停止或重新运行后,旧准备结果不得启动进程,旧 executionId 的停止请求不得影响新执行。 | | | 调试器 | **启动调试与断点**
debug-breakpoints | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Debug`、`shared/fixtures/debug` | 🟡 部分实现
🔍 待验证
`windows/tauri/src/features/debugger`、`shared/fixtures/debug` | Debug | 设置、命中、禁用和重新定位断点,确认调试会话生命周期。 | Windows 真实调试产品链路仍在 #466 跟进。 | | 调试器 | **变量、异常与断开策略**
debug-state | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Debug`、`shared/fixtures/debug` | 🟡 部分实现
🔍 待验证
`windows/tauri/src/features/debugger`、`shared/fixtures/debug` | Debug | 验证变量分页、异常信息、step filters、暂停/继续和 disconnect policy。 | Windows 真实调试产品链路仍在 #466 跟进。 | | 项目运行 | **Docker / Compose 项目识别与运行**
docker-compose | 🟡 部分实现
🔍 待验证
`macos/Sources/Lithe/Views/Run/RunConfigurationIcon.swift`、`macos/Sources/Lithe/Services/Java` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/docker`、`windows/tauri/src/features/run` | Run / Docker | 使用 Dockerfile 和 Compose fixture 验证识别、命令参数、输出和进程停止;确认 macOS 是否只有入口识别。 | | @@ -174,18 +178,19 @@
-工作台 · 12 个能力点 +工作台 · 13 个能力点 | 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | | --- | --- | --- | --- | --- | --- | --- | | 终端 | **Shell 发现与配置**
terminal-shell | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Terminal`、`macos/Sources/Lithe/Views/App/SettingsView.swift`、`macos/Sources/Lithe/Services` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/terminal`、`windows/tauri/src/features/settings/components/macos-settings-panels.tsx`、`windows/tauri/src/features/settings/lib/default-shell-options.ts`、`windows/tauri/src-tauri` | Terminal | 验证设置页默认 Shell 选项与终端「新建终端」菜单检测到的 Shell 一致、可选 Git Bash 并生效,以及环境变量、工作目录和不可用 Shell 的提示。 | | -| 终端 | **多会话与标签**
terminal-sessions | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Terminal` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/terminal` | Terminal | 创建多个终端会话并切换、重命名、关闭,确认子进程生命周期和资源清理;Windows 横向标签栏的新建终端与 Shell 配置按钮紧跟最后一个标签,标签溢出时按钮保持可见;标签栏右侧最小化按钮隐藏终端面板且会话继续运行,重新打开时恢复为停靠状态。 | | +| 终端 | **多会话与标签**
terminal-sessions | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Terminal` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/terminal` | Terminal | 创建多个终端会话并切换、重命名、关闭,确认子进程生命周期和资源清理;Windows 横向标签栏的新建终端与 Shell 配置按钮紧跟最后一个标签,标签溢出时按钮保持可见;标签栏右侧最小化按钮隐藏终端面板且会话继续运行,重新打开时恢复为停靠状态;Windows 打开底部终端面板时默认通栏(与 IDEA 一致):面板位于左右两条活动栏之间并横跨侧边栏与编辑器下方(活动栏保持通高,底部的活动栏按钮位置不上移),侧边栏让出纵向空间,且仍可通过面板菜单切换为编辑器宽度。 | | | 终端 | **复制粘贴、搜索与调整大小**
terminal-interaction | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Terminal` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/terminal` | Terminal | 验证复制粘贴、终端搜索、调整面板大小和高频输入不丢失。 | | | 终端 | **Windows Shell 超长命令输入**
terminal-long-command-input | 🧩 平台专属
— 不适用
`macos/Sources/Lithe/Views/Terminal` | ✅ 已实现
🔍 待验证
`windows/tauri/crates/terminal/src/connection.rs`、`windows/tauri/src-tauri/src/terminal.rs` | Terminal | 在 Windows cmd.exe 和 PowerShell 中执行超过 Shell 单行限制的 IDE 多行命令,确认脚本执行、原始命令内容和终端关闭后的临时文件清理。 | 仅在 IDE 后端为 cmd.exe/PowerShell 的超长多行文本创建一次性脚本;不修改系统 Shell 配置。 | | 本地历史 | **快照列表与 Diff**
history-snapshots | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/History`、`macos/Sources/Lithe/Services` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/local-history` | Local History | 编辑同一文件多次,比较快照时间、内容 Diff 和文件范围。 | | | 本地历史 | **恢复、删除与持久化**
history-restore | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/History` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/local-history` | Local History | 验证恢复、删除、应用重启后持久化和失败回滚。 | | | 命令与布局 | **命令面板与全局动作**
command-palette | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift`、`macos/Sources/Lithe/Views/Search/SearchEverywhereView.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/command-palette` | Workbench | 搜索并执行打开面板、运行、Git 和设置命令,确认快捷键和不可用命令状态。 | | | 命令与布局 | **紧凑菜单栏可在标题栏左侧原位展开完整水平菜单,并在点击外部或按 Escape 后收起,不遮挡工作区**
workbench-compact-menu-bar | 🧩 平台专属
— 不适用
`macos/Sources/Lithe/LitheApp.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/window/components/title-bar/title-bar.tsx`、`windows/tauri/src/features/window/components/title-bar/title-project-menu.tsx`、`windows/tauri/src/features/window/components/window-menu-bar.tsx`、`windows/tauri/src/features/window/hooks/use-compact-menu-bar-dismissal.ts`、`windows/tauri/src/features/window/hooks/use-compact-menu-bar-dismissal.test.tsx` | Workbench | 在 Windows 工作台点击标题栏左端菜单按钮,确认汉堡按钮由“文件”菜单项取代,“文件”项处于选中状态且其下拉菜单立即展开;菜单与标题栏同高,不覆盖编辑区域,项目/分支控件在展开时隐藏。检查常驻品牌标识与下方活动栏图标列对齐且不可选中,并确认项目菜单显示当前项目图标(无自定义图标时显示项目缩写)。点击其他菜单项时菜单条保持展开并执行动作;点击工作区、标题栏空白和右侧控件或按 Escape 后收起;按 Escape 后确认焦点返回菜单入口;窄窗口确认菜单在标题栏内水平滚动且不挤压窗口控件。macOS 保持系统菜单栏行为。 | | +| 命令与布局 | **从工作台 chrome 打开设置:Windows 点击标题栏搜索按钮右侧的齿轮,macOS 通过应用菜单及面板内入口**
workbench-settings-entry | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Models/LitheAction.swift`、`macos/Sources/Lithe/Views/App/SettingsView.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/window/components/title-bar/title-bar.tsx`、`windows/tauri/src/features/layout/components/sidebar/sidebar-pane-selector.tsx`、`windows/tauri/src/features/layout/config/item-order.ts` | Workbench | 在 Windows 打开工作区,确认左侧活动栏底部不再显示设置齿轮,活动栏右键“在活动栏中显示”列表不包含设置项;点击标题栏右上搜索按钮右侧的齿轮打开设置对话框并再次关闭;携带旧版设置文件(活动栏排序或隐藏列表含 settings)启动后确认遗留条目被清理且活动栏显示正常。macOS 通过应用菜单和各面板入口打开设置保持不变。 | | | 窗口管理 | **标题栏控制窗口最小化、最大化/还原与关闭;命令面板可最小化、最大化及切换全屏**
window-controls | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/LitheApp.swift`、`macos/Sources/Lithe/Views/App/RootView.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/window/components/title-bar/window-controls.tsx`、`windows/tauri/src/features/command-palette/constants/window-actions.tsx`、`windows/tauri/src/features/window/utils/window-actions.ts` | Workbench | 在 macOS 检查系统标题栏控制及全屏入口;在 Windows 验证标题栏最小化、最大化/还原和关闭,以及命令面板的窗口操作,确认图标语义、最大化/还原状态与按钮标签一致,并确认悬停关闭按钮背景贴合窗口右边缘。 | | | 命令与布局 | **分栏、面板与工具窗布局**
workbench-panes | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Workbench`、`macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift`、`macos/Sources/Lithe/Views/Components/LitheSplitPaneView.swift`、`macos/Sources/Lithe/Services/Workbench/WorkbenchLayoutStore.swift`、`macos/Sources/Lithe/Views/App/RootView.swift`、`macos/Sources/Lithe/LitheApp.swift`、`macos/Sources/Lithe/Theme/LitheTheme.swift`、`macos/Resources/IDEAIcons/expui`、`macos/Resources/IDEAIcons/terminal` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/panes`、`windows/tauri/src/features/layout` | Workbench | 打开、关闭、移动和调整面板,验证布局持久化及高频拖动稳定性;在 macOS 启用背景图后检查项目栏、编辑器、底部及右侧工具窗之间是否仍有清晰的分割区间;将 macOS 左侧工具窗缩至 30pt 并确认目录树始终贴左、不随宽度向右移动、内容不溢出、右上和右下圆角仍可见,关闭并重新打开项目后仍保持窄侧栏及其他布局尺寸,重新展开后布局正常;在 Maven 项目产生构建输出时确认左侧模块按钮可滚动、设置按钮始终完整显示;在亮色主题且有未读通知时确认未打开的铃铛图标清晰可见、打开通知面板后白色图标与蓝色背景成对出现;在深色 Lithe 主题下对照外框渐变、面板对比度、背景图片回退、左右工具窗的 40pt 按钮槽、30pt 选中块及 IDEA Community 图标对比度,悬停工具窗按钮时确认箭头光标,检查项目栏标题颜色和弹窗主按钮主题色;检查 macOS 40pt 顶栏 Logo 与系统窗口按钮垂直居中,项目切换器使用 20pt 项目字母头像和 30pt 悬停块,项目与分支切换块悬停保持箭头光标,红绿灯右侧留白与 IDEA 接近,项目头像、欢迎列表及切换菜单按标准化项目路径共享稳定颜色(超过 9 个项目、移除后重开仍一致),顶栏使用 IDEA Islands 主题项目色并按 85% 混色、600pt 水平和 300pt 垂直范围渐变,深色窗口分隔条四角与底栏缺口无浅色亮斑,缩放窗口及从欢迎页进入工作区再返回后仍对齐。 | | | 工作台布局 | **活动栏图标的稳定悬停说明**
workbench-activity-tooltips | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Workbench/WorkbenchHoverTooltip.swift`、`macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift`、`macos/Tests/LitheTests/WorkbenchHoverTooltipTests.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/layout/components/sidebar/sidebar-pane-selector.tsx`、`windows/tauri/src/features/layout/components/footer/footer-tab-control.tsx`、`windows/tauri/src/features/layout/components/plugin-activity-rail.tsx` | Workbench | 逐一悬停左上导航、左下工具入口及右侧通知/插件/Maven,快速切换图标并移出活动栏;确认始终只显示当前图标说明,面板更新和滚动后不残留旧提示,禁用入口可显示说明,短文字自适应且提示不超出窗口边缘。 | | diff --git a/frontend/editor/src/diff-review.ts b/frontend/editor/src/diff-review.ts index 24d39249d..9ff1b198a 100644 --- a/frontend/editor/src/diff-review.ts +++ b/frontend/editor/src/diff-review.ts @@ -10,6 +10,9 @@ export interface ReviewRow { right: string | null; kind: "context" | "changed" | "addition" | "removal" | "information"; hunkID?: string | null; + /** Renders the hunk's action band above this row. Full-file reviews have no + * `@@` information rows, so the host marks each hunk's first change instead. */ + actionAnchor?: boolean; } export interface ReviewAction { id: string; title: string } export interface DiffReviewInput { @@ -136,8 +139,14 @@ export function mountDiffReview(container: HTMLElement, leftRows = old.rows; rightRows = next.rows; if (original.getLanguageId() !== input.language) monaco.editor.setModelLanguage(original, input.language); if (modified.getLanguageId() !== input.language) monaco.editor.setModelLanguage(modified, input.language); + const replaced = original.getValue() !== old.text || modified.getValue() !== next.text; if (original.getValue() !== old.text) original.setValue(old.text); if (modified.getValue() !== next.text) modified.setValue(next.text); + // Monaco carries fold state across content changes: regions that did not + // exist before, such as every region of the first real text after the + // empty mount, open fully revealed. Re-attaching the models starts a + // fresh diff view model whose unchanged regions fold, like a new review. + if (replaced) { editor.setModel(null); editor.setModel({ original, modified }); } left.updateOptions({ lineNumbers: line => String(leftRows[line - 1]?.oldLine ?? "") }); right.updateOptions({ lineNumbers: line => String(rightRows[line - 1]?.newLine ?? "") }); editor.updateOptions({ renderSideBySide: input.sideBySide ?? true, @@ -146,11 +155,19 @@ export function mountDiffReview(container: HTMLElement, container.classList.toggle("no-word-highlights", input.highlightWords === false); const actions = input.actions ?? []; if (actions.length) { - for (const [view, rows] of [[left, leftRows], [right, rightRows]] as const) { + for (const [view, side] of [[left, "left"], [right, "right"]] as const) { const ids: string[] = []; view.changeViewZones(accessor => { - rows.forEach((row, index) => { - if (row.kind !== "information" || !row.hunkID) return; + let projected = 0; + for (const row of input.rows) { + const visible = row[side] !== null; + // An `@@` row owns the band below it. An anchor row may be absent + // from this side (an addition on the left), so its band goes after + // the rows this side has already shown, keeping both sides level. + const afterLineNumber = row.hunkID && row.actionAnchor ? projected + : row.hunkID && row.kind === "information" && visible ? projected + 1 : null; + if (visible) projected++; + if (afterLineNumber === null) continue; const node = document.createElement("div"); node.className = "lithe-review-actions"; if (view === right) for (const action of actions) { @@ -159,8 +176,8 @@ export function mountDiffReview(container: HTMLElement, button.onclick = () => { if (!disposed && request === version) onAction(row.hunkID!, action.id); }; node.append(button); } - ids.push(accessor.addZone({ afterLineNumber: index + 1, heightInPx: 26, domNode: node })); - }); + ids.push(accessor.addZone({ afterLineNumber, heightInPx: 26, domNode: node })); + } }); zones.push({ view, ids }); } diff --git a/macos/EditorFrontend/ime-input.css b/macos/EditorFrontend/ime-input.css new file mode 100644 index 000000000..76546fadf --- /dev/null +++ b/macos/EditorFrontend/ime-input.css @@ -0,0 +1,10 @@ +/* Monaco draws the composing line in a textarea over the rendered document. + Its default IME background uses our translucent current-line highlight alone, + which lets the old glyphs show through. Composite the highlight over the editor + surface; the opaque widget surface also covers glyphs in wallpaper mode. */ +.monaco-editor .inputarea.ime-input { + background-color: var(--vscode-editorWidget-background) !important; + background-image: + linear-gradient(var(--vscode-editor-lineHighlightBackground, transparent), var(--vscode-editor-lineHighlightBackground, transparent)), + linear-gradient(var(--vscode-editor-background), var(--vscode-editor-background)); +} diff --git a/macos/EditorFrontend/main.ts b/macos/EditorFrontend/main.ts index 1b0dcbafa..34877fe04 100644 --- a/macos/EditorFrontend/main.ts +++ b/macos/EditorFrontend/main.ts @@ -10,6 +10,7 @@ import { mountWorkbench } from "@lithe/editor/workbench"; import { KeyCode, KeyMod } from "monaco-editor/esm/vs/editor/editor.api.js"; import palette from "../Sources/Lithe/Resources/SyntaxHighlighting/color-mappings.json"; import { installWebKitMouseInput } from "./mouse-input"; +import "./ime-input.css"; declare global { interface Window { webkit: any; MonacoEnvironment: any; lithe: any; } } diff --git a/macos/Experiments/Monaco/ime-input.integration.ts b/macos/Experiments/Monaco/ime-input.integration.ts new file mode 100644 index 000000000..1cc2817ba --- /dev/null +++ b/macos/Experiments/Monaco/ime-input.integration.ts @@ -0,0 +1,55 @@ +import { editor as monacoEditor } from "monaco-editor/esm/vs/editor/editor.api.js"; + +const assert = (condition: unknown, message: string) => { if (!condition) throw new Error(message); }; + +// These cases exercise Monaco's real composition presentation. Native candidate +// selection and WebKit repaint timing still require the macOS input-method run. +// Load styles through the production workbench entrypoint, not this test module, +// so a missing production CSS import cannot be masked by the regression test. +export const imeInputCases = [false, true].flatMap(dark => [false, true].map(wallpaper => ({ + name: `IME overlay covers old glyphs in ${dark ? "dark" : "light"} ${wallpaper ? "wallpaper" : "solid"} theme`, + run() { + const container = document.createElement("div"); + container.style.cssText = "position:absolute;inset:0;width:800px;height:300px"; + document.body.append(container); + const theme = `ime-${dark}-${wallpaper}`; + monacoEditor.defineTheme(theme, { + base: dark ? "vs-dark" : "vs", inherit: true, rules: [], + colors: { + "editor.background": wallpaper ? "#00000000" : dark ? "#202020" : "#ffffff", + "editor.lineHighlightBackground": dark ? "#ffffff09" : "#00000009", + }, + }); + const model = monacoEditor.createModel("已有中文 abc", "plaintext"); + const view = monacoEditor.create(container, { model, theme, editContext: false }); + try { + view.setPosition({ lineNumber: 1, column: 3 }); + view.focus(); + view.render(true); + const input = container.querySelector("textarea.inputarea")!; + assert(input, "Monaco did not create its textarea input"); + const idleBackground = getComputedStyle(input).backgroundColor; + for (const candidate of ["zhong", "zhon", "中"]) { + input.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true, data: "" })); + input.dispatchEvent(new CompositionEvent("compositionupdate", { bubbles: true, data: candidate })); + assert(input.classList.contains("ime-input"), "composition did not show the input overlay"); + const style = getComputedStyle(input); + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d")!; + context.fillStyle = style.backgroundColor; + context.fillRect(0, 0, 1, 1); + assert(context.getImageData(0, 0, 1, 1).data[3] === 255, + `composition background exposes old line glyphs: ${style.backgroundColor}`); + assert(style.backgroundImage.includes("linear-gradient"), "composition lost the theme surface layers"); + input.dispatchEvent(new CompositionEvent("compositionend", { bubbles: true, data: candidate })); + assert(!input.classList.contains("ime-input"), "composition overlay remained after commit"); + assert(getComputedStyle(input).backgroundColor === idleBackground, "IME surface leaked into ordinary input"); + } + } finally { + view.dispose(); + model.dispose(); + container.remove(); + monacoEditor.setTheme("lithe-dark"); + } + }, +}))); diff --git a/macos/Experiments/Monaco/workbench.integration.ts b/macos/Experiments/Monaco/workbench.integration.ts index c56e14927..5f82ffd40 100644 --- a/macos/Experiments/Monaco/workbench.integration.ts +++ b/macos/Experiments/Monaco/workbench.integration.ts @@ -13,6 +13,7 @@ import { ready } from "./workbench"; import { acquireEditorModelSource, sourcePositionAt } from "@lithe/editor/model-source"; import { editor as monacoEditor, languages, Range, Selection, Uri } from "monaco-editor/esm/vs/editor/editor.api.js"; import { mouseInputCases } from "./mouse-input.integration"; +import { imeInputCases } from "./ime-input.integration"; // Real WebKit integration, using the exact workbench bundle and the existing // bounded native probe host. No DOM-based imitation of Monaco input. @@ -33,6 +34,7 @@ async function verify() { cases.push({ name, durationMs: performance.now() - started }); } for (const test of mouseInputCases) await check(test.name, async () => test.run()); + for (const test of imeInputCases) await check(test.name, async () => test.run()); await check("diff projections preserve sparse source lines and release read-only models", async () => { const before = monacoEditor.getModels().length; const container = document.createElement("div"); @@ -115,6 +117,45 @@ async function verify() { subscription?.dispose(); review.dispose(); container.remove(); } }); + // #557: the first text after the empty mount used to open with every + // unchanged region revealed, so a full-file review showed nothing to expand. + await check("first full-file diff folds unchanged regions and anchors hunk actions", async () => { + const container = document.createElement("div"); + container.style.cssText = "position:absolute;inset:0;height:400px;width:1000px"; + document.body.append(container); + const review = mountDiffReview(container); + const rows: ReviewRow[] = Array.from({ length: 120 }, (_, index) => ({ + id: `full-${index}`, oldLine: index + 1, newLine: index + 1, + left: `value ${index}`, right: index === 4 ? "changed" : `value ${index}`, + kind: index === 4 ? "changed" : "context", hunkID: index < 8 ? "hunk-4" : null, + ...(index === 4 ? { actionAnchor: true } : {}), + })); + let subscription: { dispose(): void } | undefined; + let deadline: ReturnType | undefined; + try { + const computed = new Promise((resolve, reject) => { + deadline = setTimeout(() => reject(new Error("Full-file diff computation exceeded 5 seconds")), 5000); + subscription = review.editor.onDidUpdateDiff(() => { + if (review.editor.getLineChanges()?.length) resolve(); + }); + }); + await Promise.all([review.update({ rows, language: "plaintext", collapse: true, + actions: [{ id: "stage", title: "Stage" }] }), computed]); + const next = review.editor.getModifiedEditor(); + // Content height, unlike the viewport, shrinks only when lines are folded. + const lineHeight = next.getOption(monacoEditor.EditorOption.lineHeight); + assert(next.getContentHeight() < lineHeight * 40, + "unchanged region opened revealed instead of folded"); + assert(container.querySelectorAll(".lithe-review-actions button").length === 1, + "full-file hunk did not render exactly one action band"); + review.select({ revealID: "full-60", searchIDs: ["full-60"] }); + assert(next.getVisibleRanges().some(range => range.startLineNumber <= 61 && range.endLineNumber >= 61), + "folded source row could not be revealed"); + } finally { + if (deadline !== undefined) clearTimeout(deadline); + subscription?.dispose(); review.dispose(); container.remove(); + } + }); await check("inline diff navigation scrolls to a removed source row", async () => { const container = document.createElement("div"); container.style.cssText = "position:absolute;inset:0;height:400px;width:1000px"; diff --git a/macos/Sources/Lithe/Application/Features/IdeCapabilitiesFeatureModel.swift b/macos/Sources/Lithe/Application/Features/IdeCapabilitiesFeatureModel.swift index 8ae06db03..36855a96b 100644 --- a/macos/Sources/Lithe/Application/Features/IdeCapabilitiesFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/IdeCapabilitiesFeatureModel.swift @@ -293,6 +293,9 @@ final class IdeCapabilitiesFeatureModel: ObservableObject { case .found(let url, let source): return ["status": "resolved", "path": url.path, "source": choiceSource(source)] case .invalid(let path): return ["status": "invalid", "message": "Invalid toolchain: " + path] case .fallback(let path, let next): var result = choice(next); result["invalidOverride"] = path; return result + case .warning(let url, let source, let message): + return ["status": "resolved", "path": url.path, "source": choiceSource(source), "warning": message] + case .unavailable(let message): return ["status": "invalid", "message": message] case .notFound: return ["status": "notFound"] case nil: return ["status": "loading"] } diff --git a/macos/Sources/Lithe/Core/Ports/RuntimeLocator.swift b/macos/Sources/Lithe/Core/Ports/RuntimeLocator.swift index 0084f45a2..cf4cb07e6 100644 --- a/macos/Sources/Lithe/Core/Ports/RuntimeLocator.swift +++ b/macos/Sources/Lithe/Core/Ports/RuntimeLocator.swift @@ -68,7 +68,11 @@ struct DefaultRuntimeToolDiscovery: RuntimeToolDiscovery { protocol RuntimeLocator: Sendable { func environment() -> [String: String] func discover() -> RuntimeDiscoveryResult + /// Java-only probing for launch paths; never runs Maven or other tools. + func discoverJavaRuntimes() -> [JavaRuntimeCandidate] func validJavaHome(path: String) -> URL? + /// Returns the candidate reached by PATH, resolving platform symlinks. + func javaHomeOnPath(in candidates: [JavaRuntimeCandidate]) -> URL? func javaRuntime(at homeURL: URL) -> JavaRuntimeCandidate? func isExecutable(at url: URL) -> Bool func systemMavenExecutable() -> URL? @@ -81,7 +85,26 @@ protocol RuntimeLocator: Sendable { } extension RuntimeLocator { + func discoverJavaRuntimes() -> [JavaRuntimeCandidate] { discover().javaRuntimes } + func javaHomeOnPath(in candidates: [JavaRuntimeCandidate]) -> URL? { nil } // Default implementation for test stubs and non-macOS locators that do // not ship a bundled JDK. func bundledJdkHome() -> URL? { nil } } + +/// Shared selection policy; platform locators still own executable validation +/// and version probing. Requirements are read through Core's existing document. +protocol JavaRuntimeSelecting: Sendable { + func selectJavaRuntime(at root: URL?, candidates: [AutomaticJavaCandidate], fallbackID: String?) -> Result +} + +struct AutomaticJavaCandidate: Codable, Sendable { + let id: String + let version: String + let priority: UInt32 +} + +struct AutomaticJavaSelection: Decodable, Sendable { + let id: String? + let warning: String? +} diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index 7d14671b0..455237d5e 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -2615,6 +2615,19 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { ) } + func selectJavaRuntime( + at root: URL?, candidates: [AutomaticJavaCandidate], fallbackID: String? + ) -> Result { + struct Payload: Encodable { + let root: String? + let candidates: [AutomaticJavaCandidate] + let fallbackId: String? + } + return executeResult(command: "runConfig.selectJava", payload: Payload( + root: root?.standardizedFileURL.path, candidates: candidates, fallbackId: fallbackID + )) + } + func inspectRunConfiguration( at rootURL: URL, checkFingerprint: Bool? = nil, @@ -4104,3 +4117,5 @@ private extension RustCoreBridge.WorkspaceNodePayload { ) } } + +extension RustCoreBridge: JavaRuntimeSelecting {} diff --git a/macos/Sources/Lithe/Models/Runtime/ProjectRuntimeModels.swift b/macos/Sources/Lithe/Models/Runtime/ProjectRuntimeModels.swift index 807714cb8..ddc23a34e 100644 --- a/macos/Sources/Lithe/Models/Runtime/ProjectRuntimeModels.swift +++ b/macos/Sources/Lithe/Models/Runtime/ProjectRuntimeModels.swift @@ -230,6 +230,8 @@ enum RuntimeChoiceSource: Equatable, Sendable { /// Outcome of one runtime selection chain in `ProjectRuntimeService`. indirect enum RuntimeChoice: Equatable, Sendable { case found(URL, RuntimeChoiceSource) + case warning(URL, RuntimeChoiceSource, String) + case unavailable(String) /// A configured path that is not a usable runtime; a launch fails on it. case invalid(String) /// A configured path that is not usable, replaced by `to`. @@ -239,15 +241,16 @@ indirect enum RuntimeChoice: Equatable, Sendable { var url: URL? { switch self { - case .found(let url, _): url + case .found(let url, _), .warning(let url, _, _): url case .fallback(_, let choice): choice.url - case .invalid, .notFound: nil + case .invalid, .notFound, .unavailable: nil } } /// The same choice presented as inherited from the project JDK. var inheritedAsProjectJDK: RuntimeChoice { if case .found(let url, _) = self { return .found(url, .projectJDK) } + if case .warning(let url, _, let message) = self { return .warning(url, .projectJDK, message) } return self } } diff --git a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeDiscovery.swift b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeDiscovery.swift index ab2b1ef77..ce19e387a 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeDiscovery.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeDiscovery.swift @@ -2,11 +2,7 @@ import Foundation enum MacRuntimeDiscovery { static func discover(environment: [String: String]) -> RuntimeDiscoveryResult { - let javaRuntimes = discoverJavaHomes(environment: environment) - .compactMap(probeJavaHome) - .sorted { lhs, rhs in - lhs.version.localizedStandardCompare(rhs.version) == .orderedDescending - } + let javaRuntimes = discoverJavaRuntimes(environment: environment) let mavenRuntimes = discoverMavenExecutables(environment: environment) .compactMap(probeMaven) .sorted { lhs, rhs in @@ -47,6 +43,25 @@ enum MacRuntimeDiscovery { : nil } + static func discoverJavaRuntimes(environment: [String: String]) -> [JavaRuntimeCandidate] { + discoverJavaHomes(environment: environment).compactMap(probeJavaHome).sorted { lhs, rhs in + lhs.version.localizedStandardCompare(rhs.version) == .orderedDescending + } + } + + static func javaHomeOnPath(environment: [String: String]) -> URL? { + for directory in (environment["PATH"] ?? "").split(separator: ":") { + let executable = URL(fileURLWithPath: String(directory), isDirectory: true).appendingPathComponent("java") + guard FileManager.default.isExecutableFile(atPath: executable.path) else { continue } + let resolved = executable.resolvingSymlinksInPath() + // Apple's system launcher selects a JDK itself; /usr is not a JDK + // home and must never become JAVA_HOME for child processes. + guard resolved.path != "/usr/bin/java" else { return nil } + return validJavaHome(resolved.deletingLastPathComponent().deletingLastPathComponent().path) + } + return nil + } + private static func discoverJavaHomes(environment: [String: String]) -> [URL] { discoverJavaHomes( environment: environment, diff --git a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeLocator.swift b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeLocator.swift index db57d9166..666ae93d9 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeLocator.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeLocator.swift @@ -44,10 +44,22 @@ struct MacRuntimeLocator: RuntimeLocator { MacRuntimeDiscovery.discover(environment: environment()) } + func discoverJavaRuntimes() -> [JavaRuntimeCandidate] { + MacRuntimeDiscovery.discoverJavaRuntimes(environment: environment()) + } + func validJavaHome(path: String) -> URL? { MacRuntimeDiscovery.validJavaHome(path) } + func javaHomeOnPath(in candidates: [JavaRuntimeCandidate]) -> URL? { + guard let home = MacRuntimeDiscovery.javaHomeOnPath(environment: environment()) else { return nil } + return candidates.lazy.compactMap { candidate -> URL? in + let url = URL(fileURLWithPath: candidate.homePath, isDirectory: true) + return url.resolvingSymlinksInPath().path == home.resolvingSymlinksInPath().path ? url : nil + }.first + } + func javaRuntime(at homeURL: URL) -> JavaRuntimeCandidate? { MacRuntimeDiscovery.probeJavaHome(homeURL) } diff --git a/macos/Sources/Lithe/Services/Java/ProjectRuntimeService.swift b/macos/Sources/Lithe/Services/Java/ProjectRuntimeService.swift index 4f66026ce..e5b86c523 100644 --- a/macos/Sources/Lithe/Services/Java/ProjectRuntimeService.swift +++ b/macos/Sources/Lithe/Services/Java/ProjectRuntimeService.swift @@ -39,7 +39,9 @@ final class ProjectRuntimeService: ObservableObject { @Published private(set) var isDiscovering = false @Published private(set) var settings = ProjectRuntimeSettings() private var activeServiceJavaHomePath = "" + private var launchJavaRuntimes: [JavaRuntimeCandidate]? + private let javaSelector: any JavaRuntimeSelecting private let runtimeLocator: any RuntimeLocator private let store: any KeyValueStore private let toolDiscovery: any RuntimeToolDiscovery @@ -52,8 +54,10 @@ final class ProjectRuntimeService: ObservableObject { init( runtimeLocator: any RuntimeLocator, store: any KeyValueStore, - toolDiscovery: (any RuntimeToolDiscovery)? = nil + toolDiscovery: (any RuntimeToolDiscovery)? = nil, + javaSelector: any JavaRuntimeSelecting = RustCoreBridge() ) { + self.javaSelector = javaSelector self.runtimeLocator = runtimeLocator self.store = store self.toolDiscovery = toolDiscovery ?? DefaultRuntimeToolDiscovery() @@ -71,6 +75,7 @@ final class ProjectRuntimeService: ObservableObject { javaRuntimes = [] mavenRuntimes = [] discoveredProjectURL = nil + launchJavaRuntimes = nil settings = loadSettings(for: normalizedURL) javaEnvironmentReport = .checking(for: normalizedURL) javaLanguageServerRuntimePreparation = .unprepared @@ -85,6 +90,7 @@ final class ProjectRuntimeService: ObservableObject { javaRuntimes = [] mavenRuntimes = [] discoveredProjectURL = nil + launchJavaRuntimes = nil settings = ProjectRuntimeSettings() javaEnvironmentReport = nil isDiscovering = false @@ -105,6 +111,7 @@ final class ProjectRuntimeService: ObservableObject { } func refreshAvailableRuntimes() async { + launchJavaRuntimes = nil discoveryTask?.cancel() discoveryTask = nil await performRuntimeRefresh() @@ -137,13 +144,21 @@ final class ProjectRuntimeService: ObservableObject { isDiscovering = false } + private func javaRuntimesForLaunch() -> [JavaRuntimeCandidate] { + if hasDiscoveredRuntimes { return javaRuntimes } + if let launchJavaRuntimes { return launchJavaRuntimes } + let runtimes = runtimeLocator.discoverJavaRuntimes() + launchJavaRuntimes = runtimes + return runtimes + } + func javaHomeURL(overridePath: String? = nil) -> URL? { - chooseJavaHome(overridePath: overridePath) { self.runtimeLocator.discover().javaRuntimes }?.url + chooseJavaHome(overridePath: overridePath) { self.javaRuntimesForLaunch() }?.url } /// The single JDK selection chain behind launches and Settings: an explicit - /// override, then the project JDK, then `JAVA_HOME`, then the first detected - /// JDK. An invalid explicit path does not fall back, so a launch fails on it. + /// override, then the project JDK, then Core's requirement-aware automatic + /// selection. An invalid explicit path does not fall back, so a launch fails on it. /// /// `detected` supplies discovered runtimes and is only called when the chain /// reaches detection; returning `nil` reports that detection is pending. @@ -163,14 +178,44 @@ final class ProjectRuntimeService: ObservableObject { let path = normalizedOverridePath(configuredProjectJDK) return runtimeLocator.validJavaHome(path: path).map { RuntimeChoice.found($0, .projectSetting) } ?? .invalid(path) } - let paths = [runtimeLocator.environment()["JAVA_HOME"]] - for path in paths.compactMap({ $0 }).map(normalizedPath).filter({ !$0.isEmpty }) { - if let home = runtimeLocator.validJavaHome(path: path) { return .found(home, .javaHomeEnvironment) } + let environmentHome = runtimeLocator.environment()["JAVA_HOME"] + .flatMap { runtimeLocator.validJavaHome(path: normalizedPath($0)) } + if let environmentHome { + // With no project requirement, preserve the old no-probe JAVA_HOME + // fast path. Unknown versions never satisfy a real minimum, so only + // an unconstrained result may bypass discovery here. + let candidate = AutomaticJavaCandidate(id: environmentHome.path, version: "", priority: 0) + switch javaSelector.selectJavaRuntime(at: projectURL, candidates: [candidate], fallbackID: candidate.id) { + case .failure(let error): return .unavailable(error.message) + case .success(let selection) where selection.warning == nil: + return .found(environmentHome, .javaHomeEnvironment) + case .success: break + } } + // Settings supplies cached probes; pending discovery must not synchronously + // launch java -version or temporarily present an incompatible JAVA_HOME. guard let runtimes = detected() else { return nil } - return runtimes.first - .flatMap { runtimeLocator.validJavaHome(path: $0.homePath) } - .map { RuntimeChoice.found($0, .detected) } ?? .notFound + let pathHome = runtimeLocator.javaHomeOnPath(in: runtimes) + var candidates = runtimes.compactMap { runtime -> AutomaticJavaCandidate? in + guard let home = runtimeLocator.validJavaHome(path: runtime.homePath) else { return nil } + let priority: UInt32 = home.path == environmentHome?.path ? 0 : (home.path == pathHome?.path ? 1 : 2) + return AutomaticJavaCandidate(id: home.path, version: runtime.version, priority: priority) + } + // Preserve the old JAVA_HOME fallback even if its version probe failed. + // An unknown version cannot satisfy a requirement in the shared policy. + if let home = environmentHome, !candidates.contains(where: { $0.id == home.path }) { + candidates.append(AutomaticJavaCandidate(id: home.path, version: "", priority: 0)) + } + let fallback = environmentHome?.path ?? candidates.first?.id + switch javaSelector.selectJavaRuntime(at: projectURL, candidates: candidates, fallbackID: fallback) { + case .failure(let error): return .unavailable(error.message) + case .success(let selection): + guard let id = selection.id else { return .notFound } + let url = URL(fileURLWithPath: id).standardizedFileURL + let source: RuntimeChoiceSource = url.path == environmentHome?.path ? .javaHomeEnvironment : .detected + if let warning = selection.warning { return .warning(url, source, warning) } + return .found(url, source) + } } func javaExecutableURL(overridePath: String? = nil) -> URL? { @@ -246,7 +291,7 @@ final class ProjectRuntimeService: ObservableObject { } func mavenJavaHomeURL(overridePath: String? = nil) -> URL? { - chooseMavenJavaHome(overridePath: overridePath) { self.runtimeLocator.discover().javaRuntimes }?.url + chooseMavenJavaHome(overridePath: overridePath) { self.javaRuntimesForLaunch() }?.url } /// Maven's JDK: an explicit override, then the configured Maven JDK, then diff --git a/macos/Sources/Lithe/Views/App/EffectiveRuntimeLabel.swift b/macos/Sources/Lithe/Views/App/EffectiveRuntimeLabel.swift index b2b6c558c..d277e34d7 100644 --- a/macos/Sources/Lithe/Views/App/EffectiveRuntimeLabel.swift +++ b/macos/Sources/Lithe/Views/App/EffectiveRuntimeLabel.swift @@ -39,6 +39,12 @@ struct EffectiveRuntimeLabel: View { .foregroundStyle(LitheTheme.primaryText) .textSelection(.enabled) .lineLimit(3) + case .warning(let url, let source, let message)?: + Text(resolvedText(url: url, source: source, mode: modeTitle)) + .font(LitheTheme.smallFont).textSelection(.enabled) + Text(message).font(LitheTheme.smallFont).foregroundStyle(LitheTheme.error) + case .unavailable(let message)?: + Text(message).font(LitheTheme.smallFont).foregroundStyle(LitheTheme.error) case .fallback(let invalidPath, let replacement)?: VStack(alignment: .leading, spacing: 2) { Text(String(format: String(localized: "Cannot use %@; using the project JDK instead."), invalidPath)) @@ -52,6 +58,12 @@ struct EffectiveRuntimeLabel: View { .font(.system(size: 11.5, design: .monospaced)) .foregroundStyle(LitheTheme.primaryText) .textSelection(.enabled) + case .warning(let url, _, let message): + Text(resolvedText(url: url, source: .projectJDK, mode: String(localized: "Use Project JDK"))) + .font(LitheTheme.smallFont) + Text(message).font(LitheTheme.smallFont).foregroundStyle(LitheTheme.error) + case .unavailable(let message): + Text(message).font(LitheTheme.smallFont).foregroundStyle(LitheTheme.error) case .invalid(let path), .fallback(let path, _): Text(String(format: String(localized: "Cannot be used: %@"), path)) .font(LitheTheme.smallFont) diff --git a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift index 7edf4ee74..fef55162b 100644 --- a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift +++ b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift @@ -42,8 +42,8 @@ struct DebugModuleTests { #expect(feature.start(fileURL: source, rootURL: root, configuration: configuration)) let firstID = try #require(feature.activeSessionID) #expect(feature.startAdditional(fileURL: source, rootURL: root, configuration: configuration)) - feature.registerJavaUpdateTarget(JavaDebugLaunchTarget(mainClass: "example.Main")) - await feature.restartJavaService { _, _ in JavaDebugLaunchTarget(mainClass: "example.Main") } + feature.registerJavaUpdateTarget(JavaDebugLaunchTarget(mainClass: "example.Main", projectName: "app")) + await feature.restartJavaService { _, _ in JavaDebugLaunchTarget(mainClass: "example.Main", projectName: "app") } #expect(createdSessions.count == 3) #expect(createdSessions[0].isRunning) #expect(!createdSessions[1].isRunning) diff --git a/macos/Tests/LitheTests/MacRuntimeDiscoveryTests.swift b/macos/Tests/LitheTests/MacRuntimeDiscoveryTests.swift index 390576c94..0e96f6594 100644 --- a/macos/Tests/LitheTests/MacRuntimeDiscoveryTests.swift +++ b/macos/Tests/LitheTests/MacRuntimeDiscoveryTests.swift @@ -4,6 +4,19 @@ import Testing @Suite("macOS runtime discovery") struct MacRuntimeDiscoveryTests { + @Test + func resolvesJavaHomeFromPathSymlinkWithoutLaunchingIt() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let home = root.appendingPathComponent("custom-jdk", isDirectory: true) + try createExecutableJava(at: home) + let bin = root.appendingPathComponent("path-bin", isDirectory: true) + try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(at: bin.appendingPathComponent("java"), withDestinationURL: home.appendingPathComponent("bin/java")) + let environment = ["PATH": bin.path] + #expect(MacRuntimeDiscovery.javaHomeOnPath(environment: environment)?.resolvingSymlinksInPath().path == home.resolvingSymlinksInPath().path) + } + @Test func discoversSDKMANJavaCandidatesFromDefaultDirectory() throws { let testRoot = FileManager.default.temporaryDirectory diff --git a/macos/Tests/LitheTests/MavenRuntimeTests.swift b/macos/Tests/LitheTests/MavenRuntimeTests.swift index c2c1d6cde..269dda7bd 100644 --- a/macos/Tests/LitheTests/MavenRuntimeTests.swift +++ b/macos/Tests/LitheTests/MavenRuntimeTests.swift @@ -5,6 +5,47 @@ import Testing @Suite("Maven and runtime integration") struct MavenRuntimeTests { + @Test + @MainActor + func automaticJavaUsesProjectMinimumAndPreservesExplicitSelections() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root.appendingPathComponent(".lithe/toolchains"), withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let requirements = root.appendingPathComponent(".lithe/toolchains/requirements.json") + let old = JavaRuntimeCandidate(homePath: "/jdk/env", version: "1.8.0_402", vendor: "fixture") + let current = JavaRuntimeCandidate(homePath: "/jdk/current", version: "21.0.4", vendor: "fixture") + let locator = ChoiceRuntimeLocator(environmentValues: ["JAVA_HOME": old.homePath], validJavaHomes: [old.homePath, current.homePath], runtimeCandidates: [old, current]) + let service = ProjectRuntimeService(runtimeLocator: locator, store: EmptyKeyValueStore()) + service.openProject(at: root) + func require(_ version: String) throws { + try Data("{\"version\":1,\"toolchains\":{\"project-jdk\":{\"type\":\"java\",\"minimumVersion\":\"\(version)\"}}}".utf8).write(to: requirements) + } + try require("17") + let choice = service.chooseJavaHome(overridePath: nil) { [old, current] } + #expect(choice?.url?.path == current.homePath) + #expect(service.javaHomeURL()?.path == current.homePath) + #expect(service.mavenJavaHomeURL()?.path == current.homePath) + #expect(locator.discoverCalls == 1) + #expect(service.chooseMavenJavaHome(overridePath: nil) { [old, current] }?.url == choice?.url) + #expect(service.chooseJavaHome(overridePath: old.homePath) { [old, current] }?.url?.path == old.homePath) + #expect(service.chooseJavaHome(overridePath: "/missing") { [old, current] } == .invalid("/missing")) + try require("25") + if case .warning(let url, _, let message)? = service.chooseJavaHome(overridePath: nil, detected: { [old, current] }) { + #expect(url.path == old.homePath) + #expect(message.contains("25")) + } else { Issue.record("Expected an actionable fallback warning") } + try FileManager.default.removeItem(at: requirements) + #expect(service.chooseJavaHome(overridePath: nil) { [current, old] }?.url?.path == old.homePath) + try Data("{".utf8).write(to: requirements) + if case .unavailable(_)? = service.chooseJavaHome(overridePath: nil, detected: { [old, current] }) {} else { + Issue.record("Invalid requirements must be visible") + } + // Project switching cannot retain the previous workspace's requirement. + service.closeProject() + #expect(service.chooseJavaHome(overridePath: nil) { [current, old] }?.url?.path == old.homePath) + #expect(locator.discoverCalls == 1) + } + @Test func mavenLifecyclePhasesMatchTheSharedPlatformContract() throws { let fixture = try Self.platformContractFixture() @@ -286,6 +327,8 @@ struct MavenRuntimeTests { service.openProject(at: URL(fileURLWithPath: "/workspace", isDirectory: true)) #expect(describe(service.chooseJavaHome(overridePath: nil) { nil }) == "found /jdk/env javaHomeEnvironment") + #expect(service.javaHomeURL()?.path == "/jdk/env") + #expect(locator.discoverCalls == 0) #expect(describe(service.chooseJavaHome(overridePath: "/missing") { nil }) == "invalid /missing") #expect(describe(service.chooseJavaHome(overridePath: "/jdk/detected") { nil }) == "found /jdk/detected configured") @@ -329,6 +372,14 @@ struct MavenRuntimeTests { // One discovery in total: the environment report must reuse its result // instead of probing every JDK again on the main actor. #expect(locator.discoverCalls == 1) + #expect(service.javaHomeURL() == nil) + #expect(service.mavenJavaHomeURL() == nil) + #expect(locator.discoverCalls == 1) + service.closeProject() + service.openProject(at: URL(fileURLWithPath: "/another-fixture-workspace", isDirectory: true)) + #expect(service.javaHomeURL() == nil) + #expect(service.mavenJavaHomeURL() == nil) + #expect(locator.discoverCalls == 2) } @Test @@ -341,19 +392,19 @@ struct MavenRuntimeTests { let service = ProjectRuntimeService(runtimeLocator: locator, store: EmptyKeyValueStore()) service.openProject(at: URL(fileURLWithPath: "/workspace", isDirectory: true)) - #expect(describe(service.chooseMavenJavaHome(overridePath: nil) { nil }) == "found /jdk/env projectJDK") + #expect(describe(service.chooseMavenJavaHome(overridePath: nil) { [] }) == "found /jdk/env projectJDK") var settings = service.settings settings.mavenJavaHomePath = "/missing-maven-jdk" service.updateSettings(settings) // Launches keep falling back to the project JDK; Settings now says so. - #expect(describe(service.chooseMavenJavaHome(overridePath: nil) { nil }) + #expect(describe(service.chooseMavenJavaHome(overridePath: nil) { [] }) == "fallback /missing-maven-jdk -> found /jdk/env javaHomeEnvironment") #expect(service.mavenJavaHomeURL()?.path == "/jdk/env") settings.mavenJavaHomePath = "/jdk/maven" service.updateSettings(settings) - #expect(describe(service.chooseMavenJavaHome(overridePath: nil) { nil }) == "found /jdk/maven projectSetting") + #expect(describe(service.chooseMavenJavaHome(overridePath: nil) { [] }) == "found /jdk/maven projectSetting") // A usable Maven JDK never walks the project chain, which may probe every JDK. #expect(locator.discoverCalls == 0) } @@ -699,6 +750,8 @@ private func describe(_ choice: RuntimeChoice?) -> String { case .found(let url, let source)?: "found \(url.path) \(source)" case .invalid(let path)?: "invalid \(path)" case .fallback(let path, let replacement)?: "fallback \(path) -> \(describe(replacement))" + case .warning(let url, _, let message)?: "warning \(url.path) \(message)" + case .unavailable(let message)?: "unavailable \(message)" case .notFound?: "notFound" } } @@ -709,24 +762,27 @@ private final class ChoiceRuntimeLocator: RuntimeLocator, @unchecked Sendable { private let validJavaHomes: Set private let executables: Set private let systemMaven: URL? + private let runtimeCandidates: [JavaRuntimeCandidate] private(set) var discoverCalls = 0 init( environmentValues: [String: String] = [:], validJavaHomes: Set = [], executables: Set = [], - systemMaven: URL? = nil + systemMaven: URL? = nil, + runtimeCandidates: [JavaRuntimeCandidate] = [] ) { self.environmentValues = environmentValues self.validJavaHomes = validJavaHomes self.executables = executables self.systemMaven = systemMaven + self.runtimeCandidates = runtimeCandidates } func environment() -> [String: String] { environmentValues } func discover() -> RuntimeDiscoveryResult { discoverCalls += 1 - return RuntimeDiscoveryResult(javaRuntimes: [], mavenRuntimes: []) + return RuntimeDiscoveryResult(javaRuntimes: runtimeCandidates, mavenRuntimes: []) } func validJavaHome(path: String) -> URL? { validJavaHomes.contains(path) ? URL(fileURLWithPath: path, isDirectory: true) : nil diff --git a/rust/lithe-core/src/execution/classpath_jar.rs b/rust/lithe-core/src/execution/classpath_jar.rs new file mode 100644 index 000000000..fafacf933 --- /dev/null +++ b/rust/lithe-core/src/execution/classpath_jar.rs @@ -0,0 +1,619 @@ +//! Shortens an oversized Java 8 class path through a manifest-only JAR. +//! +//! JDK 9 added `java @file` argument files, so [`super::plan_launch_command`] +//! cannot help a JDK 8 launch. The launcher has always accepted a JAR whose +//! `META-INF/MANIFEST.MF` lists further class-path entries in its `Class-Path` +//! attribute. Passing that one JAR with `-cp` keeps the command line short +//! while the application class loader still sees every original entry in order. +//! +//! Core converts each entry to an absolute `file:` URL and renders the manifest +//! text, including the 72-byte line rule. The host owns the filesystem: it +//! answers whether an entry is a directory, writes the JAR at the path it +//! supplied, and deletes it after that execution exits. Every URL is +//! percent-encoded UTF-8, so the manifest is pure ASCII and, unlike an argument +//! file, does not depend on the Windows system code page. + +use super::launch_command::{command_line_length, COMMAND_LINE_MARGIN, WINDOWS_COMMAND_LINE_LIMIT}; +use std::path::Path; + +/// First JDK release whose launcher reads argument files; from this version on +/// the argument-file planner owns shortening. +const FIRST_ARGFILE_JAVA_VERSION: u32 = 9; + +/// Launcher options that a JDK 8 `java` accepts for the class path. The long +/// `--class-path` spelling only exists from JDK 9 on. +const CLASSPATH_OPTIONS: &[&str] = &["-cp", "-classpath"]; + +/// Longest manifest line in bytes, excluding the line break. Longer headers +/// continue on lines that start with one space (JAR File Specification). +const MANIFEST_LINE_LIMIT: usize = 72; + +/// Path syntax of the host that will start the JVM. It decides the class-path +/// separator and how absolute paths are recognized and turned into URLs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClasspathStyle { + /// `;`-separated entries with drive-letter or UNC absolute paths. + Windows, + /// `:`-separated entries with `/`-rooted absolute paths. + Posix, +} + +/// Inputs for [`plan_classpath_jar_launch`]. +#[derive(Debug, Clone, Copy)] +pub struct ClasspathJarRequest<'a> { + /// Java executable path used for launcher detection. + pub executable: &'a str, + /// Complete argument vector before shortening. + pub arguments: &'a [String], + /// Directory the JVM starts in. Relative class-path entries resolve against + /// it on the command line, but against the JAR inside a manifest, so Core + /// makes them absolute first. + pub working_directory: &'a str, + /// Host-owned path that may be referenced by a classpath-JAR plan. + pub classpath_jar_path: &'a Path, + /// Optional host-specific command-line cap; `None` uses the Windows cap. + pub limit: Option, + /// JDK feature version read by the host from its `release` file. + pub java_feature_version: Option, + /// Path syntax used by the class-path value. + pub style: ClasspathStyle, +} + +/// How a host should start a JDK 8 launch whose class path may be too long. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ClasspathJarPlan { + /// Spawn with the original arguments. + Direct, + /// Write a JAR containing only `META-INF/MANIFEST.MF` with `manifest` at the + /// supplied path, then spawn with `arguments`. + ClasspathJar { + /// Arguments whose effective class-path value is the JAR path. + arguments: Vec, + /// Complete ASCII manifest text with CRLF line breaks. + manifest: String, + }, +} + +/// Plans a manifest-JAR launch for a known JDK older than 9. +/// +/// `is_directory` receives each absolute entry and reports whether it names a +/// directory: a manifest URL must end in `/` for the class loader to treat it as +/// a directory rather than an archive. Returns [`ClasspathJarPlan::Direct`] when +/// the JDK is unknown or supports argument files, the command fits, the launch +/// uses `-jar` (which ignores `-cp`), or an entry cannot be expressed as an +/// absolute URL without changing its meaning, such as a `lib/*` wildcard. +pub fn plan_classpath_jar_launch( + request: ClasspathJarRequest<'_>, + is_directory: impl Fn(&str) -> bool, +) -> ClasspathJarPlan { + let executable_name = request + .executable + .rsplit(['/', '\\']) + .next() + .unwrap_or(request.executable); + if !["java", "java.exe", "javaw", "javaw.exe"] + .iter() + .any(|name| executable_name.eq_ignore_ascii_case(name)) + || !request + .java_feature_version + .is_some_and(|version| version < FIRST_ARGFILE_JAVA_VERSION) + { + return ClasspathJarPlan::Direct; + } + let budget = request + .limit + .unwrap_or(WINDOWS_COMMAND_LINE_LIMIT) + .saturating_sub(COMMAND_LINE_MARGIN); + if command_line_length(request.executable, request.arguments) <= budget { + return ClasspathJarPlan::Direct; + } + let Some(value_index) = effective_classpath_value_index(request.arguments) else { + return ClasspathJarPlan::Direct; + }; + let Some(urls) = classpath_urls( + &request.arguments[value_index], + request.working_directory, + request.style, + &is_directory, + ) else { + return ClasspathJarPlan::Direct; + }; + let mut arguments = request.arguments.to_vec(); + arguments[value_index] = request.classpath_jar_path.display().to_string(); + let mut manifest = String::from("Manifest-Version: 1.0\r\n"); + manifest.push_str(&manifest_header("Class-Path", &urls.join(" "))); + // A header is only recognized once its line is terminated, and the blank + // line closes the main section. + manifest.push_str("\r\n"); + ClasspathJarPlan::ClasspathJar { + arguments, + manifest, + } +} + +/// Finds the class-path value the JDK 8 launcher will use. +/// +/// Launcher options end at the main class or `-jar`. When `-cp` appears more +/// than once the last occurrence wins, so only that value is replaced. `-jar` +/// ignores `-cp` entirely, which leaves nothing to shorten. +fn effective_classpath_value_index(arguments: &[String]) -> Option { + let mut effective = None; + let mut index = 0; + while index < arguments.len() { + let argument = arguments[index].as_str(); + if argument == "-jar" { + return None; + } + if !argument.starts_with('-') { + break; + } + if CLASSPATH_OPTIONS.contains(&argument) && index + 1 < arguments.len() { + effective = Some(index + 1); + index += 2; + } else { + index += 1; + } + } + effective +} + +/// Converts a joined class-path value into manifest URLs in launcher order. +fn classpath_urls( + value: &str, + working_directory: &str, + style: ClasspathStyle, + is_directory: &dyn Fn(&str) -> bool, +) -> Option> { + let separator = match style { + ClasspathStyle::Windows => ';', + ClasspathStyle::Posix => ':', + }; + value + .split(separator) + .map(|entry| { + // The launcher reads an empty element as the current directory. + let entry = if entry.is_empty() { "." } else { entry }; + let absolute = absolute_entry(entry, working_directory, style)?; + // Launcher wildcards are expanded only on the command line; the + // manifest class loader would look for a file literally named `*`. + if absolute.rsplit(['/', '\\']).next() == Some("*") { + return None; + } + Some(file_url(&absolute, style, is_directory(&absolute))) + }) + .collect() +} + +/// Makes one entry absolute, or returns `None` when its base is ambiguous. +fn absolute_entry(entry: &str, working_directory: &str, style: ClasspathStyle) -> Option { + match style { + ClasspathStyle::Posix => { + if entry.starts_with('/') { + Some(entry.to_string()) + } else if working_directory.starts_with('/') { + Some(format!( + "{}/{entry}", + working_directory.trim_end_matches('/') + )) + } else { + None + } + } + ClasspathStyle::Windows => { + let entry = strip_verbatim_prefix(&entry.replace('/', "\\"))?; + let working_directory = strip_verbatim_prefix(&working_directory.replace('/', "\\"))?; + let working_directory = working_directory.as_str(); + let path = if windows_root(&entry).is_some() { + entry + } else if entry.starts_with('\\') { + // `\lib\a.jar` is rooted on the working directory's drive. + format!( + "{}{entry}", + windows_root(working_directory)?.trim_end_matches('\\') + ) + } else if entry.as_bytes().get(1) == Some(&b':') { + // `C:lib` depends on a per-drive current directory that the + // host never sees, so it cannot be resolved deterministically. + return None; + } else { + windows_root(working_directory)?; + format!( + "{}\\{entry}", + working_directory.trim_end_matches(['\\', '/']) + ) + }; + Some(normalize_windows_path(&path)) + } + } +} + +/// Rewrites `\\?\C:\x` to `C:\x` and `\\?\UNC\host\share` to `\\host\share`. +/// +/// Other `\\?\` and `\\.\` device paths have no `file:` URL form, so they +/// return `None` and keep the launch direct instead of becoming a bogus UNC +/// host named `?` or `.`. +fn strip_verbatim_prefix(entry: &str) -> Option { + if let Some(rest) = entry.strip_prefix("\\\\?\\UNC\\") { + return Some(format!("\\\\{rest}")); + } + if let Some(rest) = entry.strip_prefix("\\\\?\\") { + let bytes = rest.as_bytes(); + let drive = bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && bytes[2] == b'\\'; + return drive.then(|| rest.to_string()); + } + if entry.starts_with("\\\\.\\") { + return None; + } + Some(entry.to_string()) +} + +/// Returns the root prefix of an absolute Windows path: `C:\` or `\\host\share`. +fn windows_root(path: &str) -> Option { + let path = path.replace('/', "\\"); + let bytes = path.as_bytes(); + if bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'\\' { + return Some(path[..3].to_string()); + } + let unc = path.strip_prefix("\\\\")?; + let mut parts = unc.splitn(3, '\\'); + let host = parts.next().filter(|part| !part.is_empty())?; + let share = parts.next().filter(|part| !part.is_empty())?; + Some(format!("\\\\{host}\\{share}")) +} + +/// Removes `.` and `..` segments the way Windows does, which is lexical. +fn normalize_windows_path(path: &str) -> String { + let root = windows_root(path).expect("absolute Windows path"); + let mut segments: Vec<&str> = Vec::new(); + for segment in path[root.len()..].split('\\') { + match segment { + "" | "." => {} + ".." => { + segments.pop(); + } + _ => segments.push(segment), + } + } + let root = root.trim_end_matches('\\'); + if segments.is_empty() { + format!("{root}\\") + } else { + format!("{root}\\{}", segments.join("\\")) + } +} + +/// Renders an absolute path as a `file:` URL the JDK class loader can open. +/// +/// Drive paths become `file:/C:/...` and UNC paths `file:////host/share/...`, +/// matching `java.io.File.toURI`. Every byte outside a small unreserved set is +/// percent-encoded as UTF-8; the JDK decodes the URL before opening the file. +fn file_url(path: &str, style: ClasspathStyle, directory: bool) -> String { + let path = match style { + ClasspathStyle::Windows => { + let slashed = path.replace('\\', "/"); + if slashed.starts_with("//") { + format!("//{slashed}") + } else { + format!("/{slashed}") + } + } + ClasspathStyle::Posix => path.to_string(), + }; + let mut url = String::from("file:"); + for byte in path.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~' | b'/' | b':') { + url.push(byte as char); + } else { + url.push_str(&format!("%{byte:02X}")); + } + } + if directory && !url.ends_with('/') { + url.push('/'); + } + url +} + +/// Renders one manifest header, continuing lines after 72 bytes. +/// +/// The text is ASCII because every URL is percent-encoded, so splitting at a +/// byte offset can never cut a character in half. +fn manifest_header(name: &str, value: &str) -> String { + let line = format!("{name}: {value}"); + debug_assert!(line.is_ascii()); + let mut rendered = String::with_capacity(line.len() + line.len() / 35 + 2); + let mut rest = line.as_str(); + let mut width = MANIFEST_LINE_LIMIT; + loop { + let (head, tail) = rest.split_at(rest.len().min(width)); + rendered.push_str(head); + rendered.push_str("\r\n"); + if tail.is_empty() { + return rendered; + } + rendered.push(' '); + rest = tail; + width = MANIFEST_LINE_LIMIT - 1; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn jar_path() -> &'static Path { + Path::new("C:\\Temp\\lithe-run\\launch-1-0.classpath.jar") + } + + /// Mirrors a resolved Maven runtime classpath that exceeds the Windows cap. + fn classpath(entries: usize) -> String { + (0..entries) + .map(|index| { + format!( + "C:\\Users\\developer\\.m2\\repository\\org\\example\\artifact-{index}\\1.0.{index}\\artifact-{index}-1.0.{index}.jar" + ) + }) + .collect::>() + .join(";") + } + + fn request<'a>(arguments: &'a [String], version: Option) -> ClasspathJarRequest<'a> { + ClasspathJarRequest { + executable: "C:\\jdk8\\bin\\java.exe", + arguments, + working_directory: "C:\\work\\demo", + classpath_jar_path: jar_path(), + limit: None, + java_feature_version: version, + style: ClasspathStyle::Windows, + } + } + + /// Joins continuation lines back into logical headers. + fn class_path_urls(manifest: &str) -> Vec { + let logical = manifest.replace("\r\n ", ""); + logical + .split("\r\n") + .find_map(|line| line.strip_prefix("Class-Path: ")) + .expect("manifest has a Class-Path header") + .split(' ') + .map(str::to_string) + .collect() + } + + fn no_directories(_: &str) -> bool { + false + } + + /// Regression for #955: a large JDK 8 project failed in `CreateProcessW` + /// because argument files only exist from JDK 9 on. + #[test] + fn an_oversized_jdk8_classpath_moves_into_a_manifest_jar() { + let arguments = vec![ + "-Dfile.encoding=UTF-8".to_string(), + "-cp".to_string(), + classpath(500), + "com.example.Main".to_string(), + "--server.port=8080".to_string(), + ]; + let ClasspathJarPlan::ClasspathJar { + arguments: shortened, + manifest, + } = plan_classpath_jar_launch(request(&arguments, Some(8)), no_directories) + else { + panic!("an oversized JDK 8 classpath must be shortened"); + }; + assert_eq!( + shortened, + [ + "-Dfile.encoding=UTF-8", + "-cp", + "C:\\Temp\\lithe-run\\launch-1-0.classpath.jar", + "com.example.Main", + "--server.port=8080", + ] + ); + assert!(manifest.starts_with("Manifest-Version: 1.0\r\nClass-Path: file:/C:/Users/")); + assert!(manifest.ends_with("\r\n\r\n")); + let urls = class_path_urls(&manifest); + assert_eq!(urls.len(), 500); + assert_eq!( + urls[0], + "file:/C:/Users/developer/.m2/repository/org/example/artifact-0/1.0.0/artifact-0-1.0.0.jar" + ); + } + + #[test] + fn manifest_lines_never_exceed_72_bytes() { + let arguments = vec!["-cp".into(), classpath(500), "Main".into()]; + let ClasspathJarPlan::ClasspathJar { manifest, .. } = + plan_classpath_jar_launch(request(&arguments, Some(8)), no_directories) + else { + panic!("classpath should move"); + }; + let body = manifest + .strip_suffix("\r\n\r\n") + .expect("terminated section"); + let lines: Vec<&str> = body.split("\r\n").collect(); + assert!(lines.len() > 100, "a long header must wrap"); + assert!(lines.iter().all(|line| line.len() <= MANIFEST_LINE_LIMIT)); + // Continuation lines start with the one marker space and fill the + // remaining 71 bytes, except the last one. The content after the + // marker may itself begin with the space that separates two URLs. + for line in &lines[2..lines.len() - 1] { + assert_eq!(line.len(), MANIFEST_LINE_LIMIT); + assert!(line.starts_with(' ')); + } + let urls = class_path_urls(&manifest); + assert_eq!(urls.len(), 500); + assert!(urls.iter().all(|url| url.starts_with("file:/C:/Users/"))); + // Every break is CRLF, the form the `jar` tool writes. + assert_eq!( + manifest.matches('\n').count(), + manifest.matches("\r\n").count() + ); + } + + #[test] + fn spaces_chinese_and_reserved_characters_are_percent_encoded() { + let entries = format!( + "C:\\Program Files\\示例 库\\a#b%c+d.jar;D:/mixed/slash.jar;{}", + classpath(500) + ); + let arguments = vec!["-cp".into(), entries, "Main".into()]; + let ClasspathJarPlan::ClasspathJar { manifest, .. } = + plan_classpath_jar_launch(request(&arguments, Some(8)), no_directories) + else { + panic!("classpath should move"); + }; + assert!( + manifest.is_ascii(), + "manifest must not depend on a code page" + ); + let urls = class_path_urls(&manifest); + assert_eq!( + urls[0], + "file:/C:/Program%20Files/%E7%A4%BA%E4%BE%8B%20%E5%BA%93/a%23b%25c%2Bd.jar" + ); + assert_eq!(urls[1], "file:/D:/mixed/slash.jar"); + } + + #[test] + fn directories_relative_unc_and_empty_entries_become_absolute_urls() { + let entries = format!( + "target\\classes;..\\shared\\lib.jar;\\tools\\x.jar;\\\\server\\share\\dep.jar;;\\\\?\\C:\\long\\v.jar;\\\\?\\UNC\\server\\share\\w.jar;{}", + classpath(500) + ); + let arguments = vec!["-cp".into(), entries, "Main".into()]; + let directories = ["C:\\work\\demo\\target\\classes", "C:\\work\\demo"]; + let ClasspathJarPlan::ClasspathJar { manifest, .. } = + plan_classpath_jar_launch(request(&arguments, Some(8)), |path| { + directories.contains(&path) + }) + else { + panic!("classpath should move"); + }; + let urls = class_path_urls(&manifest); + assert_eq!(urls[0], "file:/C:/work/demo/target/classes/"); + assert_eq!(urls[1], "file:/C:/work/shared/lib.jar"); + assert_eq!(urls[2], "file:/C:/tools/x.jar"); + assert_eq!(urls[3], "file:////server/share/dep.jar"); + // An empty element means the working directory to the launcher. + assert_eq!(urls[4], "file:/C:/work/demo/"); + // Verbatim paths from canonicalized tooling keep their real target. + assert_eq!(urls[5], "file:/C:/long/v.jar"); + assert_eq!(urls[6], "file:////server/share/w.jar"); + } + + #[test] + fn jdk9_unknown_short_and_non_java_launches_stay_direct() { + let long = vec!["-cp".into(), classpath(500), "Main".into()]; + for version in [None, Some(9), Some(21)] { + assert_eq!( + plan_classpath_jar_launch(request(&long, version), no_directories), + ClasspathJarPlan::Direct + ); + } + let short = vec!["-cp".into(), classpath(3), "Main".into()]; + assert_eq!( + plan_classpath_jar_launch(request(&short, Some(8)), no_directories), + ClasspathJarPlan::Direct + ); + let relative = vec![ + "-cp".into(), + format!("classes;{}", classpath(500)), + "Main".into(), + ]; + let mut verbatim = request(&relative, Some(8)); + verbatim.working_directory = "\\\\?\\C:\\work\\demo"; + let ClasspathJarPlan::ClasspathJar { manifest, .. } = + plan_classpath_jar_launch(verbatim, no_directories) + else { + panic!("a verbatim working directory still resolves relative entries"); + }; + assert_eq!(class_path_urls(&manifest)[0], "file:/C:/work/demo/classes"); + let mut node = request(&long, Some(8)); + node.executable = "node.exe"; + assert_eq!( + plan_classpath_jar_launch(node, no_directories), + ClasspathJarPlan::Direct + ); + } + + #[test] + fn launches_whose_meaning_a_manifest_cannot_preserve_stay_direct() { + for (entries, target) in [ + (format!("lib\\*;{}", classpath(500)), "Main"), + (format!("C:lib\\a.jar;{}", classpath(500)), "Main"), + (format!("\\\\.\\pipe\\x;{}", classpath(500)), "Main"), + (classpath(500), "-jar"), + ] { + let arguments = vec!["-cp".into(), entries, target.into(), "app.jar".into()]; + assert_eq!( + plan_classpath_jar_launch(request(&arguments, Some(8)), no_directories), + ClasspathJarPlan::Direct, + "{target}" + ); + } + let without_classpath = vec!["Main".into(), classpath(500)]; + assert_eq!( + plan_classpath_jar_launch(request(&without_classpath, Some(8)), no_directories), + ClasspathJarPlan::Direct + ); + } + + #[test] + fn only_the_effective_classpath_before_the_main_class_is_replaced() { + let arguments = vec![ + "-cp".into(), + "ignored.jar".into(), + "-classpath".into(), + classpath(500), + "Main".into(), + "-cp".into(), + "program-value".into(), + ]; + let ClasspathJarPlan::ClasspathJar { + arguments: shortened, + manifest, + } = plan_classpath_jar_launch(request(&arguments, Some(8)), no_directories) + else { + panic!("classpath should move"); + }; + assert_eq!(&shortened[..3], &arguments[..3]); + assert_eq!( + shortened[3], + "C:\\Temp\\lithe-run\\launch-1-0.classpath.jar" + ); + assert_eq!(&shortened[4..], &arguments[4..]); + assert!(!manifest.contains("program-value")); + assert!(!manifest.contains("ignored.jar")); + } + + #[test] + fn posix_paths_use_colon_separators() { + let entries = (0..800) + .map(|index| format!("/home/dev/.m2/repository/lib {index}/artifact-{index}.jar")) + .chain(["classes".to_string()]) + .collect::>() + .join(":"); + let arguments = vec!["-cp".into(), entries, "Main".into()]; + let mut posix = request(&arguments, Some(8)); + posix.executable = "/opt/jdk8/bin/java"; + posix.working_directory = "/work/demo"; + posix.style = ClasspathStyle::Posix; + let ClasspathJarPlan::ClasspathJar { manifest, .. } = + plan_classpath_jar_launch(posix, |path| path == "/work/demo/classes") + else { + panic!("classpath should move"); + }; + let urls = class_path_urls(&manifest); + assert_eq!( + urls[0], + "file:/home/dev/.m2/repository/lib%200/artifact-0.jar" + ); + assert_eq!(urls[800], "file:/work/demo/classes/"); + } +} diff --git a/rust/lithe-core/src/execution/configuration.rs b/rust/lithe-core/src/execution/configuration.rs index a5f430382..e001d98ee 100644 --- a/rust/lithe-core/src/execution/configuration.rs +++ b/rust/lithe-core/src/execution/configuration.rs @@ -2249,7 +2249,7 @@ fn validate_version(version: u32) -> Result<(), CoreError> { Ok(()) } -fn validate_sidecar_version(version: u32) -> Result<(), CoreError> { +pub(super) fn validate_sidecar_version(version: u32) -> Result<(), CoreError> { if version != SIDECAR_VERSION { return Err( CoreError::new(ErrorCode::NotSupported, "Unsupported document version") @@ -2720,7 +2720,9 @@ fn read_document_value(root: &Path, relative: &str) -> Result, Cor Ok(Some(value)) } -fn read_requirements(root: &Path) -> Result, CoreError> { +pub(super) fn read_requirements( + root: &Path, +) -> Result, CoreError> { let path = root .join(".lithe") .join("toolchains") @@ -2950,7 +2952,7 @@ fn append_toolchain_diagnostics( } } -fn version_satisfies(kind: &str, actual: &str, required: &str, minimum: bool) -> bool { +pub(super) fn version_satisfies(kind: &str, actual: &str, required: &str, minimum: bool) -> bool { let mut actual_parts = version_parts(actual); let mut required_parts = version_parts(required); if kind == "java" { @@ -2967,7 +2969,7 @@ fn version_satisfies(kind: &str, actual: &str, required: &str, minimum: bool) -> } } -fn version_parts(value: &str) -> Vec { +pub(super) fn version_parts(value: &str) -> Vec { value .split(|character: char| !character.is_ascii_digit()) .filter(|part| !part.is_empty()) @@ -2977,7 +2979,7 @@ fn version_parts(value: &str) -> Vec { /// Drops the legacy `1.` prefix Java 8 and earlier report (`1.8.0_504`), so /// those runtimes compare on the same feature-version scale as `8` or `17.0.12`. -fn java_feature_version_parts(parts: Vec) -> Vec { +pub(super) fn java_feature_version_parts(parts: Vec) -> Vec { if parts.len() > 1 && parts[0] == 1 { parts[1..].to_vec() } else { diff --git a/rust/lithe-core/src/execution/java_selection.rs b/rust/lithe-core/src/execution/java_selection.rs new file mode 100644 index 000000000..4997fdc96 --- /dev/null +++ b/rust/lithe-core/src/execution/java_selection.rs @@ -0,0 +1,114 @@ +//! Selects an automatic project JDK from platform-probed candidates. +//! Project requirements remain owned by the existing run-configuration document. + +use super::configuration::{ + java_feature_version_parts, read_requirements, version_parts, version_satisfies, +}; +use crate::protocol::CoreError; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// A probed candidate; `id` is opaque and is never persisted or opened by Core. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct JavaSelectionCandidate { + /// Opaque machine-local identity supplied by the host. + pub id: String, + /// Version reported by the Java executable; empty means unknown. + pub version: String, + /// Lower values win: JAVA_HOME, PATH, installations, project-managed JDK. + pub priority: u32, +} + +/// Platform candidates and the workspace whose existing requirements apply. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct JavaSelectionRequest { + /// Open workspace; absent means no project requirements. + pub root: Option, + /// Usable executable homes probed by the platform. + pub candidates: Vec, + /// The platform's unconstrained choice; used when requirements are absent + /// or no candidate satisfies them. This keeps fallback behavior explicit. + pub fallback_id: Option, +} + +/// Selected candidate and an actionable warning when no compatible JDK exists. +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct JavaSelection { + /// Selected identity, or absent when the host found no usable JDK. + pub id: Option, + /// No candidate satisfies the minimum; the fallback remains usable. + pub warning: Option, +} + +/// Orders equivalent sources numerically, including legacy Java `1.8` versions. +/// Stable IDs break equal-version ties independently of filesystem enumeration. +pub fn compare_java_candidates( + left: &JavaSelectionCandidate, + right: &JavaSelectionCandidate, +) -> std::cmp::Ordering { + left.priority + .cmp(&right.priority) + .then_with(|| { + java_feature_version_parts(version_parts(&right.version)) + .cmp(&java_feature_version_parts(version_parts(&left.version))) + }) + .then_with(|| left.id.cmp(&right.id)) +} + +/// Reuses Core's requirement reader and version semantics without generating +/// requirements, probing executables, or changing an explicit user selection. +pub fn select_java(request: JavaSelectionRequest) -> Result { + let document = request + .root + .as_deref() + .map(read_requirements) + .transpose()? + .flatten(); + if let Some(document) = &document { + super::configuration::validate_sidecar_version(document.version)?; + } + let minimum = document + .as_ref() + .and_then(|doc| doc.toolchains.get("project-jdk")) + .filter(|requirement| requirement.kind == "java") + .and_then(|requirement| requirement.minimum_version.as_deref()); + Ok(select_java_candidates( + &request.candidates, + request.fallback_id.as_deref(), + minimum, + )) +} + +/// Applies only the project minimum; explicit paths and vendor preferences +/// remain the responsibility of existing launch validation and diagnostics. +pub fn select_java_candidates( + candidates: &[JavaSelectionCandidate], + fallback_id: Option<&str>, + minimum: Option<&str>, +) -> JavaSelection { + let fallback = candidates + .iter() + .find(|candidate| Some(candidate.id.as_str()) == fallback_id) + .or_else(|| { + candidates + .iter() + .min_by(|a, b| compare_java_candidates(a, b)) + }); + let Some(minimum) = minimum else { + return JavaSelection { + id: fallback.map(|candidate| candidate.id.clone()), + warning: None, + }; + }; + let matching = candidates + .iter() + .filter(|candidate| version_satisfies("java", &candidate.version, minimum, true)) + .min_by(|a, b| compare_java_candidates(a, b)); + JavaSelection { + id: matching.or(fallback).map(|candidate| candidate.id.clone()), + warning: matching.is_none().then(|| format!("No installed JDK satisfies Java {minimum} or newer. Install a compatible JDK or choose one in project settings.")), + } +} diff --git a/rust/lithe-core/src/execution/launch_command.rs b/rust/lithe-core/src/execution/launch_command.rs index 148a22929..24eb39c13 100644 --- a/rust/lithe-core/src/execution/launch_command.rs +++ b/rust/lithe-core/src/execution/launch_command.rs @@ -12,6 +12,9 @@ //! deletes it after that execution exits. The returned text is Unicode; the //! host must encode it losslessly with the launcher's native platform encoding. //! JEP 400 does not make Windows launcher argument files UTF-8. +//! +//! JDK 8 has no argument files; [`super::plan_classpath_jar_launch`] covers +//! that release with a manifest-only class-path JAR under the same budget. use serde::{Deserialize, Serialize}; use std::path::Path; @@ -23,7 +26,7 @@ pub const WINDOWS_COMMAND_LINE_LIMIT: usize = 32_767; /// Headroom for the null terminator and for host quoting that this estimate /// does not model exactly. Shortening early costs nothing; shortening too late /// fails the launch. -const COMMAND_LINE_MARGIN: usize = 2_048; +pub(super) const COMMAND_LINE_MARGIN: usize = 2_048; /// Options whose value is a joined list of absolute paths. These carry /// practically all of an oversized command line, so moving them into the @@ -226,7 +229,7 @@ pub fn plan_launch_command( /// Windows counts UTF-16 code units, and each argument containing whitespace or /// a quote is wrapped in quotes. The estimate never has to be exact because /// [`COMMAND_LINE_MARGIN`] absorbs the difference. -fn command_line_length(executable: &str, arguments: &[String]) -> usize { +pub(super) fn command_line_length(executable: &str, arguments: &[String]) -> usize { let mut length = quoted_length(executable); for argument in arguments { length += 1 + quoted_length(argument); diff --git a/rust/lithe-core/src/execution/mod.rs b/rust/lithe-core/src/execution/mod.rs index 80f882a41..b3cbcb0a3 100644 --- a/rust/lithe-core/src/execution/mod.rs +++ b/rust/lithe-core/src/execution/mod.rs @@ -1,10 +1,19 @@ //! Run configuration generation, resolution, and project detectors. +mod classpath_jar; mod configuration; mod detectors; +mod java_selection; mod launch_command; +pub use java_selection::{ + compare_java_candidates, select_java, select_java_candidates, JavaSelection, + JavaSelectionCandidate, JavaSelectionRequest, +}; mod types; +pub use classpath_jar::{ + plan_classpath_jar_launch, ClasspathJarPlan, ClasspathJarRequest, ClasspathStyle, +}; pub(crate) use configuration::*; pub use launch_command::{ java_feature_version_from_release, plan_launch_command, plan_launch_command_request, diff --git a/rust/lithe-core/src/git/graph.rs b/rust/lithe-core/src/git/graph.rs index e7577ee7b..b70a84c96 100644 --- a/rust/lithe-core/src/git/graph.rs +++ b/rust/lithe-core/src/git/graph.rs @@ -369,6 +369,7 @@ mod tests { author_name: "Lithe Test".to_string(), author_email: "test@example.com".to_string(), date: "2026-09-04".to_string(), + date_utc_offset_minutes: None, subject: hash.to_string(), decorations: decorations.to_string(), } diff --git a/rust/lithe-core/src/git/history.rs b/rust/lithe-core/src/git/history.rs index 04d1c9c94..4d79da81e 100644 --- a/rust/lithe-core/src/git/history.rs +++ b/rust/lithe-core/src/git/history.rs @@ -2,7 +2,8 @@ use super::{ command_value, execute_git_readonly_with_environment, git_process, parse_commit, - parse_reference, readonly_command, validate_root, GitCommandRequest, INTERNAL_REF_PREFIX, + parse_reference, readonly_command, validate_root, GitCommandRequest, GIT_COMMIT_DATE_ARGUMENT, + INTERNAL_REF_PREFIX, }; use crate::protocol::{ cancellation, CoreError, ErrorCode, GitCommitResponse, GitHistoryCursorCloseResponse, @@ -340,7 +341,7 @@ fn offset_history_page( offset.to_string(), "-n".to_string(), limit.saturating_add(1).to_string(), - "--date=format:%Y/%m/%d %H:%M".to_string(), + GIT_COMMIT_DATE_ARGUMENT.to_string(), "--pretty=format:%H%x1f%h%x1f%P%x1f%an%x1f%ae%x1f%ad%x1f%s%x1f%D".to_string(), ]); let output = readonly_command(GitCommandRequest { @@ -479,7 +480,7 @@ impl HistorySession { format!("--decorate-refs-exclude={INTERNAL_REF_PREFIX}*"), "-n".to_string(), MAX_HISTORY_COMMITS.to_string(), - "--date=format:%Y/%m/%d %H:%M".to_string(), + GIT_COMMIT_DATE_ARGUMENT.to_string(), "--pretty=format:%H%x1f%h%x1f%P%x1f%an%x1f%ae%x1f%ad%x1f%s%x1f%D".to_string(), ]); let mut process = git_process(); diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index df263141b..b8c72da32 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -72,6 +72,10 @@ use std::time::Duration; const DEFAULT_PUSH_PREVIEW_LIMIT: usize = 500; const INTERNAL_REF_PREFIX: &str = "refs/lithe/"; +/// Commit date argument for every `parse_commit` producer. The trailing `%z` +/// carries the author's UTC offset, which `parse_commit` splits off so the +/// displayed `date` keeps its `%Y/%m/%d %H:%M` shape. +const GIT_COMMIT_DATE_ARGUMENT: &str = "--date=format:%Y/%m/%d %H:%M %z"; const DEFAULT_REPOSITORY_SCAN_MAX_DIRECTORIES: usize = usize::MAX; const DEFAULT_REPOSITORY_SCAN_MAX_DEPTH: usize = usize::MAX; static TEMPORARY_INDEX_SEQUENCE: AtomicU64 = AtomicU64::new(0); @@ -2036,7 +2040,7 @@ fn read_commit_log( "--decorate=short".to_string(), "-n".to_string(), (limit.saturating_add(1)).to_string(), - "--date=format:%Y/%m/%d %H:%M".to_string(), + GIT_COMMIT_DATE_ARGUMENT.to_string(), "--pretty=format:%H%x1f%h%x1f%P%x1f%an%x1f%ae%x1f%ad%x1f%s%x1f%D".to_string(), ]); let commit_output = readonly_command(GitCommandRequest { @@ -2058,7 +2062,7 @@ fn read_commit_log( Ok((all_commits.into_iter().take(limit).collect(), has_more)) } -/// Resolves one commit and its parent metadata. +/// Resolves one commit, its parent metadata, and the message body after the subject. pub fn commit(request: GitCommitRequest) -> Result { let root = validate_root(&request.root)?; validate_revision(&request.commit)?; @@ -2067,8 +2071,13 @@ pub fn commit(request: GitCommitRequest) -> Result Result Option { + let (header, body) = output.split_once('\0')?; + let commit = parse_commit(header.trim_end_matches(['\r', '\n']))?; + Some(GitCommitLookupResponse { + commit, + // Git terminates `%b` with a line break; trailing blank lines carry no content. + body: body.trim_end().to_string(), + }) } /// Lists workspace-relative files changed by one commit. @@ -5944,18 +5960,40 @@ fn parse_commit(line: &str) -> Option { if columns.len() < 8 { return None; } + let (date, date_utc_offset_minutes) = split_commit_date_offset(columns[5]); Some(GitCommitResponse { hash: columns[0].to_string(), short_hash: columns[1].to_string(), parent_hashes: columns[2].split_whitespace().map(String::from).collect(), author_name: columns[3].to_string(), author_email: columns[4].to_string(), - date: columns[5].to_string(), + date: date.to_string(), + date_utc_offset_minutes, subject: columns[6].to_string(), decorations: columns[7].to_string(), }) } +/// Splits a `GIT_COMMIT_DATE_ARGUMENT` value into the displayed local date and +/// the author's UTC offset in minutes. Values without a valid `±HHMM` suffix are +/// kept whole with no offset so unexpected Git output stays visible. +fn split_commit_date_offset(value: &str) -> (&str, Option) { + let Some((date, zone)) = value.rsplit_once(' ') else { + return (value, None); + }; + let bytes = zone.as_bytes(); + if bytes.len() != 5 + || !matches!(bytes[0], b'+' | b'-') + || !zone[1..].bytes().all(|b| b.is_ascii_digit()) + { + return (value, None); + } + let hours: i32 = zone[1..3].parse().unwrap_or(0); + let minutes: i32 = zone[3..5].parse().unwrap_or(0); + let sign = if bytes[0] == b'-' { -1 } else { 1 }; + (date, Some(sign * (hours * 60 + minutes))) +} + fn validate_revision(value: &str) -> Result<(), CoreError> { if value.trim().is_empty() || value.starts_with('-') || value.contains('\0') { return Err(CoreError::new( @@ -6780,13 +6818,15 @@ fn relative_or_absolute(path: &Path, root: &Path) -> String { #[cfg(test)] mod tests { use super::{ - annotation_message_from_tag_object, line_similarity, pair_diff_entries, parse_diff, - pathspec_batches, simplified_canonical_path, structured_diff_from_output, DiffEntry, - GitCommandInvocation, GitCommandResponse, GitProcessOutput, MAX_ALIGNMENT_CELLS, + annotation_message_from_tag_object, line_similarity, pair_diff_entries, parse_commit, + parse_commit_lookup, parse_diff, pathspec_batches, simplified_canonical_path, + structured_diff_from_output, DiffEntry, GitCommandInvocation, GitCommandResponse, + GitProcessOutput, MAX_ALIGNMENT_CELLS, }; use crate::protocol::{ - CoreError, ErrorCode, GitCommitResponse, GitHistoryPageResponse, GitHistoryResponse, - GitPushPreviewResponse, GitPushTagResponse, GitReferenceResponse, GitReferencesResponse, + CoreError, ErrorCode, GitCommitLookupResponse, GitCommitResponse, GitHistoryPageResponse, + GitHistoryResponse, GitPushPreviewResponse, GitPushTagResponse, GitReferenceResponse, + GitReferencesResponse, }; use serde_json::Value; use std::path::PathBuf; @@ -7241,6 +7281,25 @@ mod tests { ); } + #[test] + fn parse_commit_splits_the_author_utc_offset_from_the_displayed_date() { + let line = + |date: &str| format!("h\u{1f}s\u{1f}\u{1f}A\u{1f}a@x\u{1f}{date}\u{1f}msg\u{1f}"); + + let east = parse_commit(&line("2026/09/30 21:05 +0800")).expect("commit should parse"); + assert_eq!(east.date, "2026/09/30 21:05"); + assert_eq!(east.date_utc_offset_minutes, Some(480)); + + let west = parse_commit(&line("2026/09/30 08:05 -0330")).expect("commit should parse"); + assert_eq!(west.date, "2026/09/30 08:05"); + assert_eq!(west.date_utc_offset_minutes, Some(-210)); + + // Output without a recognizable zone stays visible unchanged instead of being trimmed. + let legacy = parse_commit(&line("2026/09/30 08:05")).expect("commit should parse"); + assert_eq!(legacy.date, "2026/09/30 08:05"); + assert_eq!(legacy.date_utc_offset_minutes, None); + } + #[test] fn history_response_matches_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!(concat!( @@ -7278,6 +7337,7 @@ mod tests { author_name: "Lithe Test".into(), author_email: "test@example.invalid".into(), date: "2026/08/30 12:00".into(), + date_utc_offset_minutes: Some(480), subject: "Initial commit".into(), decorations: "HEAD -> feature/recent".into(), }], @@ -7347,6 +7407,7 @@ mod tests { author_name: "Lithe Test".into(), author_email: "test@example.invalid".into(), date: "2026/08/30 12:00".into(), + date_utc_offset_minutes: Some(480), subject: "Initial commit".into(), decorations: "HEAD -> feature/recent".into(), }], @@ -7361,6 +7422,54 @@ mod tests { ); } + #[test] + fn commit_lookup_response_matches_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/git/commit-lookup-response-v1.json" + ))) + .expect("Git commit lookup response fixture should be valid JSON"); + let response = GitCommitLookupResponse { + commit: GitCommitResponse { + hash: "0123456789abcdef0123456789abcdef01234567".into(), + short_hash: "0123456".into(), + parent_hashes: vec!["89abcdef0123456789abcdef0123456789abcdef".into()], + author_name: "Lithe Test".into(), + author_email: "test@example.invalid".into(), + date: "2026/08/30 12:00".into(), + date_utc_offset_minutes: Some(480), + subject: "Fix commit details".into(), + decorations: "HEAD -> feature/recent".into(), + }, + body: "Explain why the change is needed.\n\n- keep blank lines\n and indentation" + .into(), + }; + + assert_eq!( + serde_json::to_value(response).expect("Git commit lookup response should serialize"), + fixture + ); + } + + #[test] + fn commit_lookup_keeps_separator_characters_inside_the_body() { + // The body may contain the header's unit separator or CRLF line breaks; + // only the first NUL delimits the header from the free-form message. + let output = "abc\u{1f}ab\u{1f}p1 p2\u{1f}Dev\u{1f}dev@example.invalid\u{1f}2026/08/30 12:00\u{1f}Subject\u{1f}tag: v1\0Line \u{1f} one\r\n\r\nLine two\n\n"; + let lookup = parse_commit_lookup(output).expect("lookup output should parse"); + assert_eq!(lookup.commit.parent_hashes, ["p1", "p2"]); + assert_eq!(lookup.commit.subject, "Subject"); + assert_eq!(lookup.commit.decorations, "tag: v1"); + assert_eq!(lookup.body, "Line \u{1f} one\r\n\r\nLine two"); + + let subject_only = parse_commit_lookup( + "abc\u{1f}ab\u{1f}\u{1f}Dev\u{1f}dev@example.invalid\u{1f}2026/08/30 12:00\u{1f}Only\u{1f}\0", + ) + .expect("subject-only output should parse"); + assert_eq!(subject_only.body, ""); + assert!(parse_commit_lookup("missing delimiter").is_none()); + } + #[test] fn push_preview_response_matches_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!(concat!( @@ -7386,6 +7495,7 @@ mod tests { author_name: "Lithe Developer".into(), author_email: "developer@lithe.local".into(), date: "2026/08/31 10:30".into(), + date_utc_offset_minutes: Some(-300), subject: "Add push preview".into(), decorations: "HEAD -> feature/core".into(), }], diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index 9ca04202b..16957ad27 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -205,6 +205,8 @@ pub enum CoreCommand { LspDestroyServer, /// Validates layered run-configuration documents (`runConfig.inspect`). RunConfigInspect, + /// Selects an automatic project JDK (`runConfig.selectJava`). + RunConfigSelectJava, /// Regenerates detected run configurations (`runConfig.generate`). RunConfigGenerate, /// Merges configuration layers and toolchains (`runConfig.resolve`). @@ -420,6 +422,7 @@ impl CoreCommand { "lsp.waitEvents" => Some(Self::LspWaitEvents), "lsp.destroyServer" => Some(Self::LspDestroyServer), "runConfig.inspect" => Some(Self::RunConfigInspect), + "runConfig.selectJava" => Some(Self::RunConfigSelectJava), "runConfig.generate" => Some(Self::RunConfigGenerate), "runConfig.resolve" => Some(Self::RunConfigResolve), "runConfig.updateOptions" => Some(Self::RunConfigUpdateOptions), diff --git a/rust/lithe-core/src/protocol/contracts.rs b/rust/lithe-core/src/protocol/contracts.rs index dc03e484a..c7f7bcd26 100644 --- a/rust/lithe-core/src/protocol/contracts.rs +++ b/rust/lithe-core/src/protocol/contracts.rs @@ -626,7 +626,12 @@ pub struct GitCommitResponse { pub parent_hashes: Vec, pub author_name: String, pub author_email: String, + /// Author date as `YYYY/MM/DD HH:MM` in the author's own time zone. pub date: String, + /// Author's UTC offset in minutes (east positive) for `date`; `null` when + /// Git did not report one. Hosts compare it with the local offset before + /// presenting relative times. + pub date_utc_offset_minutes: Option, pub subject: String, pub decorations: String, } @@ -716,6 +721,10 @@ pub struct GitPushTagResponse { /// Exact lookup result for one commit. pub struct GitCommitLookupResponse { pub commit: GitCommitResponse, + /// Message text after the subject paragraph, as Git's `%b` placeholder, + /// with line breaks preserved and trailing whitespace removed. Empty when + /// the message has only a subject. History pages omit it to stay compact. + pub body: String, } #[derive(Debug, Clone, Serialize)] diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index e8837b007..77f51e1c5 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -1438,6 +1438,21 @@ fn execute(request: &str) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::RunConfigSelectJava => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid Java selection request") + .with_details(error.to_string()) + }) + .and_then(crate::execution::select_java) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Java selection encodes"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::RunConfigInspect => { match serde_json::from_value::(parsed.payload) .map_err(|error| { diff --git a/rust/lithe-core/src/tests/git.rs b/rust/lithe-core/src/tests/git.rs index 31075dfec..3197e9c1b 100644 --- a/rust/lithe-core/src/tests/git.rs +++ b/rust/lithe-core/src/tests/git.rs @@ -2994,6 +2994,180 @@ fn git_diff_and_apply_round_trip_a_patch() { fs::remove_dir_all(root).expect("temporary workspace should be removable"); } +/// Windows single-file reviews ask for Git's largest context so the renderer +/// can reveal folded unchanged lines (#557). The snapshot diff must accept that +/// value and return the complete file as one hunk. +#[test] +fn git_snapshot_diff_returns_the_whole_file_for_maximum_context() { + let root = temporary_root("git-full-context-diff"); + fs::create_dir_all(&root).expect("temporary workspace should be creatable"); + let run = |arguments: &[&str]| { + Command::new("git") + .args(arguments) + .current_dir(&root) + .output() + .expect("git should be available") + }; + assert!(run(&["init", "-q"]).status.success()); + assert!(run(&["config", "core.autocrlf", "false"]).status.success()); + assert!(run(&["config", "user.email", "test@example.com"]) + .status + .success()); + assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); + let original = (1..=200) + .map(|line| format!("line {line}\n")) + .collect::(); + fs::write(root.join("long.txt"), &original).expect("file should be writable"); + assert!(run(&["add", "long.txt"]).status.success()); + assert!(run(&["commit", "-qm", "initial"]).status.success()); + fs::write( + root.join("long.txt"), + original + .replace("line 10\n", "line ten\n") + .replace("line 190\n", "line one-ninety\n"), + ) + .expect("file should be writable"); + + let request = serde_json::json!({ + "id": "full-context-diff", + "command": "git.diff", + "payload": { + "root": root, + "pathspecs": ["long.txt"], + "worktreeSnapshot": true, + "contextLines": i32::MAX + } + }); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("diff request should encode"), + )) + .expect("diff response should be JSON"); + assert_eq!(response["ok"], true, "{response}"); + let patch = response["data"]["patch"] + .as_str() + .expect("diff output should be text"); + assert!(patch.contains("@@ -1,200 +1,200 @@"), "{patch}"); + assert!( + patch.contains("\n line 100\n"), + "middle context missing: {patch}" + ); + assert_eq!(response["data"]["hunks"].as_array().unwrap().len(), 1); + fs::remove_dir_all(root).expect("temporary workspace should be removable"); +} + +/// Windows diff reviews roll back one block by reverse-applying a patch the +/// host rebuilds from parsed lines (#688). Full-context reviews re-derive +/// blocks with three context lines and their own `@@` ranges, so the rebuilt +/// patch must still apply, restore only its block, and leave the index alone. +#[test] +fn git_apply_discard_reverts_one_rebuilt_hunk_and_keeps_the_index() { + struct RemoveOnDrop(std::path::PathBuf); + impl Drop for RemoveOnDrop { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + let root = temporary_root("git-discard-hunk"); + let _cleanup = RemoveOnDrop(root.clone()); + fs::create_dir_all(&root).expect("temporary workspace should be creatable"); + // Use the existing process adapter's deadline and process-tree cleanup; + // fixture setup must not hang on user hooks or commit signing prompts. + let run = |arguments: &[&str]| -> String { + let mut isolated_arguments = vec![ + "-c", + "core.hooksPath=disabled-fixture-hooks", + "-c", + "commit.gpgSign=false", + ]; + isolated_arguments.extend_from_slice(arguments); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "discard-hunk-setup", + "timeoutMilliseconds": 5_000, + "command": "git.command", + "payload": { "root": root, "arguments": isolated_arguments } + }) + .to_string(), + )) + .expect("setup response should be JSON"); + assert_eq!(response["ok"], true, "{response}"); + assert_eq!(response["data"]["exitCode"], 0, "{response}"); + response["data"]["stdout"] + .as_str() + .expect("stdout") + .to_string() + }; + run(&["init", "-q"]); + run(&["config", "core.autocrlf", "false"]); + run(&["config", "user.email", "test@example.invalid"]); + run(&["config", "user.name", "Lithe Test"]); + let original = (1..=40) + .map(|line| format!("line {line}\n")) + .collect::(); + fs::write(root.join("long.txt"), &original).expect("file should be writable"); + fs::write(root.join("mixed.txt"), "one\ntwo\nthree\n").expect("file should be writable"); + run(&["add", "."]); + run(&["commit", "-qm", "initial"]); + + let edited = original + .replace("line 5\n", "line five\n") + .replace("line 30\n", "line thirty\n"); + fs::write(root.join("long.txt"), &edited).expect("file should be writable"); + // Block shape the Windows adapter derives from a full-context review: the + // change at line 30 with three lines of context and a synthesized header. + let discard = |patch: &str| -> Value { + let request = serde_json::json!({ + "id": "discard-hunk", + "timeoutMilliseconds": 5_000, + "command": "git.apply", + "payload": { "root": root, "patch": patch, "mode": "discard" } + }); + serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("apply request should encode"), + )) + .expect("apply response should be JSON") + }; + let response = discard( + "diff --git a/long.txt b/long.txt\n--- a/long.txt\n+++ b/long.txt\n\ + @@ -27,7 +27,7 @@\n line 27\n line 28\n line 29\n-line 30\n+line thirty\n line 31\n line 32\n line 33\n", + ); + assert_eq!(response["ok"], true, "{response}"); + assert_eq!(response["data"]["exitCode"], 0, "{response}"); + assert_eq!( + fs::read_to_string(root.join("long.txt")).expect("file should be readable"), + original.replace("line 5\n", "line five\n"), + "only the selected block may be restored" + ); + + // A second click with the same, now stale block must fail without writing. + let stale = discard( + "diff --git a/long.txt b/long.txt\n--- a/long.txt\n+++ b/long.txt\n\ + @@ -27,7 +27,7 @@\n line 27\n line 28\n line 29\n-line 30\n+line thirty\n line 31\n line 32\n line 33\n", + ); + assert_ne!(stale["data"]["exitCode"], 0, "{stale}"); + assert_eq!( + fs::read_to_string(root.join("long.txt")).expect("file should be readable"), + original.replace("line 5\n", "line five\n") + ); + + // Index-to-worktree blocks of a file with staged edits leave the index intact. + fs::write(root.join("mixed.txt"), "ONE\ntwo\nthree\n").expect("file should be writable"); + run(&["add", "mixed.txt"]); + fs::write(root.join("mixed.txt"), "ONE\ntwo\nTHREE\n").expect("file should be writable"); + let response = discard( + "diff --git a/mixed.txt b/mixed.txt\n--- a/mixed.txt\n+++ b/mixed.txt\n\ + @@ -1,3 +1,3 @@\n ONE\n two\n-three\n+THREE\n", + ); + assert_eq!(response["data"]["exitCode"], 0, "{response}"); + assert_eq!( + fs::read_to_string(root.join("mixed.txt")).expect("file should be readable"), + "ONE\ntwo\nthree\n" + ); + let status = run(&["status", "--porcelain", "--", "mixed.txt"]); + assert_eq!(status, "M mixed.txt\n"); +} + #[test] fn git_diff_resolves_the_empty_tree_for_a_sha256_repository() { let root = temporary_root("git-diff-sha256-empty-tree"); @@ -3444,6 +3618,81 @@ fn git_history_page_treats_unborn_head_as_empty_history() { assert_eq!(response["data"]["hasMore"], false); } +/// Regression for #771: the commit detail view must receive the full multi-line +/// message, including text that contains the field separator used by the header, +/// while history pages keep returning only the subject. +#[test] +fn git_commit_returns_the_multi_line_body_separately_from_history_pages() { + struct RemoveOnDrop(std::path::PathBuf); + + impl Drop for RemoveOnDrop { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + let root = temporary_root("git-commit-body"); + let _cleanup = RemoveOnDrop(root.clone()); + fs::create_dir_all(&root).expect("temporary repository should be creatable"); + git_text(&root, &["init", "-q"]); + git_text(&root, &["config", "user.email", "test@example.invalid"]); + git_text(&root, &["config", "user.name", "Lithe Test"]); + // Keep the trailing blank lines so the test covers the trimming contract. + git_text(&root, &["config", "commit.cleanup", "verbatim"]); + let body = "First body line\n indented \u{1f} separator\n\n中文第二段"; + let message = format!("Fix commit details\n\n{body}\n\n"); + git_text( + &root, + &["commit", "-q", "--allow-empty", "-m", message.as_str()], + ); + git_text( + &root, + &["commit", "-q", "--allow-empty", "-m", "Subject only"], + ); + let detailed_hash = git_text(&root, &["rev-parse", "HEAD~1"]); + + let lookup = |id: &str, revision: &str| -> Value { + let request = serde_json::json!({ + "id": id, + "command": "git.commit", + "payload": {"root": root, "commit": revision} + }); + let response: Value = serde_json::from_str(&execute_json(&request.to_string())) + .expect("commit response should be JSON"); + assert_eq!(response["ok"], true, "{response:?}"); + response["data"].clone() + }; + + let detailed = lookup("detailed", &detailed_hash); + assert_eq!(detailed["commit"]["hash"], detailed_hash); + assert_eq!(detailed["commit"]["subject"], "Fix commit details"); + assert_eq!(detailed["body"], body); + + let subject_only = lookup("subject-only", "HEAD"); + assert_eq!(subject_only["commit"]["subject"], "Subject only"); + // The body follows the decorations column, so the header must still parse fully. + assert!(subject_only["commit"]["decorations"] + .as_str() + .is_some_and(|decorations| decorations.starts_with("HEAD -> "))); + assert_eq!(subject_only["body"], ""); + + let page_request = serde_json::json!({ + "id": "history-page-subjects", + "command": "git.historyPage", + "payload": {"root": root, "reference": "HEAD", "limit": 10} + }); + let page: Value = serde_json::from_str(&execute_json(&page_request.to_string())) + .expect("history page response should be JSON"); + assert_eq!(page["ok"], true, "{page:?}"); + let subjects = page["data"]["commits"] + .as_array() + .expect("history page should contain commits") + .iter() + .map(|commit| commit["subject"].as_str().expect("subject").to_string()) + .collect::>(); + assert_eq!(subjects, ["Subject only", "Fix commit details"]); +} + #[test] fn git_history_page_returns_disjoint_incremental_pages() { struct RemoveOnDrop(std::path::PathBuf); diff --git a/rust/lithe-core/src/tests/run_configuration.rs b/rust/lithe-core/src/tests/run_configuration.rs index ee17d6712..d9c66259d 100644 --- a/rust/lithe-core/src/tests/run_configuration.rs +++ b/rust/lithe-core/src/tests/run_configuration.rs @@ -3848,3 +3848,78 @@ fn working_directory_override_must_name_an_existing_project_directory() { serde_json::from_str(updated["data"]["document"].as_str().unwrap()).unwrap(); assert_eq!(document["configurations"][0]["cwd"], "backend"); } + +#[test] +fn automatic_java_selection_uses_shared_fixture_and_current_requirements() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/run-configuration/automatic-java-selection.json" + )) + .unwrap(); + let root = temporary_root("automatic-java-selection"); + fs::create_dir_all(root.join(".lithe/toolchains")).unwrap(); + struct Cleanup(PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + fs::remove_dir_all(&self.0).expect("remove selection fixture"); + } + } + let _cleanup = Cleanup(root.clone()); + let requirements = root.join(".lithe/toolchains/requirements.json"); + for case in fixture["cases"].as_array().unwrap() { + let document = serde_json::json!({"version":1,"toolchains":{"project-jdk":{ + "type":"java", "minimumVersion":case["minimum"] + }}}); + fs::write(&requirements, document.to_string()).unwrap(); + // Reverse enumeration order too: tie-breaking is not filesystem order. + for reverse in [false, true] { + let mut candidates = case["candidates"].as_array().unwrap().clone(); + if reverse { + candidates.reverse(); + } + let response: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id":"selection", "command":"runConfig.selectJava", "payload":{ + "root":root, "candidates":candidates, "fallbackId":case["fallbackId"] + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], true, "{response}"); + assert_eq!( + response["data"]["id"], + fixture["expected"][case["name"].as_str().unwrap()]["id"], + "{}", + case["name"] + ); + assert_eq!( + !response["data"]["warning"].is_null(), + fixture["expected"][case["name"].as_str().unwrap()]["warning"] + .as_bool() + .unwrap() + ); + } + assert_eq!( + fs::read_to_string(&requirements).unwrap(), + document.to_string() + ); + } + // Missing documents preserve fallback; malformed or future documents must + // not silently launch a potentially incompatible JDK. + for contents in [None, Some("{"), Some(r#"{"version":999,"toolchains":{}}"#)] { + if let Some(contents) = contents { + fs::write(&requirements, contents).unwrap(); + } else { + fs::remove_file(&requirements).unwrap(); + } + let response: Value = serde_json::from_str(&execute_json(&serde_json::json!({ + "id":"selection", "command":"runConfig.selectJava", "payload":{ + "root":root, "candidates":[{"id":"env","version":"1.8.0_402","priority":0}], "fallbackId":"env" + } + }).to_string())).unwrap(); + assert_eq!(response["ok"], contents.is_none(), "{response}"); + if contents.is_none() { + assert_eq!(response["data"]["id"], "env"); + } + } +} diff --git a/scripts/reuse-worktree-resources.mjs b/scripts/reuse-worktree-resources.mjs index 21badfe7a..69d5700a1 100644 --- a/scripts/reuse-worktree-resources.mjs +++ b/scripts/reuse-worktree-resources.mjs @@ -366,9 +366,9 @@ async function main() { const selected = options.resources.length > 0 ? options.resources.map((identifier) => { - // Runtime snapshots and isolated PHP packaging resources take this - // rejection route, never a content-hash reuse validator: the pinned npm - // archive alone does not identify the generated, signed plugin package. + // Runtime snapshots, per-execution Java launch files and isolated plugin + // packages take this rejection route, never a content-hash validator. + // Identical bytes do not establish transferable execution ownership. if (excludedResources.some((resource) => resource.id === identifier)) { const excluded = excludedResources.find((resource) => resource.id === identifier); throw new Error(`Resource ${identifier} is isolated (${excluded.locations.join(", ")}): ${excluded.reason}; it cannot be reused across worktrees`); diff --git a/scripts/test-lithe-issue-form-gate.mjs b/scripts/test-lithe-issue-form-gate.mjs new file mode 100644 index 000000000..f4f73a6c3 --- /dev/null +++ b/scripts/test-lithe-issue-form-gate.mjs @@ -0,0 +1,85 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { + ALLOWED_LABELS, GATE_MARKER, ISSUE_FORMS, decideIssueFormGate, gateComment, hasGateComment, isMaintainerPermission, labelNames, missingAllowedLabels, +} from '../.github/lithe-issue-form/logic.mjs'; + +const readPermission = { permission: 'read', role_name: 'read' }; +const issueBy = (login, overrides = {}) => ({ + number: 1, state: 'open', user: { login, type: 'User' }, labels: [], body: '', ...overrides, +}); +const decide = (issue, authorPermission, extra = {}) => + decideIssueFormGate({ issue, sender: { login: issue.user.login }, authorPermission, ...extra }); + +test('every issue form declares its gate label', () => { + for (const form of ISSUE_FORMS) { + const source = readFileSync(new URL(`../.github/ISSUE_TEMPLATE/${form.template}`, import.meta.url), 'utf8'); + assert.match(source, new RegExp(`^\\s*-\\s*${form.label}\\s*$`, 'm'), `${form.template} must list ${form.label}`); + } +}); + +test('non-maintainer issues from the bug and feature forms stay open', () => { + assert.deepEqual(decide(issueBy('alice', { labels: [{ name: 'bug' }, { name: 'issue-form:bug' }] }), readPermission), { action: 'allow', reason: 'issue-form' }); + assert.deepEqual(decide(issueBy('alice', { labels: [{ name: 'enhancement' }, { name: 'issue-form:feature' }] }), readPermission), { action: 'allow', reason: 'issue-form' }); +}); + +test('a blank non-maintainer issue is closed', () => { + assert.deepEqual(decide(issueBy('alice', { body: null }), readPermission), { action: 'close', reason: 'not-from-issue-form' }); +}); + +test('an API or CLI issue with a non-empty body but no form label is closed', () => { + const issue = issueBy('alice', { title: '[Bug] crash', body: 'please support this', labels: [{ name: 'bug' }] }); + assert.deepEqual(decide(issue, readPermission), { action: 'close', reason: 'not-from-issue-form' }); +}); + +test('regression #988: an outside user with an empty body is closed', () => { + const issue = issueBy('outside-user', { title: '能否做一个热更新的功能', body: null, labels: [{ name: 'review: low' }] }); + assert.equal(decide(issue, { permission: 'none', role_name: 'none' }).action, 'close'); +}); + +test('write, maintain, and admin authors may open blank issues', () => { + for (const level of ['write', 'maintain', 'admin']) { + assert.deepEqual(decide(issueBy('maintainer'), { permission: level, role_name: level }), { action: 'allow', reason: 'maintainer' }); + } + // Custom roles keep their base level in the legacy permission field. + assert.equal(decide(issueBy('custom'), { permission: 'write', role_name: 'release-manager' }).action, 'allow'); + assert.equal(isMaintainerPermission({ permission: 'read', role_name: 'triage' }), false); + assert.equal(isMaintainerPermission(null), false); +}); + +test('uncertain inputs never close an issue', () => { + assert.deepEqual(decide(issueBy('alice'), null), { action: 'skip', reason: 'permission-unknown' }); + assert.deepEqual(decide(issueBy('alice'), readPermission, { missingLabels: ['issue-form:bug'] }), { action: 'skip', reason: 'form-labels-missing' }); + assert.equal(decide(issueBy('alice', { state: 'closed' }), readPermission).action, 'skip'); + assert.equal(decide(issueBy('alice', { pull_request: { url: 'x' } }), readPermission).action, 'skip'); + assert.equal(decideIssueFormGate({ issue: issueBy('alice'), sender: { login: 'maintainer' }, authorPermission: readPermission }).reason, 'opened-by-another-actor'); + assert.equal(decideIssueFormGate({ issue: null }).action, 'skip'); +}); + +test('bot authors are allowed', () => { + assert.equal(decide(issueBy('dependabot[bot]', { user: { login: 'dependabot[bot]', type: 'Bot' } }), readPermission).action, 'allow'); +}); + +test('label helpers accept string and object labels', () => { + assert.deepEqual(labelNames(['bug', { name: 'issue-form:bug' }, {}, null]), ['bug', 'issue-form:bug']); + assert.deepEqual(missingAllowedLabels(['issue-form:bug']), ['issue-form:feature']); + assert.deepEqual(missingAllowedLabels(ALLOWED_LABELS), []); +}); + +test('the gate comment is bilingual, links every form, and never echoes issue text', () => { + const body = gateComment({ login: 'alice', serverUrl: 'https://github.com', owner: '1lck', repo: 'Lithe-IDEA' }); + assert.ok(body.startsWith(GATE_MARKER)); + assert.match(body, /@alice/); + assert.match(body, /Issue 表单/); + assert.match(body, /issue forms/); + assert.ok(body.includes('https://github.com/1lck/Lithe-IDEA/issues/new?template=bug_report.yml')); + assert.ok(body.includes('https://github.com/1lck/Lithe-IDEA/issues/new?template=feature_request.yml')); + assert.ok(body.includes('https://github.com/1lck/Lithe-IDEA/issues/new/choose')); +}); + +test('only the workflow bot comment counts as an existing gate comment', () => { + assert.equal(hasGateComment([{ user: { login: 'alice', type: 'User' }, body: GATE_MARKER }]), false); + assert.equal(hasGateComment([{ user: { login: 'github-actions[bot]', type: 'Bot' }, body: `${GATE_MARKER}\nclosed` }]), true); + assert.equal(hasGateComment([]), false); +}); diff --git a/scripts/test-lithe-issue-workflows.mjs b/scripts/test-lithe-issue-workflows.mjs new file mode 100644 index 000000000..7a4d223ea --- /dev/null +++ b/scripts/test-lithe-issue-workflows.mjs @@ -0,0 +1,128 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; +const workflowSource = (name) => readFileSync(new URL(`../.github/workflows/${name}.yml`, import.meta.url), 'utf8'); +const gateSource = workflowSource('lithe-issue-form-gate'); +const prioritySource = workflowSource('lithe-issue-priority'); +const logicUrl = new URL('../.github/lithe-issue-form/logic.mjs', import.meta.url).href; + +// Execute the actual checked-in github-script body with deterministic API doubles. +// Pass a local process double so tests do not mutate shared environment variables. +async function run(source, fixture) { + const marker = ' script: |\n'; + assert.equal(source.split(marker).length, 2); + const body = source.split(marker)[1].split('\n').map((line) => line.slice(12)).join('\n'); + await new AsyncFunction('github', 'context', 'core', 'process', body)( + fixture.github, fixture.context, { info() {}, warning() {} }, + { env: { ISSUE_FORM_LOGIC: logicUrl } }, + ); +} + +function fixture({ archived = false, permission = 'read', onRead = () => {}, sender = 'alice' } = {}) { + const issue = { number: 1, state: 'open', user: { login: 'alice', type: 'User' }, labels: [], title: '[Bug] example', body: '### Priority / 优先级\nP1' }; + const context = { repo: { owner: 'example', repo: 'test' }, issue: { number: 1 }, payload: { issue: structuredClone(issue), sender: { login: sender } }, serverUrl: 'https://github.com' }; + const comments = []; + const removals = []; + let reads = 0; + const github = { + rest: { + issues: { + get: async () => { + const snapshot = structuredClone(issue); + onRead(issue, ++reads); + return { data: snapshot }; + }, + getLabel: async () => ({ data: { archived_at: archived ? '2026-01-01T00:00:00Z' : null } }), + addLabels: async ({ labels }) => { + for (const name of labels) if (!issue.labels.some((label) => label.name === name)) issue.labels.push({ name }); + }, + removeLabel: async ({ name }) => { + removals.push(name); + issue.labels = issue.labels.filter((label) => label.name !== name); + }, + listComments: async () => ({ data: comments }), + createComment: async ({ body }) => comments.push({ user: { login: 'github-actions[bot]', type: 'Bot' }, body }), + update: async ({ state, state_reason }) => Object.assign(issue, { state, state_reason }), + }, + repos: { getCollaboratorPermissionLevel: async () => ({ data: { permission, role_name: permission } }) }, + }, + paginate: async (method, args) => (await method(args)).data, + }; + return { issue, context, github, comments, removals }; +} + +test('author close then reopen is checked again, without duplicating comments', async () => { + assert.match(gateSource, /types: \[opened, reopened\]/); + const f = fixture(); + f.issue.state = 'closed'; + await run(gateSource, f); + assert.equal(f.comments.length, 0); + f.context.payload.action = 'reopened'; + f.issue.state = 'open'; + await run(gateSource, f); + assert.equal(f.issue.state, 'closed'); + assert.equal(f.issue.state_reason, 'not_planned'); + assert.equal(f.comments.length, 1); + f.issue.state = 'open'; + await run(gateSource, f); + assert.equal(f.issue.state, 'closed'); + assert.equal(f.comments.length, 1); +}); + +test('another actor reopening an external issue is respected', async () => { + const f = fixture({ sender: 'maintainer' }); + f.context.payload.action = 'reopened'; + await run(gateSource, f); + assert.equal(f.issue.state, 'open'); + assert.equal(f.comments.length, 0); +}); + +test('archived form labels disable closing even when getLabel succeeds', async () => { + const f = fixture({ archived: true }); + await run(gateSource, f); + assert.equal(f.issue.state, 'open'); + assert.equal(f.comments.length, 0); +}); + +test('priority synchronization preserves form labels applied after its read', async () => { + // opened payload and priority snapshot have no form label. GitHub applies it + // before priority writes; gate starts only after priority has finished. + const f = fixture({ onRead(issue, count) { + if (count === 1) issue.labels.push({ name: 'issue-form:bug' }); + } }); + f.issue.labels.push({ name: 'P2' }, { name: 'claimed' }); + await run(prioritySource, f); + assert.deepEqual(f.removals, ['P2']); + assert.deepEqual(f.issue.labels.map((label) => label.name).sort(), ['P1', 'bug', 'claimed', 'issue-form:bug']); + await run(gateSource, f); + assert.equal(f.issue.state, 'open'); + assert.equal(f.comments.length, 0); +}); + +test('a form label appearing between gate reads prevents closing', async () => { + const f = fixture({ onRead(issue, count) { + if (count === 1) issue.labels.push({ name: 'issue-form:feature' }); + } }); + await run(gateSource, f); + assert.equal(f.issue.state, 'open'); + assert.equal(f.comments.length, 0); +}); + +test('permission lookup failure leaves the issue open', async () => { + const f = fixture(); + f.github.rest.repos.getCollaboratorPermissionLevel = async () => { throw new Error('unavailable'); }; + await run(gateSource, f); + assert.equal(f.issue.state, 'open'); + assert.equal(f.comments.length, 0); +}); + +test('maintainer authors can still submit without form labels', async () => { + for (const permission of ['write', 'maintain', 'admin']) { + const f = fixture({ permission }); + await run(gateSource, f); + assert.equal(f.issue.state, 'open'); + assert.equal(f.comments.length, 0); + } +}); diff --git a/scripts/test-reuse-worktree-resources.mjs b/scripts/test-reuse-worktree-resources.mjs index 4dd984dcd..77a2cbbde 100644 --- a/scripts/test-reuse-worktree-resources.mjs +++ b/scripts/test-reuse-worktree-resources.mjs @@ -64,6 +64,14 @@ function reuse(extraArguments = []) { } try { + await test("Java launch files are never listed or copied between worktrees", { timeout: 15000 }, () => { + const listed = run(process.execPath, [reuseScript, "--list"]); + assertSucceeded(listed); + assert.ok(!listed.stdout.includes("java-launch-temporaries")); + const refused = reuse(["--resource", "java-launch-temporaries"]); + assert.notEqual(refused.status, 0); + assert.match(diagnostics(refused), /java-launch-temporaries.*classpath\.jar.*cannot be reused/); + }); await test("IDE MCP credentials and helpers cannot cross worktrees", { timeout: 15000 }, () => { const refused = reuse(["--resource", "ide-mcp"]); assert.notEqual(refused.status, 0); diff --git a/scripts/verify-windows-boundaries.sh b/scripts/verify-windows-boundaries.sh index 6465443d5..729a729fb 100755 --- a/scripts/verify-windows-boundaries.sh +++ b/scripts/verify-windows-boundaries.sh @@ -10,9 +10,12 @@ if find windows \ exit 1 fi +# The exemption pattern accepts backslash separators (ripgrep emits them on +# Windows) and starts with a character class so Git Bash does not rewrite a +# leading-slash argument into a Windows path. direct_imports=$(rg -l 'from "@tauri-apps/api/core"' windows/tauri/src \ --glob '*.ts' --glob '*.tsx' | \ - rg -v '/(core/lithe-core-client|platform/tauri-core)\.ts$' || true) + rg -v '[/\\](core[/\\]lithe-core-client|platform[/\\]tauri-core)\.ts$' || true) if [[ -n "$direct_imports" ]]; then echo "Frontend modules must use @/platform/tauri-core:" >&2 echo "$direct_imports" >&2 diff --git a/scripts/worktree-resources.json b/scripts/worktree-resources.json index 5fce253c3..0e25cd98c 100644 --- a/scripts/worktree-resources.json +++ b/scripts/worktree-resources.json @@ -117,6 +117,16 @@ ], "identity": "Helper executable is tied to source, Cargo.lock, target triple and signing; runtime connection descriptors contain ephemeral bearer credentials and workspace ownership locks. No reusable build identity stamp.", "reason": "Build helpers separately before packaging; never copy runtime connection files or locks. Installed helpers remain read-only. There is no permitted worktree copy stage." + }, + { + "id": "java-launch-temporaries", + "locations": [ + "/lithe-run/launch--.argfile", + "/lithe-run/launch--.classpath.jar" + ], + "reusable": false, + "identity": "Owned by one Java execution: absolute classpath, working directory, JDK version and native encoding. No reusable version/platform/architecture/toolchain identity stamp.", + "reason": "Created exclusively by the platform launch adapter and deleted on preparation failure, cancelled launch, spawn failure or process exit. Never copy, publish or share between worktrees at any stage; installed bundles and JDK directories remain read-only." } ] } diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index b4d72a4d7..1297342b1 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -38,6 +38,18 @@ quoted values, since the native argument-file parser processes bytes. Every execution owns an exclusively created temporary file; partial writes and spawn failures clean it up, while successful launches retain it until that exact process exits. A replacement execution never shares its predecessor's file. +For a known JDK older than 9, Rust hosts call +`lithe_core::execution::plan_classpath_jar_launch` instead. Under the same +command-line budget it replaces the effective `-cp`/`-classpath` value with a +host-owned JAR path and returns the ASCII `META-INF/MANIFEST.MF` text: the +`Class-Path` header lists every entry, in order, as an absolute percent-encoded +UTF-8 `file:` URL (directories end in `/`), wrapped at 72 bytes. The host +answers whether each entry is a directory and writes the manifest-only JAR with +the same exclusive temporary-file lifecycle. Wildcard entries, drive-relative +Windows entries, `-jar` launches, and unknown JDK versions stay direct. This is +a Rust API only; there is no JSON command yet because the macOS process +argument limit (`ARG_MAX`) is far above the Windows cap, so a direct JDK 8 +launch already succeeds there. Strings returned by the core are UTF-8 JSON allocated by Rust. The caller must release response strings with `lithe_core_free_string`. @@ -384,6 +396,7 @@ package manager owns the download and Lithe does not infer bytes from logs. | `java.structure` | Parse Java editor folds, inlay hints, and portable syntax roles | | `spring.index` | Build a deterministic Spring configuration, bean, injection, and endpoint index | | `mybatis.index` | Build a deterministic MyBatis mapper-interface and XML statement index | +| `runConfig.selectJava` | Select a project-compatible automatic JDK from platform-probed candidates | | `runConfig.inspect` | Inspect `.lithe` run documents, versions, and staleness without writing files | | `runConfig.generate` | Generate deterministic Java/Maven configurations and toolchain requirements | | `runConfig.resolve` | Merge generated, project, and local layers and return diagnostics | @@ -417,10 +430,10 @@ package manager owns the download and Lithe does not infer bytes from logs. | `git.apply` | Apply or check a patch in `stage`, `unstage`, `discard`, or Shelf restore mode | | `git.history` | Return the legacy combined reference snapshot and first bounded commit page | | `git.references` | Return deterministic refs, recent local branches, ahead/behind state, and effective Git identity without scanning commit history | -| `git.historyPage` | Return one bounded commit page, parent hashes, decorations, and an opaque continuation cursor | +| `git.historyPage` | Return one bounded commit page, parent hashes, decorations, author dates with their UTC offset (`dateUtcOffsetMinutes`, east positive, `null` when unknown), and an opaque continuation cursor | | `git.historyCursorClose` | Release an unfinished incremental history cursor and its Git process | | `git.pushPreview` | Resolve a local branch push destination and the bounded commits not present on that remote base | -| `git.commit` | Return one structured commit by revision | +| `git.commit` | Return one structured commit by revision with its full message body | | `git.commitFiles` | Return files changed by one commit | | `git.comparison` | Return files changed between a reference and the working tree | | `git.stashes` | Return structured stash references and messages | @@ -1149,7 +1162,13 @@ For compatibility, a request that explicitly contains the deprecated numeric `offset`; repository size does not select between the two protocols. -`git.commit` accepts `root` and a revision, returning one `commit` object. +`git.commit` accepts `root` and a revision, returning one `commit` object with +the same fields as a history page entry and a `body` string. `body` is the +message after the subject paragraph (Git `%b`), keeps internal line breaks and +indentation, has trailing whitespace removed, and is empty for a subject-only +message. History pages carry only `subject`; a commit detail view reads the +body on demand with `git.commit`. See +`shared/fixtures/git/commit-lookup-response-v1.json`. `git.blame` accepts `root` and a workspace-relative `path`; its line numbers are one-based and author timestamps are Unix seconds. @@ -1829,6 +1848,27 @@ the response preserves the path text, uses one-based line and column values, and normalizes severity to `error` or `warning`. Duplicate issue lines are removed deterministically. +`runConfig.selectJava` reads only the existing workspace +`.lithe/toolchains/requirements.json` document. Its request contains optional +`root`, `candidates` (`id`, probed `version`, numeric source `priority`), and +`fallbackId`. IDs are opaque machine-local identities, never persisted or opened +by this operation. Lower priority wins; equal-priority candidates use descending +numeric Java versions (including legacy `1.8`), then ascending ID. + +When `project-jdk.minimumVersion` exists, selection first filters using the same +Java version comparison as run-configuration diagnostics. The response is +`{ id, warning }`: the compatible candidate, or the supplied usable fallback +with an actionable warning if none qualifies. Missing requirements retain the +platform's unconstrained choice; malformed/unsupported documents return the +existing parse/version error. Explicit configured paths bypass automatic +selection. The operation never generates requirements or probes executables. +The cross-platform examples are in +`shared/fixtures/run-configuration/automatic-java-selection.json`. + +Windows `run_resolve_toolchains` includes an optional `warning` on resolved +JDKs, including inherited Maven JDKs. This warning remains visible even when no +run configuration exists to carry a scoped `toolchainVersionMismatch` diagnostic. + `runConfig.inspect` also returns the local document-level `toolchain`, including when no generated configuration exists (`status: "missing"`). Settings, toolchain-only callers, and initial run-panel presentation may send diff --git a/shared/fixtures/git/commit-lookup-response-v1.json b/shared/fixtures/git/commit-lookup-response-v1.json new file mode 100644 index 000000000..ddb22ca38 --- /dev/null +++ b/shared/fixtures/git/commit-lookup-response-v1.json @@ -0,0 +1,14 @@ +{ + "commit": { + "hash": "0123456789abcdef0123456789abcdef01234567", + "shortHash": "0123456", + "parentHashes": ["89abcdef0123456789abcdef0123456789abcdef"], + "authorName": "Lithe Test", + "authorEmail": "test@example.invalid", + "date": "2026/08/30 12:00", + "dateUtcOffsetMinutes": 480, + "subject": "Fix commit details", + "decorations": "HEAD -> feature/recent" + }, + "body": "Explain why the change is needed.\n\n- keep blank lines\n and indentation" +} diff --git a/shared/fixtures/git/history-page-response-v1.json b/shared/fixtures/git/history-page-response-v1.json index 8d8f14bdf..3fd5d9581 100644 --- a/shared/fixtures/git/history-page-response-v1.json +++ b/shared/fixtures/git/history-page-response-v1.json @@ -7,6 +7,7 @@ "authorName": "Lithe Test", "authorEmail": "test@example.invalid", "date": "2026/08/30 12:00", + "dateUtcOffsetMinutes": 480, "subject": "Initial commit", "decorations": "HEAD -> feature/recent" } diff --git a/shared/fixtures/git/history-response-v1.json b/shared/fixtures/git/history-response-v1.json index a5e303309..4efa5904a 100644 --- a/shared/fixtures/git/history-response-v1.json +++ b/shared/fixtures/git/history-response-v1.json @@ -51,6 +51,7 @@ "authorName": "Lithe Test", "authorEmail": "test@example.invalid", "date": "2026/08/30 12:00", + "dateUtcOffsetMinutes": 480, "subject": "Initial commit", "decorations": "HEAD -> feature/recent" } diff --git a/shared/fixtures/git/push-preview-v1.json b/shared/fixtures/git/push-preview-v1.json index 49125b431..9a11e7246 100644 --- a/shared/fixtures/git/push-preview-v1.json +++ b/shared/fixtures/git/push-preview-v1.json @@ -19,6 +19,7 @@ "authorName": "Lithe Developer", "authorEmail": "developer@lithe.local", "date": "2026/08/31 10:30", + "dateUtcOffsetMinutes": -300, "subject": "Add push preview", "decorations": "HEAD -> feature/core" } diff --git a/shared/fixtures/run-configuration/automatic-java-selection.json b/shared/fixtures/run-configuration/automatic-java-selection.json new file mode 100644 index 000000000..8248fb335 --- /dev/null +++ b/shared/fixtures/run-configuration/automatic-java-selection.json @@ -0,0 +1,216 @@ +{ + "version": 1, + "cases": [ + { + "name": "skip-incompatible-java-home", + "minimum": "17", + "candidates": [ + { + "id": "env", + "version": "1.8.0_402", + "priority": 0 + }, + { + "id": "path", + "version": "17.0.12", + "priority": 1 + }, + { + "id": "latest", + "version": "21.0.4", + "priority": 2 + } + ], + "fallbackId": "env" + }, + { + "name": "compatible-java-home-wins", + "minimum": "17", + "candidates": [ + { + "id": "env", + "version": "17.0.12", + "priority": 0 + }, + { + "id": "latest", + "version": "21.0.4", + "priority": 2 + } + ], + "fallbackId": "env" + }, + { + "name": "numeric-version-order", + "minimum": "8", + "candidates": [ + { + "id": "eight", + "version": "8.0.402", + "priority": 2 + }, + { + "id": "twenty-one", + "version": "21.0.4", + "priority": 2 + }, + { + "id": "seventeen", + "version": "17.0.12", + "priority": 2 + } + ], + "fallbackId": "eight" + }, + { + "name": "numeric-patch-order", + "minimum": "17", + "candidates": [ + { + "id": "nine", + "version": "17.0.9", + "priority": 2 + }, + { + "id": "ten", + "version": "17.0.10", + "priority": 2 + } + ], + "fallbackId": "nine" + }, + { + "name": "legacy-java-eight", + "minimum": "8", + "candidates": [ + { + "id": "env", + "version": "1.8.0_402", + "priority": 0 + }, + { + "id": "latest", + "version": "21.0.4", + "priority": 2 + } + ], + "fallbackId": "env" + }, + { + "name": "stable-tie", + "minimum": "17", + "candidates": [ + { + "id": "z", + "version": "17.0.12", + "priority": 2 + }, + { + "id": "a", + "version": "17.0.12", + "priority": 2 + } + ], + "fallbackId": "z" + }, + { + "name": "unsatisfied-retains-fallback", + "minimum": "21", + "candidates": [ + { + "id": "env", + "version": "8.0.402", + "priority": 0 + }, + { + "id": "other", + "version": "17.0.12", + "priority": 2 + } + ], + "fallbackId": "env" + }, + { + "name": "missing-requirement-retains-fallback", + "minimum": null, + "candidates": [ + { + "id": "env", + "version": "8.0.402", + "priority": 0 + }, + { + "id": "latest", + "version": "21.0.4", + "priority": 2 + } + ], + "fallbackId": "env" + }, + { + "name": "unknown-version-is-not-compatible", + "minimum": "17", + "candidates": [ + { + "id": "env", + "version": "", + "priority": 0 + }, + { + "id": "other", + "version": "17.0.12", + "priority": 2 + } + ], + "fallbackId": "env" + }, + { + "name": "no-runtimes", + "minimum": "17", + "candidates": [], + "fallbackId": null + } + ], + "expected": { + "skip-incompatible-java-home": { + "id": "path", + "warning": false + }, + "compatible-java-home-wins": { + "id": "env", + "warning": false + }, + "numeric-version-order": { + "id": "twenty-one", + "warning": false + }, + "numeric-patch-order": { + "id": "ten", + "warning": false + }, + "legacy-java-eight": { + "id": "env", + "warning": false + }, + "stable-tie": { + "id": "a", + "warning": false + }, + "unsatisfied-retains-fallback": { + "id": "env", + "warning": true + }, + "missing-requirement-retains-fallback": { + "id": "env", + "warning": false + }, + "unknown-version-is-not-compatible": { + "id": "other", + "warning": false + }, + "no-runtimes": { + "id": null, + "warning": true + } + } +} diff --git a/shared/platform-feature-matrix.json b/shared/platform-feature-matrix.json index 2e75a51bc..ad707092a 100644 --- a/shared/platform-feature-matrix.json +++ b/shared/platform-feature-matrix.json @@ -569,13 +569,15 @@ "windows": { "evidence": [ "windows/tauri/src/features/editor", - "windows/tauri/src/features/tabs" + "windows/tauri/src/features/tabs", + "windows/tauri/src/features/tabs/components/tab-context-menu.tsx", + "windows/tauri/src/features/tabs/components/tab-context-menu.test.ts" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Editor", - "verification": "打开、编辑、保存、关闭和恢复多个文本文件,确认光标、脏状态和标签状态;在 macOS 打开无扩展名文本文件,确认标签与项目树图标一致;从外部改为二进制后折叠并展开项目树,确认两处图标更新一致。" + "verification": "打开、编辑、保存、关闭和恢复多个文本文件,确认光标、脏状态和标签状态;右键标签页确认关闭分组排在其他操作之前;在 macOS 打开无扩展名文本文件,确认标签与项目树图标一致;从外部改为二进制后折叠并展开项目树,确认两处图标更新一致。" }, { "id": "editor-pointer-selection", @@ -627,6 +629,31 @@ "owner": "Editor", "verification": "分别打开 Java、Markdown 和普通文本,确认语言识别、语法高亮和模型切换;开启缩略图并滚动,确认滚动条轨道不透出编辑器代码。" }, + { + "id": "editor-macos-ime-presentation", + "area": "编辑器", + "group": "文本编辑", + "capability": "macOS 中文组合输入层遮住原文字,输入和删除候选文字时不透出整行重影", + "macos": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "macos/EditorFrontend/ime-input.css", + "macos/EditorFrontend/main.ts", + "macos/Experiments/Monaco/ime-input.integration.ts" + ] + }, + "windows": { + "implementationStatus": "platform-specific", + "verificationStatus": "not-applicable", + "evidence": [ + "windows/tauri/src/features/editor/components/monaco-editor.tsx" + ] + }, + "owner": "Editor", + "notes": "修正 macOS 原生主题半透明当前行底色作为 Monaco 输入法覆盖层底色时的字形透出。Linux Chromium 的真实 Monaco 组合事件样式回归通过;macOS 原生输入法及删除键实机验证待完成。Windows 不加载此 macOS 样式。", + "verification": "运行 scripts/probe-macos-monaco.sh --workbench-tests。在 macOS 使用系统拼音与微信输入法,在含中文和英文的同一行中间输入、删减候选拼音、确认上屏,再按删除键;检查整行无重影、候选与光标正常、撤销恢复文本。覆盖明暗主题、背景图开关和分屏,结束输入后背景恢复。" + }, { "id": "editor-wallpaper-overview-ruler", "area": "编辑器", @@ -898,7 +925,7 @@ "verificationStatus": "pending" }, "owner": "Git", - "verification": "验证新增、删除、重命名、二进制和多文件 Diff 的展示与定位;从源代码管理打开已修改和未跟踪文件的工作区 Diff 后保持静止,确认 Diff 不会自动关闭,且只在文件不再出现在 Git 状态中时关闭。" + "verification": "验证新增、删除、重命名、二进制和多文件 Diff 的展示与定位;从源代码管理打开已修改和未跟踪文件的工作区 Diff 后保持静止,确认 Diff 不会自动关闭,且只在文件不再出现在 Git 状态中时关闭;在 Windows 单文件工作区 Diff 中点击折叠条展开省略的未改动代码,确认 Diff 保持打开且每个修改块仍可单独暂存;在 Windows 未暂存单文件 Diff 中点击某个修改块的“回滚”,确认弹出确认框、取消不改文件、确认后只恢复该块且暂存区不变,最后一块回滚后文件从状态中消失时 Diff 关闭;暂存区、历史、分支比较与新增/未跟踪文件不显示回滚按钮。" }, { "id": "git-history", @@ -920,13 +947,18 @@ "windows/tauri/src/features/git", "windows/tauri/src/features/git/utils/git-graph-layout.ts", "windows/tauri/src/features/git/utils/git-graph-colors.ts", - "windows/tauri/src/features/git/utils/git-graph-layout.test.ts" + "windows/tauri/src/features/git/utils/git-graph-layout.test.ts", + "windows/tauri/src/features/git/components/log/git-commit-details.tsx", + "windows/tauri/src/features/git/components/log/git-commit-details.test.tsx", + "windows/tauri/src/features/git/hooks/use-git-diff-actions.test.tsx", + "windows/tauri/src/features/git/hooks/use-commit-file-preview.ts", + "windows/tauri/src/features/git/hooks/use-commit-file-preview.test.tsx" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Git", - "verification": "分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端保留各自调色板,不要求 RGB 值一致。" + "verification": "分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;在 Windows Git Log 选中带多行正文的提交,确认右侧详情在标题下方完整显示正文(保留空行与缩进),复制提交信息和打开提交 Diff 时也包含正文;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端保留各自调色板,不要求 RGB 值一致。 读取提交正文期间切换仓库、重新打开另一提交或关闭面板,确认迟到结果不会打开旧 Diff 或覆盖新请求的加载状态。 Windows 另验证:单击提交后编辑区预览该提交第一个文件的 diff;单击或双击 Commit files 中的文件只显示该文件的 diff(不含提交说明,也不显示文件列表),并复用同一个预览标签;Git Log 刚打开、尚未点击提交时不会自动打开 diff;日期列对与本机同一时区的提交显示相对时间(刚刚、N 分钟前、今天/昨天 + 12 小时制,中文为上午/下午),其他时区或未知时区的提交只显示日期,鼠标移入行时刷新相对时间;作者/日期列可在行内列边缘拖动调整宽度并在重启后保留。 非连续多选提交修改同一路径时,预览保留每个提交的 diff 区段及提交标识;延迟请求返回前取消选择、隐藏 Git Log、切换仓库或卸载工具窗,确认旧请求不会重新打开或激活预览标签。" }, { "id": "git-rebase-stash", @@ -1137,11 +1169,14 @@ "id": "java-runtime-discovery", "area": "Java", "group": "Java/Maven", - "capability": "JDK 与 Maven 工具链发现", + "capability": "JDK 与 Maven 工具链发现及按项目要求自动选择 JDK", "macos": { "evidence": [ "macos/Sources/Lithe/Services/Java", - "macos/Sources/Lithe/Views/Run" + "macos/Sources/Lithe/Views/Run", + "macos/Sources/Lithe/Core/Ports/RuntimeLocator.swift", + "macos/Tests/LitheTests/MavenRuntimeTests.swift", + "rust/lithe-core/src/execution/java_selection.rs" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -1149,13 +1184,15 @@ "windows": { "evidence": [ "windows/tauri/src/features/maven", - "windows/tauri/src/features/workspace" + "windows/tauri/src/features/workspace", + "windows/tauri/src-tauri/src/run.rs", + "shared/fixtures/run-configuration/automatic-java-selection.json" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Java / Maven", - "verification": "配置多个 JDK/Maven 候选,验证发现、选择、版本不匹配和错误提示。" + "verification": "两端安装 JDK 8/17/21:项目要求 17 且 JAVA_HOME 为 8 时验证自动选择兼容版本;核对设置、Run、Maven 目标、测试使用同一 JDK。验证显式选择不变、没有兼容版本时回退并提示,以及无要求时的平台默认顺序。" }, { "id": "java-maven-project-settings", @@ -1607,13 +1644,15 @@ "windows": { "evidence": [ "windows/tauri/src-tauri/src/run/launch_arguments.rs", - "rust/lithe-core/src/execution/launch_command.rs" + "rust/lithe-core/src/execution/launch_command.rs", + "rust/lithe-core/src/execution/classpath_jar.rs", + "windows/tauri/src-tauri/src/run.rs" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Run", - "verification": "使用包含大量依赖的 Java 项目运行 main,确认超长类路径自动写入参数文件、进程可启动且参数文件在退出后清理。" + "verification": "使用包含大量依赖的 Java 项目运行 main,确认超长类路径自动写入参数文件、进程可启动且参数文件在退出后清理。 Windows 上分别用 JDK 8 和 JDK 17 运行同一个依赖很多、路径含空格和中文的项目 main:JDK 8 应在临时目录生成只含 META-INF/MANIFEST.MF 的 .classpath.jar 并以 -cp 引用,JDK 17 仍使用 @argfile;两者都能启动,进程退出或启动失败后临时文件被删除,安装目录与 JDK 目录不新增文件。 在慢速网络盘 classpath 准备期间确认工作台可交互;停止或重新运行后,旧准备结果不得启动进程,旧 executionId 的停止请求不得影响新执行。" }, { "id": "debug-breakpoints", @@ -1712,7 +1751,7 @@ "verificationStatus": "pending" }, "owner": "Terminal", - "verification": "创建多个终端会话并切换、重命名、关闭,确认子进程生命周期和资源清理;Windows 横向标签栏的新建终端与 Shell 配置按钮紧跟最后一个标签,标签溢出时按钮保持可见;标签栏右侧最小化按钮隐藏终端面板且会话继续运行,重新打开时恢复为停靠状态。" + "verification": "创建多个终端会话并切换、重命名、关闭,确认子进程生命周期和资源清理;Windows 横向标签栏的新建终端与 Shell 配置按钮紧跟最后一个标签,标签溢出时按钮保持可见;标签栏右侧最小化按钮隐藏终端面板且会话继续运行,重新打开时恢复为停靠状态;Windows 打开底部终端面板时默认通栏(与 IDEA 一致):面板位于左右两条活动栏之间并横跨侧边栏与编辑器下方(活动栏保持通高,底部的活动栏按钮位置不上移),侧边栏让出纵向空间,且仍可通过面板菜单切换为编辑器宽度。" }, { "id": "terminal-interaction", @@ -2245,6 +2284,31 @@ "owner": "Workbench", "verification": "在 Windows 工作台点击标题栏左端菜单按钮,确认汉堡按钮由“文件”菜单项取代,“文件”项处于选中状态且其下拉菜单立即展开;菜单与标题栏同高,不覆盖编辑区域,项目/分支控件在展开时隐藏。检查常驻品牌标识与下方活动栏图标列对齐且不可选中,并确认项目菜单显示当前项目图标(无自定义图标时显示项目缩写)。点击其他菜单项时菜单条保持展开并执行动作;点击工作区、标题栏空白和右侧控件或按 Escape 后收起;按 Escape 后确认焦点返回菜单入口;窄窗口确认菜单在标题栏内水平滚动且不挤压窗口控件。macOS 保持系统菜单栏行为。" }, + { + "id": "workbench-settings-entry", + "area": "工作台", + "group": "命令与布局", + "capability": "从工作台 chrome 打开设置:Windows 点击标题栏搜索按钮右侧的齿轮,macOS 通过应用菜单及面板内入口", + "macos": { + "evidence": [ + "macos/Sources/Lithe/Models/LitheAction.swift", + "macos/Sources/Lithe/Views/App/SettingsView.swift" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "windows": { + "evidence": [ + "windows/tauri/src/features/window/components/title-bar/title-bar.tsx", + "windows/tauri/src/features/layout/components/sidebar/sidebar-pane-selector.tsx", + "windows/tauri/src/features/layout/config/item-order.ts" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "owner": "Workbench", + "verification": "在 Windows 打开工作区,确认左侧活动栏底部不再显示设置齿轮,活动栏右键“在活动栏中显示”列表不包含设置项;点击标题栏右上搜索按钮右侧的齿轮打开设置对话框并再次关闭;携带旧版设置文件(活动栏排序或隐藏列表含 settings)启动后确认遗留条目被清理且活动栏显示正常。macOS 通过应用菜单和各面板入口打开设置保持不变。" + }, { "id": "window-controls", "area": "工作台", @@ -2961,6 +3025,76 @@ }, "owner": "Run/Debug", "verification": "在双端运行 Spring Boot HTTP 服务,修改方法体并更新,检查响应、PID、DevTools 日志与断点暂停状态;覆盖编译失败、JVM 拒绝结构修改、连续点击、停止/重启、依赖路径变化。对比运行前后安装目录清单/哈希。Linux 仅完成共享 Core 和前端测试,原生验证待完成。" + }, + { + "id": "editor-git-gutter-peek", + "area": "编辑器", + "group": "Git 变更", + "capability": "编辑器 Git 行变更标记、点击预览与变更导航", + "macos": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "macos/Sources/Lithe/Views/Editor/CodeEditorView.swift" + ] + }, + "windows": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "windows/tauri/src/features/editor/engines/monaco/git-gutter-controller.ts", + "windows/tauri/src/features/editor/engines/monaco/git-gutter-peek-widget.ts", + "windows/tauri/src/features/git/utils/git-gutter-changes.test.ts" + ] + }, + "owner": "Editor / Git", + "verification": "在 Windows 打开已跟踪文件,新增、修改、删除内容并保存,点击标记查看差异、切换前后变更、按 Escape 关闭;确认多仓库同名文件互不影响。切换浅色、深色及自定义主色,检查面板边框、焦点和 Git 语义色;关闭 Git 标记设置后标记消失。macOS 检查对应行变更入口。Linux 单元测试不替代目标平台运行验证。" + }, + { + "id": "editor-git-gutter-stage", + "area": "编辑器", + "group": "Git 变更", + "capability": "从编辑器行变更入口暂存单处修改", + "macos": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "macos/Sources/Lithe/Views/Editor/CodeEditorView.swift" + ] + }, + "windows": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "windows/tauri/src/features/git/services/git-gutter-actions.ts", + "windows/tauri/src/features/git/utils/git-gutter-changes.test.ts" + ] + }, + "owner": "Editor / Git", + "verification": "保存含多处增删改的已跟踪文件,单独暂存后一处修改,检查 index 只包含选中修改,邻近与前方未暂存修改保留;未保存或只读时禁用暂存。Windows 与 macOS 均需实机验证。" + }, + { + "id": "editor-git-gutter-setting", + "area": "编辑器", + "group": "Git 变更", + "capability": "设置中开关编辑器 Git 变更标记", + "macos": { + "implementationStatus": "platform-specific", + "verificationStatus": "not-applicable", + "evidence": [ + "macos/Sources/Lithe/Views/Editor/CodeEditorView.swift" + ] + }, + "windows": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "windows/tauri/src/features/settings/components/tabs/git-settings.tsx", + "windows/tauri/src/features/settings/config/default-settings.ts" + ] + }, + "owner": "Editor / Git", + "verification": "Windows 默认显示 Git 变更标记;在 Git 设置中关闭时清除标记和预览,重新启用后恢复,重启后保留选择。此项记录 Windows 新增的独立开关。" } ] } diff --git a/windows/tauri/src-tauri/src/platform.rs b/windows/tauri/src-tauri/src/platform.rs index f0dd8ffa5..b8b4004ae 100644 --- a/windows/tauri/src-tauri/src/platform.rs +++ b/windows/tauri/src-tauri/src/platform.rs @@ -627,17 +627,19 @@ fn translate(command: &str, args: Value) -> Result<(String, Value), String> { payload.insert("arguments".into(), json!(["reset", "HEAD"])); "git.command" } - "git_stage_hunk" | "git_unstage_hunk" => { + "git_stage_hunk" | "git_unstage_hunk" | "git_discard_hunk" => { let hunk = payload .remove("hunk") .ok_or_else(|| "Hunk payload is required".to_string())?; payload.insert("patch".into(), json!(hunk_patch(&hunk)?)); payload.insert( "mode".into(), - json!(if command == "git_stage_hunk" { - "stage" - } else { - "unstage" + // `discard` reverse-applies the worktree hunk without touching + // the index, so staged content for the file survives. + json!(match command { + "git_stage_hunk" => "stage", + "git_unstage_hunk" => "unstage", + _ => "discard", }), ); "git.apply" @@ -1328,6 +1330,33 @@ mod tests { ); } + #[test] + fn preserves_full_file_context_for_single_file_review() { + // #557: the review reveals folded lines locally, so Core must receive + // the whole-file context request unchanged. + let (command, payload) = translate( + "git_diff_file", + json!({ + "repoPath": "C:/work", + "filePath": "src/main.rs", + "worktreeSnapshot": true, + "contextLines": 2_147_483_647u32 + }), + ) + .unwrap(); + + assert_eq!(command, "git.diff"); + assert_eq!( + payload, + json!({ + "root": "C:/work", + "pathspecs": ["src/main.rs"], + "worktreeSnapshot": true, + "contextLines": 2_147_483_647u32 + }) + ); + } + #[test] fn translates_untracked_diff_file_pathspec() { let (command, payload) = translate( @@ -1573,4 +1602,40 @@ mod tests { "diff --git a/src/main.rs b/src/main.rs\n--- a/src/main.rs\n+++ b/src/main.rs\n@@ -1 +1 @@\n-old\n+new\n" ); } + + #[test] + fn translates_hunk_discard_to_reverse_worktree_apply() { + let hunk = json!({ + "file_path": "src/main.rs", + "lines": [ + { "line_type": "header", "content": "@@ -1,2 +1,2 @@" }, + { "line_type": "context", "content": "keep" }, + { "line_type": "removed", "content": "old" }, + { "line_type": "added", "content": "new" } + ] + }); + let (command, payload) = translate( + "git_discard_hunk", + json!({ "repoPath": "C:/work", "hunk": hunk.clone() }), + ) + .unwrap(); + + assert_eq!(command, "git.apply"); + assert_eq!( + payload, + json!({ + "root": "C:/work", + "mode": "discard", + "patch": "diff --git a/src/main.rs b/src/main.rs\n--- a/src/main.rs\n+++ b/src/main.rs\n@@ -1,2 +1,2 @@\n keep\n-old\n+new\n" + }) + ); + + // Stage and unstage keep their modes when the discard arm is shared. + for (name, mode) in [("git_stage_hunk", "stage"), ("git_unstage_hunk", "unstage")] { + let (_, payload) = + translate(name, json!({ "repoPath": "C:/work", "hunk": hunk.clone() })).unwrap(); + assert_eq!(payload["mode"], mode); + } + assert!(translate("git_discard_hunk", json!({ "repoPath": "C:/work" })).is_err()); + } } diff --git a/windows/tauri/src-tauri/src/run.rs b/windows/tauri/src-tauri/src/run.rs index 064107fac..5563672f0 100644 --- a/windows/tauri/src-tauri/src/run.rs +++ b/windows/tauri/src-tauri/src/run.rs @@ -13,7 +13,7 @@ use std::path::{Path, PathBuf}; use std::process::{Child, ChildStdin, Command, Stdio}; use std::sync::{ mpsc::{self, Receiver, RecvTimeoutError, SyncSender}, - Mutex, OnceLock, + Arc, Mutex, OnceLock, }; use std::thread; use std::time::{Duration, Instant}; @@ -79,6 +79,52 @@ fn sessions() -> &'static Mutex> { SESSIONS.get_or_init(|| Mutex::new(HashMap::new())) } +// Serializes reservation changes with process publication, never with launch preparation. +fn pending_launches() -> &'static Mutex> { + static PENDING: OnceLock>> = OnceLock::new(); + PENDING.get_or_init(|| Mutex::new(HashMap::new())) +} + +struct PendingLaunch { + identity: Arc<()>, + execution_id: Option, +} + +struct LaunchReservation { + key: RunSessionKey, + identity: Arc<()>, +} + +impl LaunchReservation { + fn is_current(&self, pending: &HashMap) -> bool { + pending + .get(&self.key) + .is_some_and(|launch| Arc::ptr_eq(&launch.identity, &self.identity)) + } +} + +impl Drop for LaunchReservation { + fn drop(&mut self) { + if let Ok(mut pending) = pending_launches().lock() { + if self.is_current(&pending) { + pending.remove(&self.key); + } + } + } +} + +fn cancel_pending_launch( + pending: &mut HashMap, + key: &RunSessionKey, + execution_id: Option<&str>, +) { + if pending.get(key).is_some_and(|launch| { + execution_id.is_none() || launch.execution_id.as_deref() == execution_id + }) { + pending.remove(key); + } +} + fn run_session_key(window_label: &str, session_id: &str) -> RunSessionKey { RunSessionKey { window_label: window_label.to_string(), @@ -398,6 +444,8 @@ pub enum ToolchainResolution { version: String, vendor: String, source: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + warning: Option, }, /// Nothing was configured and nothing usable was detected. NotFound { message: Option }, @@ -451,12 +499,14 @@ fn resolve_toolchains_for_display( path, version, vendor, + warning, .. } => ToolchainResolution::Resolved { path: path.clone(), version: version.clone(), vendor: vendor.clone(), source: "projectJdk", + warning: warning.clone(), }, other => other.clone(), } @@ -471,7 +521,19 @@ fn resolve_toolchains_for_display( } fn java_resolution(root: &Path, override_path: &str) -> ToolchainResolution { - match resolve_java_home(root, override_path) { + let automatic = if override_path.trim().is_empty() { + match select_project_java(Some(root), &discover_java_runtimes(Some(root))) { + Ok(selection) => Some(selection), + Err(message) => return ToolchainResolution::Invalid { message }, + } + } else { + None + }; + let resolved = automatic + .as_ref() + .map(|selection| Ok(selection.id.clone())) + .unwrap_or_else(|| resolve_java_home(root, override_path)); + match resolved { Ok(Some(home)) => { let home_path = Path::new(&home); let source = if override_path.trim().is_empty() { @@ -488,6 +550,7 @@ fn java_resolution(root: &Path, override_path: &str) -> ToolchainResolution { vendor: runtime.map(|runtime| runtime.vendor).unwrap_or_default(), path: home, source, + warning: automatic.and_then(|selection| selection.warning), } } Ok(None) => ToolchainResolution::NotFound { message: None }, @@ -544,6 +607,7 @@ fn maven_resolution(root: &Path, override_path: &str) -> ToolchainResolution { version, vendor: String::new(), source, + warning: None, } } Err(message) if override_path.trim().is_empty() => ToolchainResolution::NotFound { @@ -586,12 +650,52 @@ pub async fn run_execute_prelaunch(args: ExecutePreLaunchArgs) -> Result Result<(), String> { +pub async fn run_start_process(app: AppHandle, args: StartProcessArgs) -> Result<(), String> { if args.window_label.trim().is_empty() { return Err("A run process must be started from an active window.".into()); } - stop_session(&args.window_label, &args.session_id, None); + let (reservation, previous_pid) = { + let mut pending = pending_launches() + .lock() + .map_err(|_| "Run launch state is unavailable".to_string())?; + let previous_pid = take_running_pid(&args.window_label, &args.session_id, None); + let key = run_session_key(&args.window_label, &args.session_id); + let identity = Arc::new(()); + pending.insert( + key.clone(), + PendingLaunch { + identity: identity.clone(), + execution_id: args.execution_id.clone(), + }, + ); + (LaunchReservation { key, identity }, previous_pid) + }; + // Filesystem metadata, JAR writes and process creation may block. The worker + // owns the reservation and temporary file even if the awaiting task ends. + tauri::async_runtime::spawn_blocking(move || { + if let Some(pid) = previous_pid { + terminate_run_process(pid); + } + start_reserved_process(app, args, reservation) + }) + .await + .map_err(|error| format!("Run launch preparation failed: {error}"))? +} + +fn start_reserved_process( + app: AppHandle, + args: StartProcessArgs, + reservation: LaunchReservation, +) -> Result<(), String> { let (arguments, argfile) = prepare_launch_arguments(&args)?; + let pending = pending_launches() + .lock() + .map_err(|_| "Run launch state is unavailable".to_string())?; + if !reservation.is_current(&pending) { + return Err("Run launch was stopped or replaced during preparation.".into()); + } + // Stop/restart cannot invalidate the reservation between this check and + // publication in sessions(). Preparation never holds this lock. let mut command = command_for_executable(&args.executable, &arguments); command .current_dir(&args.working_directory) @@ -600,6 +704,9 @@ pub fn run_start_process(app: AppHandle, args: StartProcessArgs) -> Result<(), S .stdout(Stdio::piped()) .stderr(Stdio::piped()); apply_creation_flags(&mut command); + let mut current = sessions() + .lock() + .map_err(|_| "Run process state is unavailable".to_string())?; let mut child = match command.spawn() { Ok(child) => child, Err(error) => { @@ -613,17 +720,18 @@ pub fn run_start_process(app: AppHandle, args: StartProcessArgs) -> Result<(), S let stderr = child.stderr.take(); let execution_id = args.execution_id.clone(); let session_key = run_session_key(&args.window_label, &args.session_id); - sessions() - .lock() - .map_err(|_| "Run process state is unavailable".to_string())? - .insert( - session_key, - RunningSession { - pid, - execution_id: args.execution_id, - stdin, - }, - ); + current.insert( + session_key, + RunningSession { + pid, + execution_id: args.execution_id, + stdin, + }, + ); + + drop(current); + drop(pending); + drop(reservation); // Run and Maven panels rebuild highlighted output when this event crosses // into the webview. Coalesce native pipe reads before that expensive @@ -658,7 +766,7 @@ pub fn run_start_process(app: AppHandle, args: StartProcessArgs) -> Result<(), S fn prepare_launch_arguments( args: &StartProcessArgs, ) -> Result<(Vec, Option), String> { - launch_arguments::prepare(&args.executable, &args.arguments) + launch_arguments::prepare(&args.executable, &args.arguments, &args.working_directory) } /// Explains a refused spawn with the detail the operating system reported. @@ -683,12 +791,16 @@ fn spawn_failure_message(executable: &str, arguments: &[String], error: &std::io } #[tauri::command] -pub fn run_stop_process( +pub async fn run_stop_process( window_label: String, session_id: String, execution_id: Option, ) -> Result<(), String> { - stop_session(&window_label, &session_id, execution_id.as_deref()); + if let Some(pid) = stop_session(&window_label, &session_id, execution_id.as_deref()) { + tauri::async_runtime::spawn_blocking(move || terminate_run_process(pid)) + .await + .map_err(|error| format!("Run stop failed: {error}"))?; + } Ok(()) } @@ -986,6 +1098,58 @@ pub(crate) fn discover_java_runtimes(project_root: Option<&Path>) -> Vec, + runtimes: &[JavaRuntime], +) -> Vec { + // Resolve source identities once, not from the sorting comparator. Windows + // path identity is case-insensitive and may be reached through a symlink. + let key = |path: &Path| normalize_path(path).to_string_lossy().to_lowercase(); + let environment_home = std::env::var_os("JAVA_HOME").map(|value| key(Path::new(&value))); + let path_home = lookup_on_path("java.exe") + .or_else(|| lookup_on_path("java")) + .and_then(|path| java_home_from_executable(&path)) + .map(|path| key(&path)); + let project_home = root.map(|root| key(&root.join(".lithe/toolchains/jdk"))); + runtimes + .iter() + .map(|runtime| { + let home = key(Path::new(&runtime.home_path)); + let priority = if Some(&home) == environment_home.as_ref() { + 0 + } else if Some(&home) == path_home.as_ref() { + 1 + } else if Some(&home) == project_home.as_ref() { + 3 + } else { + 2 + }; + lithe_core::execution::JavaSelectionCandidate { + id: runtime.home_path.clone(), + version: runtime.version.clone(), + priority, + } + }) + .collect() +} + +fn select_project_java( + root: Option<&Path>, + runtimes: &[JavaRuntime], +) -> Result { + let candidates = java_selection_candidates(root, runtimes); + let fallback_id = candidates + .iter() + .min_by(|a, b| lithe_core::execution::compare_java_candidates(a, b)) + .map(|candidate| candidate.id.clone()); + lithe_core::execution::select_java(lithe_core::execution::JavaSelectionRequest { + root: root.map(Path::to_path_buf), + candidates, + fallback_id, + }) + .map_err(|error| error.message) +} + fn probe_java_homes(homes: Vec) -> Vec { let mut java = Vec::new(); let mut seen_homes = std::collections::HashSet::new(); @@ -997,11 +1161,17 @@ fn probe_java_homes(homes: Vec) -> Vec { java.push(runtime); } } + // Preserve source priority, then compare Java versions numerically. Discovery + // order from read_dir must never choose the runtime of a project. + let candidates = java_selection_candidates(None, &java) + .into_iter() + .map(|candidate| (candidate.id.clone(), candidate)) + .collect::>(); java.sort_by(|left, right| { - right - .version - .cmp(&left.version) - .then(left.home_path.cmp(&right.home_path)) + lithe_core::execution::compare_java_candidates( + &candidates[&left.home_path], + &candidates[&right.home_path], + ) }); java } @@ -1020,7 +1190,19 @@ fn discover_toolchains_with_overrides( if let Some(path) = java_home_path.filter(|value| !value.trim().is_empty()) { homes.insert(0, PathBuf::from(path)); } - let java = probe_java_homes(homes); + let mut java = probe_java_homes(homes); + match select_project_java(project_root, &java) { + Ok(selection) => { + if let Some(index) = java + .iter() + .position(|runtime| Some(&runtime.home_path) == selection.id.as_ref()) + { + let selected = java.remove(index); + java.insert(0, selected); + } + } + Err(error) => eprintln!("Could not select the project JDK: {error}"), + } let maven = discover_maven_candidates( maven_executable_candidates(project_root), @@ -1313,9 +1495,8 @@ pub(crate) fn resolve_java_home( )); } // Resolving a JDK must not also run Maven wrappers or Node probes. - Ok(discover_java_runtimes(Some(root)) - .first() - .map(|runtime| runtime.home_path.clone())) + select_project_java(Some(root), &discover_java_runtimes(Some(root))) + .map(|selection| selection.id) } fn resolve_executable( @@ -2037,21 +2218,41 @@ fn take_owned_session( current.remove(key) } -fn stop_session(window_label: &str, session_id: &str, execution_id: Option<&str>) { - let pid = sessions().lock().ok().and_then(|mut current| { +fn stop_session(window_label: &str, session_id: &str, execution_id: Option<&str>) -> Option { + // Use the same lock/order as publication, so a stop cannot miss a process + // between its pending reservation and its running session. + let Ok(mut pending) = pending_launches().lock() else { + eprintln!("Run launch state is unavailable while stopping a session"); + return None; + }; + cancel_pending_launch( + &mut pending, + &run_session_key(window_label, session_id), + execution_id, + ); + take_running_pid(window_label, session_id, execution_id) +} + +fn take_running_pid( + window_label: &str, + session_id: &str, + execution_id: Option<&str>, +) -> Option { + sessions().lock().ok().and_then(|mut current| { take_owned_session( &mut current, &run_session_key(window_label, session_id), execution_id, ) .map(|session| session.pid) - }); - if let Some(pid) = pid { - let mut command = Command::new("taskkill"); - command.args(["/F", "/T", "/PID", &pid.to_string()]); - apply_creation_flags(&mut command); - let _ = command.output(); - } + }) +} + +fn terminate_run_process(pid: u32) { + let mut command = Command::new("taskkill"); + command.args(["/F", "/T", "/PID", &pid.to_string()]); + apply_creation_flags(&mut command); + let _ = command.output(); } #[cfg(test)] @@ -2059,6 +2260,78 @@ mod tests { use super::*; use std::time::{SystemTime, UNIX_EPOCH}; + fn reserve_test_launch( + pending: &mut HashMap, + window: &str, + execution: &str, + ) -> LaunchReservation { + let key = run_session_key(window, "launch-reservation-test"); + let identity = Arc::new(()); + pending.insert( + key.clone(), + PendingLaunch { + identity: identity.clone(), + execution_id: Some(execution.into()), + }, + ); + LaunchReservation { key, identity } + } + + #[test] + fn pending_launch_owner_cleanup_preserves_replacement() { + // This key is exclusive to this test; every reservation has RAII cleanup + // even when an assertion unwinds. No threads or wall-clock waits needed. + let reserve = || { + let mut pending = pending_launches().lock().unwrap(); + reserve_test_launch(&mut pending, "owner-cleanup-window", "same-id") + }; + let old = reserve(); + let latest = reserve(); + let key = latest.key.clone(); + drop(old); + assert!(latest.is_current(&pending_launches().lock().unwrap())); + drop(latest); + assert!(!pending_launches().lock().unwrap().contains_key(&key)); + } + + #[test] + fn pending_launch_stop_prevents_late_publication() { + let mut pending = HashMap::new(); + let launch = reserve_test_launch(&mut pending, "first-window", "first"); + assert!(launch.is_current(&pending)); + // Preparation is still in progress when Stop arrives. Completing that + // preparation later must never grant permission to spawn a process. + cancel_pending_launch(&mut pending, &launch.key, None); + assert!(!launch.is_current(&pending)); + assert!(pending.is_empty()); + } + + #[test] + fn pending_launch_restart_rejects_out_of_order_completion() { + let mut pending = HashMap::new(); + let old = reserve_test_launch(&mut pending, "first-window", "old"); + let latest = reserve_test_launch(&mut pending, "first-window", "new"); + assert!(latest.is_current(&pending)); + assert!(!old.is_current(&pending)); + // An old adapter's delayed Stop must not cancel the replacement Run. + cancel_pending_launch(&mut pending, &old.key, Some("old")); + assert!(latest.is_current(&pending)); + cancel_pending_launch(&mut pending, &latest.key, Some("new")); + assert!(!latest.is_current(&pending)); + } + + #[test] + fn pending_launch_isolates_windows_and_reused_execution_ids() { + let mut pending = HashMap::new(); + let old = reserve_test_launch(&mut pending, "first-window", "same-id"); + let latest = reserve_test_launch(&mut pending, "first-window", "same-id"); + let other = reserve_test_launch(&mut pending, "second-window", "same-id"); + assert!(!old.is_current(&pending)); + assert!(latest.is_current(&pending)); + cancel_pending_launch(&mut pending, &latest.key, None); + assert!(other.is_current(&pending)); + } + #[test] fn output_batch_coalesces_queued_chunks_in_order() { let (sender, receiver) = mpsc::sync_channel(2); @@ -2527,6 +2800,61 @@ mod tests { ); } + #[test] + fn automatic_java_selection_reads_requirement_changes_without_probing() { + let root = temp_project(); + struct Cleanup(PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + fs::remove_dir_all(&self.0).expect("remove Java fixture"); + } + } + let _cleanup = Cleanup(root.clone()); + let runtimes = vec![ + JavaRuntime { + home_path: root.join("jdk8").to_string_lossy().into_owned(), + version: "1.8.0_402".into(), + vendor: "fixture".into(), + }, + JavaRuntime { + home_path: root.join("jdk21").to_string_lossy().into_owned(), + version: "21.0.4".into(), + vendor: "fixture".into(), + }, + ]; + fs::create_dir_all(root.join(".lithe/toolchains")).unwrap(); + let requirements = root.join(".lithe/toolchains/requirements.json"); + fs::write( + &requirements, + r#"{"version":1,"toolchains":{"project-jdk":{"type":"java","minimumVersion":"17"}}}"#, + ) + .unwrap(); + let selected = select_project_java(Some(&root), &runtimes).unwrap(); + assert_eq!(selected.id.as_deref(), Some(runtimes[1].home_path.as_str())); + assert!(selected.warning.is_none()); + fs::write( + &requirements, + r#"{"version":1,"toolchains":{"project-jdk":{"type":"java","minimumVersion":"25"}}}"#, + ) + .unwrap(); + let fallback = select_project_java(Some(&root), &runtimes).unwrap(); + assert_eq!(fallback.id, selected.id); + assert!(fallback.warning.unwrap().contains("25")); + fs::write(&requirements, "{").unwrap(); + assert!(select_project_java(Some(&root), &runtimes).is_err()); + // Explicit paths never pass through requirement-based auto selection. + fs::create_dir_all(root.join("jdk8/bin")).unwrap(); + fs::write(root.join("jdk8/bin/java.exe"), b"fixture").unwrap(); + assert_eq!( + resolve_java_home(&root, "jdk8").unwrap(), + Some( + normalize_path(&root.join("jdk8")) + .to_string_lossy() + .into_owned() + ) + ); + } + #[test] fn displayed_maven_is_the_wrapper_a_launch_would_run() { // Settings shows the resolved Maven in place of "automatic"; it must be diff --git a/windows/tauri/src-tauri/src/run/launch_arguments.rs b/windows/tauri/src-tauri/src/run/launch_arguments.rs index 2425fd932..02c4a9720 100644 --- a/windows/tauri/src-tauri/src/run/launch_arguments.rs +++ b/windows/tauri/src-tauri/src/run/launch_arguments.rs @@ -1,4 +1,8 @@ //! Native encoding and execution-owned temporary files for Java launch arguments. +//! +//! JDK 9+ launches use a Core-planned `@argfile`; JDK 8 launches use a +//! Core-planned manifest-only class-path JAR. Both files are created under the +//! system temporary directory and owned by [`LaunchArgumentFile`]. use std::fs::{self, OpenOptions}; use std::io::Write; @@ -21,12 +25,17 @@ impl Drop for LaunchArgumentFile { pub(super) fn prepare( executable: &str, arguments: &[String], + working_directory: &str, ) -> Result<(Vec, Option), String> { let version = java_feature_version(executable); - // The planner also checks executable identity and requires a known JDK >= 9. - // No temporary file is created for a direct launch. + // JDK 8 has no `@argfile`; Core plans a manifest-only class-path JAR for + // it instead. Both planners check executable identity, and an unknown + // version keeps the original command. + if version.is_some_and(|version| version < 9) { + return prepare_classpath_jar(executable, arguments, working_directory, version); + } for _ in 0..128 { - let path = next_argfile_path(); + let path = next_temporary_path("argfile"); let lithe_core::execution::LaunchCommandPlan::Argfile { arguments: shortened, argfile_contents, @@ -56,10 +65,81 @@ pub(super) fn prepare( Err("Could not allocate a unique Java launch argument file. Retry the launch.".into()) } -fn next_argfile_path() -> PathBuf { +/// Writes the JDK 8 class-path JAR planned by Core. +/// +/// The JAR lives in the same per-execution temporary directory as argument +/// files, never next to the JDK or the installation, and the returned owner +/// deletes it on write failure, spawn failure, or process exit. +fn prepare_classpath_jar( + executable: &str, + arguments: &[String], + working_directory: &str, + version: Option, +) -> Result<(Vec, Option), String> { + use lithe_core::execution::{ + plan_classpath_jar_launch, ClasspathJarPlan, ClasspathJarRequest, ClasspathStyle, + }; + let style = if cfg!(windows) { + ClasspathStyle::Windows + } else { + ClasspathStyle::Posix + }; + for _ in 0..128 { + let path = next_temporary_path("classpath.jar"); + let ClasspathJarPlan::ClasspathJar { + arguments: shortened, + manifest, + } = plan_classpath_jar_launch( + ClasspathJarRequest { + executable, + arguments, + working_directory, + classpath_jar_path: &path, + limit: None, + java_feature_version: version, + style, + }, + |entry| std::path::Path::new(entry).is_dir(), + ) + else { + return Ok((arguments.to_vec(), None)); + }; + fs::create_dir_all(path.parent().expect("temporary file has a parent")) + .map_err(|error| format!("Could not create Java class-path JAR directory: {error}"))?; + let file = match OpenOptions::new().write(true).create_new(true).open(&path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(format!("Could not create Java class-path JAR: {error}")), + }; + let owner = LaunchArgumentFile(path); + write_manifest_jar(file, &manifest) + .map_err(|error| format!("Could not write Java class-path JAR: {error}"))?; + return Ok((shortened, Some(owner))); + } + Err("Could not allocate a unique Java class-path JAR. Retry the launch.".into()) +} + +/// Writes a JAR whose only entry is `META-INF/MANIFEST.MF`. +/// +/// The manifest is stored uncompressed: it is small, and stored entries keep +/// the archive readable by every JDK 8 `ZipFile` implementation. +fn write_manifest_jar(file: fs::File, manifest: &str) -> std::io::Result<()> { + let mut writer = zip::ZipWriter::new(file); + let options = + zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + writer + .start_file("META-INF/MANIFEST.MF", options) + .map_err(std::io::Error::other)?; + writer.write_all(manifest.as_bytes())?; + // `finish` returns the file so it is closed before the owner can delete it. + let file = writer.finish().map_err(std::io::Error::other)?; + file.sync_all() +} + +fn next_temporary_path(extension: &str) -> PathBuf { static NEXT_ID: AtomicU64 = AtomicU64::new(0); std::env::temp_dir().join("lithe-run").join(format!( - "launch-{}-{}.argfile", + "launch-{}-{}.{extension}", std::process::id(), NEXT_ID.fetch_add(1, Ordering::Relaxed) )) @@ -195,12 +275,22 @@ mod tests { struct JdkFixture(PathBuf); impl JdkFixture { fn new() -> Self { - let root = next_argfile_path().with_extension("jdk"); + Self::with_version("21.0.2") + } + fn with_version(version: &str) -> Self { + let root = next_temporary_path("jdk"); fs::create_dir_all(root.join("bin")).unwrap(); let fixture = Self(root); - fs::write(fixture.0.join("release"), "JAVA_VERSION=\"21.0.2\"\n").unwrap(); + fs::write( + fixture.0.join("release"), + format!("JAVA_VERSION=\"{version}\"\n"), + ) + .unwrap(); fixture } + fn working_directory(&self) -> String { + self.0.to_string_lossy().into_owned() + } fn executable(&self) -> String { self.0.join("bin/java.exe").to_string_lossy().into_owned() } @@ -221,11 +311,104 @@ mod tests { ] } + /// A host-native class path under the fixture: the separator and absolute + /// path syntax follow the platform the planner will be asked about. + fn native_long_classpath(jdk: &JdkFixture, classes_name: &str) -> (Vec, PathBuf) { + let classes = jdk.0.join(classes_name); + fs::create_dir_all(&classes).unwrap(); + let separator = if cfg!(windows) { ";" } else { ":" }; + let entries = std::iter::once(classes.to_string_lossy().into_owned()) + .chain((0..600).map(|i| { + jdk.0 + .join(format!("repository/artifact-{i}/1.0/artifact-{i}.jar")) + .to_string_lossy() + .into_owned() + })) + .collect::>() + .join(separator); + (vec!["-cp".into(), entries, "Main".into()], classes) + } + + fn read_manifest(path: &std::path::Path) -> String { + let mut archive = zip::ZipArchive::new(fs::File::open(path).unwrap()).unwrap(); + assert_eq!(archive.len(), 1, "the JAR holds only its manifest"); + let mut manifest = String::new(); + std::io::Read::read_to_string( + &mut archive.by_name("META-INF/MANIFEST.MF").unwrap(), + &mut manifest, + ) + .unwrap(); + manifest + } + + /// Regression for #955: JDK 8 cannot read `@argfile`, so its oversized + /// class path must be written to a temporary manifest JAR instead. + #[test] + fn jdk8_launch_uses_an_owned_classpath_jar_in_the_temporary_directory() { + let jdk = JdkFixture::with_version("1.8.0_392"); + // The manifest percent-encodes UTF-8, so this name works on every + // Windows code page, unlike an argument file. + let (arguments, classes) = native_long_classpath(&jdk, "项目 classes"); + let (shortened, file) = + prepare(&jdk.executable(), &arguments, &jdk.working_directory()).unwrap(); + let file = file.expect("an oversized JDK 8 class path must be shortened"); + let jar = file.0.clone(); + assert!(jar.starts_with(std::env::temp_dir().join("lithe-run"))); + assert!(jar.to_string_lossy().ends_with(".classpath.jar")); + assert_eq!(shortened, ["-cp", jar.to_str().unwrap(), "Main"]); + let manifest = read_manifest(&jar); + assert!(manifest.starts_with("Manifest-Version: 1.0\r\nClass-Path: file:")); + let logical = manifest.replace("\r\n ", ""); + let class_path = logical + .lines() + .find_map(|line| line.strip_prefix("Class-Path: ")) + .unwrap(); + let urls: Vec<&str> = class_path.split(' ').collect(); + assert_eq!(urls.len(), 601); + // The existing directory keeps its trailing slash and its encoded name. + assert!( + urls[0].ends_with("/%E9%A1%B9%E7%9B%AE%20classes/"), + "{}", + urls[0] + ); + assert!(urls[1].ends_with("/artifact-0.jar")); + drop(file); + assert!(!jar.exists(), "the owner deletes the JAR"); + assert!(classes.exists(), "only the temporary JAR is removed"); + } + + #[test] + fn jdk9_and_later_still_use_an_argument_file() { + let jdk = JdkFixture::with_version("17.0.9"); + let (arguments, _) = native_long_classpath(&jdk, "classes"); + let (shortened, file) = + prepare(&jdk.executable(), &arguments, &jdk.working_directory()).unwrap(); + let file = file.expect("an oversized JDK 17 class path must be shortened"); + assert!(file.0.to_string_lossy().ends_with(".argfile")); + assert_eq!(shortened[0], format!("@{}", file.0.display())); + } + + #[test] + fn short_and_unknown_version_jdk8_launches_create_no_file() { + let jdk = JdkFixture::with_version("1.8.0_392"); + let short = vec!["-cp".into(), "classes".into(), "Main".into()]; + let (actual, file) = prepare(&jdk.executable(), &short, &jdk.working_directory()).unwrap(); + assert_eq!(actual, short); + assert!(file.is_none()); + fs::write(jdk.0.join("release"), "MODULES=\"java.base\"\n").unwrap(); + let (arguments, _) = native_long_classpath(&jdk, "classes"); + let (actual, file) = + prepare(&jdk.executable(), &arguments, &jdk.working_directory()).unwrap(); + assert_eq!(actual, arguments); + assert!(file.is_none()); + } + #[test] fn replacement_execution_owns_a_distinct_file() { let jdk = JdkFixture::new(); - let (first_args, first) = prepare(&jdk.executable(), &long_arguments()).unwrap(); - let (second_args, second) = prepare(&jdk.executable(), &long_arguments()).unwrap(); + let cwd = jdk.working_directory(); + let (first_args, first) = prepare(&jdk.executable(), &long_arguments(), &cwd).unwrap(); + let (second_args, second) = prepare(&jdk.executable(), &long_arguments(), &cwd).unwrap(); let first = first.unwrap(); let second = second.unwrap(); assert_ne!(first_args, second_args); @@ -244,12 +427,13 @@ mod tests { fn short_non_java_and_unknown_jdk_launches_stay_direct() { let jdk = JdkFixture::new(); let short = vec!["-cp".into(), "classes".into(), "Main".into()]; - let (actual, file) = prepare(&jdk.executable(), &short).unwrap(); + let cwd = jdk.working_directory(); + let (actual, file) = prepare(&jdk.executable(), &short, &cwd).unwrap(); assert_eq!(actual, short); assert!(file.is_none()); for executable in ["node.exe", "C:/missing/bin/java.exe"] { let args = long_arguments(); - let (actual, file) = prepare(executable, &args).unwrap(); + let (actual, file) = prepare(executable, &args, &cwd).unwrap(); assert_eq!(actual, args); assert!(file.is_none()); } diff --git a/windows/tauri/src/features/editor/components/monaco-editor.tsx b/windows/tauri/src/features/editor/components/monaco-editor.tsx index 6f9e17408..02c87c723 100644 --- a/windows/tauri/src/features/editor/components/monaco-editor.tsx +++ b/windows/tauri/src/features/editor/components/monaco-editor.tsx @@ -35,6 +35,16 @@ import { useInlineEditToolbarStore } from "@/features/editor/stores/inline-edit- import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; import { useActiveWorkspaceId } from "@/features/workspace/stores/create-workspace-scoped-store"; import { useGitBlame } from "@/features/git/hooks/use-git-blame"; +import { useGitGutterBase, type GitGutterBase } from "@/features/git/hooks/use-git-gutter-base"; +import { + openGitGutterFullDiff, + stageGitGutterChange, +} from "@/features/git/services/git-gutter-actions"; +import { + createGitGutterController, + type GitGutterController, + type GitGutterControllerOptions, +} from "../engines/monaco/git-gutter-controller"; import { keymapRegistry } from "@/features/keymaps/utils/registry"; import { useSettingsStore } from "@/features/settings/stores/settings.store"; import { openMavenRunPane } from "@/features/maven/actions/maven-tool-window-actions"; @@ -151,6 +161,50 @@ import { toast } from "sonner"; registerMonacoLspProviders(); registerMonacoCodeLensProvider(); +interface GitGutterRenderContext { + base: GitGutterBase | null; + canStage: boolean; + languageId: string; + t: (key: string, values?: Record) => string; +} + +/** Binds the long-lived gutter controller to the editor's latest render values. */ +function gitGutterControllerOptions( + context: () => GitGutterRenderContext | null, +): GitGutterControllerOptions { + const translate = (key: string, values?: Record) => + context()?.t(key, values) ?? key; + return { + language: () => context()?.languageId ?? "plaintext", + labels: (peek) => ({ + title: translate("git.gutter.title", { index: peek.index + 1, total: peek.total }), + previous: translate("git.gutter.previous"), + next: translate("git.gutter.next"), + stage: translate("git.gutter.stage"), + stageUnavailable: translate("git.gutter.stageUnavailable"), + openDiff: translate("git.gutter.openDiff"), + close: translate("git.gutter.close"), + }), + markerTooltip: (kind) => translate(`git.gutter.${kind}`), + canStage: () => !!context()?.base && !!context()?.canStage, + stage: async (change, baseLines) => { + const base = context()?.base; + if (!base) return false; + const staged = await stageGitGutterChange(base, baseLines, change); + if (!staged) toast.error(translate("git.operationFailed")); + return staged; + }, + openDiff: () => { + const base = context()?.base; + if (!base) return; + void openGitGutterFullDiff(base, translate("git.diff.uncommitted")).catch((error) => { + console.error("Failed to open Git diff:", error); + toast.error(translate("git.operationFailed")); + }); + }, + }; +} + const EMPTY_DIAGNOSTICS: Diagnostic[] = []; const INACTIVE_CURSOR_POSITION: Position = { line: 0, column: 0, offset: 0 }; @@ -337,6 +391,9 @@ export function MonacoEditor({ // Latest Run-marker decorations, reapplied when the Monaco instance is recreated. const runMarkerDecorationSpecsRef = useRef([]); const renderInlineGitBlameRef = useRef<() => void>(() => {}); + const gitGutterControllerRef = useRef(null); + // Latest render values read by the long-lived gutter controller callbacks. + const gitGutterContextRef = useRef(null); const mouseSelectingRef = useRef(false); const mouseGestureStartRef = useRef(null); const suppressNextCursorSelectionSyncRef = useRef(false); @@ -374,6 +431,16 @@ export function MonacoEditor({ return current && current.type === "editor" ? (current.content ?? "") : ""; }, [contentRevision, editorBufferId]); const filePath = editorBuffer?.path ?? ""; + // Narrow selectors: staging needs saved text, and the gutter base reloads + // when the saved text changes. Neither re-renders on every keystroke. + const bufferIsDirty = useBufferStore((state) => { + const current = getBufferById(state.buffers, editorBufferId); + return current?.type === "editor" ? current.isDirty : false; + }); + const bufferSavedContent = useBufferStore((state) => { + const current = getBufferById(state.buffers, editorBufferId); + return current?.type === "editor" ? current.savedContent : ""; + }); const editorLanguage = editorBuffer?.language; const editorLanguageOverride = editorBuffer?.languageOverride; const documentUri = editorBuffer?.lspDocument?.documentUri; @@ -439,6 +506,7 @@ export function MonacoEditor({ javaMarkerRefreshRevision(state.lspStatus), ); const inlineGitBlameEnabled = useSettingsStore((state) => state.settings.enableInlineGitBlame); + const gitGutterEnabled = useSettingsStore((state) => state.settings.enableGitGutter); const workspaceId = useActiveWorkspaceId(); const rootFolderPath = useFileSystemStore((state) => state.rootFolderPath); const javaTestScope = useMemo( @@ -481,6 +549,20 @@ export function MonacoEditor({ : undefined, ); + const gitGutterActive = + gitGutterEnabled && enableExpensiveServices && !isPreviewMode && !editorBuffer?.isVirtual; + const gitGutterBase = useGitGutterBase( + gitGutterActive && filePath ? filePath : undefined, + () => bufferSavedContent, + bufferSavedContent, + ); + gitGutterContextRef.current = { + base: gitGutterBase, + canStage: !bufferIsDirty && !readOnly, + languageId: monacoLanguageId, + t, + }; + const renderInlineGitBlame = useCallback(() => { const editor = editorRef.current; const model = modelRef.current; @@ -964,6 +1046,16 @@ export function MonacoEditor({ runMarkerDecorationSpecsRef.current, ); runMarkerDecorationsRef.current = runMarkerDecorationCollection; + // The gutter controller lives exactly as long as this editor and model, + // so recreating the editor (file switch, option change) also disposes any + // open inline review. Its base arrives through `setBase`. + const gitGutterController = createGitGutterController( + editor, + model, + gitGutterControllerOptions(() => gitGutterContextRef.current), + ); + gitGutterControllerRef.current = gitGutterController; + gitGutterController.setBase(gitGutterContextRef.current?.base?.lines ?? null); previousContentRef.current = content; pendingLocalContentSnapshotsRef.current = []; if (filePath && fileOpenBenchmark.has(filePath)) { @@ -1488,6 +1580,10 @@ export function MonacoEditor({ } implementationMarkerOwnerRef.current = null; implementationMarkersRef.current = []; + gitGutterController.dispose(); + if (gitGutterControllerRef.current === gitGutterController) { + gitGutterControllerRef.current = null; + } runMarkerDecorationCollection.clear(); if (runMarkerDecorationsRef.current === runMarkerDecorationCollection) { runMarkerDecorationsRef.current = null; @@ -2266,6 +2362,14 @@ export function MonacoEditor({ scheduleInlineGitBlameRender(); }, [renderInlineGitBlame, scheduleInlineGitBlameRender]); + useEffect(() => { + gitGutterControllerRef.current?.setBase(gitGutterBase?.lines ?? null); + }, [gitGutterBase]); + + useEffect(() => { + gitGutterControllerRef.current?.refreshPeek(); + }, [bufferIsDirty, readOnly]); + useEffect(() => { const editor = editorRef.current; const model = modelRef.current; diff --git a/windows/tauri/src/features/editor/engines/monaco/git-gutter-controller.ts b/windows/tauri/src/features/editor/engines/monaco/git-gutter-controller.ts new file mode 100644 index 000000000..ace8b70cd --- /dev/null +++ b/windows/tauri/src/features/editor/engines/monaco/git-gutter-controller.ts @@ -0,0 +1,212 @@ +import { editor as monacoEditor, KeyCode } from "monaco-editor"; +import type * as Monaco from "monaco-editor"; +import { + computeGitGutterChanges, + gitGutterChangeIndexAtLine, + gitGutterMarkerLines, + gitGutterPeekAnchorLine, + gitGutterPeekRows, + trimFinalEmptyLine, + type GitGutterChange, +} from "@/features/git/utils/git-gutter-changes"; +import { GitGutterPeekSession, type GitGutterPeek } from "./git-gutter-peek-session"; +import { + createGitGutterPeekWidget, + type GitGutterPeekLabels, + type GitGutterPeekWidget, +} from "./git-gutter-peek-widget"; +import { monacoLineDiff } from "./monaco-line-diff"; + +export const GIT_GUTTER_MARKER_CLASS = "lithe-git-gutter"; + +/** Pause after typing before the gutter is recomputed against the Git base. */ +const GIT_GUTTER_RECOMPUTE_DELAY_MS = 250; + +export interface GitGutterControllerOptions { + language(): string; + labels(peek: GitGutterPeek): GitGutterPeekLabels; + markerTooltip(kind: GitGutterChange["kind"]): string; + /** Staging writes the change into the index, so it needs saved, writable text. */ + canStage(): boolean; + stage(change: GitGutterChange, base: readonly string[]): Promise; + openDiff(): void; +} + +export interface GitGutterController { + /** The index text to compare against, or null when the file has no Git base. */ + setBase(base: readonly string[] | null): void; + /** Re-evaluates button state, for example after the document was saved. */ + refreshPeek(): void; + dispose(): void; +} + +/** + * Git change markers in the line-decoration gutter and their inline review. + * Markers compare the live editor text with the Git index, so unsaved edits + * show up as changes. Any edit closes an open review; a review only acts on + * the change list computed for the current model version. + */ +export function createGitGutterController( + editor: Monaco.editor.IStandaloneCodeEditor, + model: Monaco.editor.ITextModel, + options: GitGutterControllerOptions, +): GitGutterController { + const decorations = editor.createDecorationsCollection(); + const session = new GitGutterPeekSession(); + let base: readonly string[] | null = null; + let widget: GitGutterPeekWidget | null = null; + let timer: ReturnType | null = null; + let staging = false; + let disposed = false; + + const closePeek = () => { + session.close(); + widget?.dispose(); + widget = null; + }; + + const editorLines = () => trimFinalEmptyLine(model.getLinesContent()); + + const render = (peek: GitGutterPeek | null) => { + if (!peek) { + closePeek(); + return; + } + widget ??= createGitGutterPeekWidget(editor, { + previous: () => navigate("previous"), + next: () => navigate("next"), + stage: () => void stage(), + openDiff: () => options.openDiff(), + close: () => { + closePeek(); + editor.focus(); + }, + }); + widget.show({ + rows: gitGutterPeekRows(session.changeList, peek.index, editorLines()), + anchorLine: gitGutterPeekAnchorLine(peek.change), + language: options.language(), + labels: options.labels(peek), + canNavigate: peek.total > 1, + canStage: base !== null && options.canStage(), + staging, + }); + }; + + const navigate = (direction: "previous" | "next") => { + if (disposed) return; + render(session.navigate(direction, model.getVersionId())); + }; + + const stage = async () => { + const peek = session.peek; + const snapshot = base; + const version = model.getVersionId(); + if (disposed || staging || !peek || !snapshot || !options.canStage()) return; + if (!session.isCurrent(peek, version)) return; + staging = true; + render(peek); + try { + // A successful write emits a Git change event; the refreshed base then + // drops this change from the list and closes the review. + await options.stage(peek.change, snapshot); + } finally { + staging = false; + if (!disposed && session.peek) render(session.peek); + } + }; + + const recompute = () => { + timer = null; + if (disposed || model.isDisposed()) return; + const changes = base ? computeGitGutterChanges(base, editorLines(), monacoLineDiff) : null; + const lineCount = model.getLineCount(); + decorations.set( + (changes ?? []).map((change) => { + const { start, end } = gitGutterMarkerLines(change, lineCount); + return { + range: { startLineNumber: start, startColumn: 1, endLineNumber: end, endColumn: 1 }, + options: { + isWholeLine: true, + linesDecorationsClassName: `${GIT_GUTTER_MARKER_CLASS} ${GIT_GUTTER_MARKER_CLASS}-${change.kind}`, + linesDecorationsTooltip: options.markerTooltip(change.kind), + stickiness: monacoEditor.TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, + }, + }; + }), + ); + render(session.setChanges(changes ?? [], model.getVersionId())); + }; + + const schedule = (delay: number) => { + if (timer !== null) clearTimeout(timer); + timer = setTimeout(recompute, delay); + }; + + const disposables = [ + model.onDidChangeContent(() => { + // The review describes text that no longer exists; markers keep + // following Monaco's tracked ranges until the recompute lands. + closePeek(); + session.invalidate(); + schedule(GIT_GUTTER_RECOMPUTE_DELAY_MS); + }), + editor.onDidChangeModel(() => { + closePeek(); + session.invalidate(); + decorations.clear(); + }), + editor.onMouseDown((event) => { + if ( + event.target.type !== monacoEditor.MouseTargetType.GUTTER_LINE_DECORATIONS || + !event.target.position || + !event.event.leftButton || + !event.target.element?.classList.contains(GIT_GUTTER_MARKER_CLASS) + ) { + return; + } + event.event.preventDefault(); + event.event.stopPropagation(); + const version = model.getVersionId(); + const index = gitGutterChangeIndexAtLine( + session.changeList, + event.target.position.lineNumber, + model.getLineCount(), + ); + if (index < 0) return; + if (session.peek?.index === index) { + closePeek(); + return; + } + render(session.open(index, version)); + // Monaco keeps focus where it was on gutter clicks; take it so Escape + // closes the review, as in VS Code. + editor.focus(); + }), + editor.onKeyDown((event) => { + if (event.keyCode !== KeyCode.Escape || !session.peek) return; + event.preventDefault(); + event.stopPropagation(); + closePeek(); + }), + ]; + + return { + setBase(next) { + if (disposed) return; + base = next; + schedule(0); + }, + refreshPeek() { + if (!disposed && session.peek) render(session.peek); + }, + dispose() { + if (disposed) return; + disposed = true; + if (timer !== null) clearTimeout(timer); + disposables.forEach((disposable) => disposable.dispose()); + closePeek(); + decorations.clear(); + }, + }; +} diff --git a/windows/tauri/src/features/editor/engines/monaco/git-gutter-peek-session.test.ts b/windows/tauri/src/features/editor/engines/monaco/git-gutter-peek-session.test.ts new file mode 100644 index 000000000..9364930bd --- /dev/null +++ b/windows/tauri/src/features/editor/engines/monaco/git-gutter-peek-session.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from "bun:test"; +import { computeGitGutterChanges } from "@/features/git/utils/git-gutter-changes"; +import { GitGutterPeekSession } from "./git-gutter-peek-session"; +import { monacoLineDiff } from "./monaco-line-diff"; + +const BASE = ["a", "b", "c", "d", "e", "f"]; +const changesOf = (editor: string[]) => computeGitGutterChanges(BASE, editor, monacoLineDiff) ?? []; +const THREE_CHANGES = ["a", "B", "c", "D", "e", "F"]; + +describe("Git gutter inline review lifecycle", () => { + test("opens a change and navigates with wrap-around", () => { + const session = new GitGutterPeekSession(); + session.setChanges(changesOf(THREE_CHANGES), 7); + + expect(session.open(1, 7)?.index).toBe(1); + expect(session.navigate("next", 7)?.index).toBe(2); + expect(session.navigate("next", 7)?.index).toBe(0); + expect(session.navigate("previous", 7)?.index).toBe(2); + expect(session.peek?.total).toBe(3); + }); + + test("refuses to open a change list computed for another model version", () => { + const session = new GitGutterPeekSession(); + session.setChanges(changesOf(THREE_CHANGES), 7); + expect(session.open(0, 8)).toBeNull(); + expect(session.peek).toBeNull(); + }); + + test("an edit closes the review and invalidates the previous change list", () => { + const session = new GitGutterPeekSession(); + session.setChanges(changesOf(THREE_CHANGES), 7); + const peek = session.open(0, 7)!; + + session.invalidate(); + + expect(session.peek).toBeNull(); + expect(session.isCurrent(peek, 7)).toBe(false); + expect(session.navigate("next", 7)).toBeNull(); + }); + + test("a stale review cannot act after a newer review replaces it", () => { + const session = new GitGutterPeekSession(); + session.setChanges(changesOf(THREE_CHANGES), 7); + const first = session.open(0, 7)!; + const second = session.open(0, 7)!; + + expect(session.isCurrent(first, 7)).toBe(false); + expect(session.isCurrent(second, 7)).toBe(true); + expect(session.isCurrent(second, 8)).toBe(false); + }); + + test("a Git refresh keeps an unchanged review open and re-indexes it", () => { + const session = new GitGutterPeekSession(); + session.setChanges(changesOf(THREE_CHANGES), 7); + session.open(2, 7); + + // The first change was staged: the refreshed list no longer contains it. + const staged = computeGitGutterChanges(["a", "B", "c", "d", "e", "f"], THREE_CHANGES, monacoLineDiff)!; + const peek = session.setChanges(staged, 7); + + expect(peek?.index).toBe(1); + expect(peek?.total).toBe(2); + expect(peek?.change.modifiedLines).toEqual(["F"]); + }); + + test("a refresh that removes the open change closes the review", () => { + const session = new GitGutterPeekSession(); + session.setChanges(changesOf(THREE_CHANGES), 7); + session.open(0, 7); + + const afterStage = computeGitGutterChanges(["a", "B", "c", "d", "e", "f"], THREE_CHANGES, monacoLineDiff)!; + expect(session.setChanges(afterStage, 7)).toBeNull(); + expect(session.peek).toBeNull(); + }); + + test("closing, as on a file switch, clears the open review", () => { + const session = new GitGutterPeekSession(); + session.setChanges(changesOf(THREE_CHANGES), 7); + const peek = session.open(1, 7)!; + session.close(); + expect(session.peek).toBeNull(); + expect(session.isCurrent(peek, 7)).toBe(false); + }); +}); diff --git a/windows/tauri/src/features/editor/engines/monaco/git-gutter-peek-session.ts b/windows/tauri/src/features/editor/engines/monaco/git-gutter-peek-session.ts new file mode 100644 index 000000000..10debba53 --- /dev/null +++ b/windows/tauri/src/features/editor/engines/monaco/git-gutter-peek-session.ts @@ -0,0 +1,102 @@ +import { + adjacentGitGutterChangeIndex, + gitGutterChangeKey, + type GitGutterChange, +} from "@/features/git/utils/git-gutter-changes"; + +/** The change an open inline review shows, bound to the text it was computed from. */ +export interface GitGutterPeek { + index: number; + total: number; + change: GitGutterChange; + key: string; + /** Monaco model version the change list was computed for. */ + version: number; + /** Increments on every open, so a late action from a replaced review is ignored. */ + generation: number; +} + +/** + * Owns which change list is current and which change is open. It has no + * Monaco dependency: the editor adapter reports content and model changes and + * renders whatever `peek` is. + */ +export class GitGutterPeekSession { + private changes: GitGutterChange[] = []; + private version = -1; + private generation = 0; + private current: GitGutterPeek | null = null; + + get peek(): GitGutterPeek | null { + return this.current; + } + + get changeList(): readonly GitGutterChange[] { + return this.changes; + } + + /** + * Publishes a change list computed for `version`. An open review stays open + * only when the same change, with the same content, is still in the list. + */ + setChanges(changes: GitGutterChange[], version: number): GitGutterPeek | null { + this.changes = changes; + this.version = version; + const open = this.current; + if (!open) return null; + // A Git refresh (save, stage, external change) recomputes the list for + // the same editor text; the review follows its change or closes when the + // change is gone, for example after it was staged. + const index = changes.findIndex((change) => gitGutterChangeKey(change) === open.key); + this.current = index < 0 ? null : { ...open, index, total: changes.length, version }; + return this.current; + } + + open(index: number, version: number): GitGutterPeek | null { + if (version !== this.version) return null; + const change = this.changes[index]; + if (!change) return null; + this.current = { + index, + total: this.changes.length, + change, + key: gitGutterChangeKey(change), + version, + generation: ++this.generation, + }; + return this.current; + } + + navigate(direction: "previous" | "next", version: number): GitGutterPeek | null { + const from = this.current?.index ?? -1; + const index = + from < 0 + ? direction === "next" + ? 0 + : this.changes.length - 1 + : adjacentGitGutterChangeIndex(this.changes.length, from, direction); + return index < 0 ? null : this.open(index, version); + } + + /** Editing, switching models or disposing makes the open review stale. */ + close(): void { + this.current = null; + } + + /** Text edits invalidate the published list until it is recomputed. */ + invalidate(): void { + this.current = null; + this.changes = []; + this.version = -1; + } + + /** True only for the review that is open now, over the text it was computed from. */ + isCurrent(peek: GitGutterPeek, version: number): boolean { + return ( + this.current !== null && + this.current.generation === peek.generation && + this.current.version === version && + this.version === version + ); + } +} diff --git a/windows/tauri/src/features/editor/engines/monaco/git-gutter-peek-widget.ts b/windows/tauri/src/features/editor/engines/monaco/git-gutter-peek-widget.ts new file mode 100644 index 000000000..44d9bafb8 --- /dev/null +++ b/windows/tauri/src/features/editor/engines/monaco/git-gutter-peek-widget.ts @@ -0,0 +1,208 @@ +import { editor as monacoEditor } from "monaco-editor"; +import type * as Monaco from "monaco-editor"; +import { mountDiffReview, type ReviewRow } from "@lithe/editor/diff-review"; + +const { EditorOption } = monacoEditor; + +const WIDGET_ID = "lithe.gitGutterPeek"; +const HEADER_HEIGHT = 28; +const MIN_BODY_LINES = 3; +const MAX_BODY_LINES = 20; +/** The review never covers more than this share of the editor viewport. */ +const MAX_VIEWPORT_SHARE = 0.6; + +export interface GitGutterPeekLabels { + title: string; + previous: string; + next: string; + stage: string; + stageUnavailable: string; + openDiff: string; + close: string; +} + +export interface GitGutterPeekView { + rows: ReviewRow[]; + /** Editor line below which the review opens; 0 places it above line 1. */ + anchorLine: number; + language: string; + labels: GitGutterPeekLabels; + canNavigate: boolean; + canStage: boolean; + staging: boolean; +} + +export interface GitGutterPeekHandlers { + previous(): void; + next(): void; + stage(): void; + openDiff(): void; + close(): void; +} + +export interface GitGutterPeekWidget { + show(view: GitGutterPeekView): void; + dispose(): void; +} + +function iconButton(codicon: string, onClick: () => void): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.className = "lithe-git-peek-button"; + const icon = document.createElement("span"); + icon.className = `codicon codicon-${codicon}`; + icon.setAttribute("aria-hidden", "true"); + button.append(icon); + // Keep focus in the editor; the review is a transient inspection surface. + button.addEventListener("mousedown", (event) => event.preventDefault()); + button.addEventListener("click", onClick); + return button; +} + +function labelButton(button: HTMLButtonElement, label: string) { + button.title = label; + button.setAttribute("aria-label", label); +} + +/** + * An inline, read-only review of one Git change, embedded between editor + * lines like VS Code's dirty-diff peek. A view zone reserves the space and an + * overlay widget renders into it, so Monaco moves it while scrolling without + * React state updates. The diff uses the shared review renderer with its own + * models, which `dispose` releases. + */ +export function createGitGutterPeekWidget( + editor: Monaco.editor.ICodeEditor, + handlers: GitGutterPeekHandlers, +): GitGutterPeekWidget { + const root = document.createElement("div"); + root.className = "lithe-git-peek"; + root.setAttribute("role", "region"); + root.style.top = "-1000px"; + + const header = document.createElement("div"); + header.className = "lithe-git-peek-header"; + header.style.height = `${HEADER_HEIGHT}px`; + const title = document.createElement("span"); + title.className = "lithe-git-peek-title"; + const actions = document.createElement("div"); + actions.className = "lithe-git-peek-actions"; + const stage = iconButton("add", handlers.stage); + const previous = iconButton("arrow-up", handlers.previous); + const next = iconButton("arrow-down", handlers.next); + const openDiff = iconButton("go-to-file", handlers.openDiff); + const close = iconButton("close", handlers.close); + actions.append(stage, next, previous, openDiff, close); + header.append(title, actions); + + const body = document.createElement("div"); + body.className = "lithe-git-peek-body"; + root.append(header, body); + root.addEventListener("keydown", (event) => { + if (event.key !== "Escape") return; + event.preventDefault(); + event.stopPropagation(); + handlers.close(); + }); + + const review = mountDiffReview(body); + const overlay: Monaco.editor.IOverlayWidget = { + getId: () => WIDGET_ID, + getDomNode: () => root, + // Positioned by the view zone callbacks below, like Monaco's ZoneWidget. + getPosition: () => null, + }; + editor.addOverlayWidget(overlay); + + let zoneId: string | null = null; + let height = 0; + let anchorLine = -1; + let disposed = false; + + const layoutWidth = () => { + const info = editor.getLayoutInfo(); + root.style.width = `${Math.max(0, info.width - info.minimap.minimapWidth - info.verticalScrollbarWidth)}px`; + }; + layoutWidth(); + const layoutListener = editor.onDidLayoutChange(layoutWidth); + + const removeZone = () => { + if (zoneId === null) return; + const id = zoneId; + zoneId = null; + editor.changeViewZones((accessor) => accessor.removeZone(id)); + }; + + return { + show(view) { + if (disposed) return; + title.textContent = view.labels.title; + root.setAttribute("aria-label", view.labels.title); + labelButton(previous, view.labels.previous); + labelButton(next, view.labels.next); + labelButton(openDiff, view.labels.openDiff); + labelButton(close, view.labels.close); + labelButton(stage, view.canStage ? view.labels.stage : view.labels.stageUnavailable); + previous.disabled = !view.canNavigate; + next.disabled = !view.canNavigate; + stage.disabled = !view.canStage || view.staging; + + const lineHeight = editor.getOption(EditorOption.lineHeight); + const viewportLines = Math.floor( + (editor.getLayoutInfo().height * MAX_VIEWPORT_SHARE - HEADER_HEIGHT) / lineHeight, + ); + const bodyLines = Math.max( + MIN_BODY_LINES, + Math.min(view.rows.length, MAX_BODY_LINES, Math.max(MIN_BODY_LINES, viewportLines)), + ); + const nextHeight = HEADER_HEIGHT + bodyLines * lineHeight + 2; + body.style.height = `${nextHeight - HEADER_HEIGHT - 2}px`; + root.style.height = `${nextHeight}px`; + + if (zoneId === null || height !== nextHeight || view.anchorLine !== anchorLine) { + removeZone(); + height = nextHeight; + anchorLine = view.anchorLine; + editor.changeViewZones((accessor) => { + zoneId = accessor.addZone({ + afterLineNumber: view.anchorLine, + heightInPx: nextHeight, + domNode: document.createElement("div"), + onDomNodeTop: (top) => { + root.style.top = `${top}px`; + }, + }); + }); + editor.revealLinesInCenterIfOutsideViewport( + Math.max(1, view.anchorLine), + Math.max(1, view.anchorLine + 1), + ); + } + + review.configure({ + fontFamily: editor.getOption(EditorOption.fontFamily), + fontSize: editor.getOption(EditorOption.fontSize), + lineHeight, + renderIndicators: true, + scrollbar: { alwaysConsumeMouseWheel: false }, + }); + void review.update({ + rows: view.rows, + language: view.language, + sideBySide: false, + collapse: false, + overview: false, + highlightWords: true, + }); + }, + dispose() { + if (disposed) return; + disposed = true; + layoutListener.dispose(); + removeZone(); + editor.removeOverlayWidget(overlay); + review.dispose(); + root.remove(); + }, + }; +} diff --git a/windows/tauri/src/features/editor/engines/monaco/monaco-diff-internals.d.ts b/windows/tauri/src/features/editor/engines/monaco/monaco-diff-internals.d.ts new file mode 100644 index 000000000..088adba2e --- /dev/null +++ b/windows/tauri/src/features/editor/engines/monaco/monaco-diff-internals.d.ts @@ -0,0 +1,25 @@ +// Narrow declaration for Monaco 0.55.1's synchronous line diff. The public API +// only exposes it through a diff editor; the gutter needs the raw line ranges. +// Re-check this shape whenever the pinned Monaco version changes. +declare module "monaco-editor/esm/vs/editor/common/diff/linesDiffComputers.js" { + interface LineRange { + readonly startLineNumber: number; + readonly endLineNumberExclusive: number; + } + interface LineRangeMapping { + readonly original: LineRange; + readonly modified: LineRange; + } + interface LinesDiff { + readonly changes: readonly LineRangeMapping[]; + readonly hitTimeout: boolean; + } + interface LinesDiffComputer { + computeDiff( + originalLines: string[], + modifiedLines: string[], + options: { ignoreTrimWhitespace: boolean; maxComputationTimeMs: number; computeMoves: boolean }, + ): LinesDiff; + } + export const linesDiffComputers: { getDefault(): LinesDiffComputer }; +} diff --git a/windows/tauri/src/features/editor/engines/monaco/monaco-line-diff.ts b/windows/tauri/src/features/editor/engines/monaco/monaco-line-diff.ts new file mode 100644 index 000000000..0ee704935 --- /dev/null +++ b/windows/tauri/src/features/editor/engines/monaco/monaco-line-diff.ts @@ -0,0 +1,35 @@ +import { linesDiffComputers } from "monaco-editor/esm/vs/editor/common/diff/linesDiffComputers.js"; +import type { GitGutterLineDiff } from "@/features/git/utils/git-gutter-changes"; + +/** + * Time budget for one gutter comparison. It runs on the UI thread after the + * user pauses typing; a file too large to compare in this time shows no + * markers instead of blocking input. + */ +const GIT_GUTTER_DIFF_BUDGET_MS = 150; + +/** Monaco's own line diff, the same algorithm its diff editor uses. */ +export const monacoLineDiff: GitGutterLineDiff = (original, modified) => { + // Monaco's computer assumes at least one line on each side, as in a model. + if (original.length === 0 || modified.length === 0) { + if (original.length === modified.length) return []; + return [{ + originalStart: 1, + originalEnd: original.length + 1, + modifiedStart: 1, + modifiedEnd: modified.length + 1, + }]; + } + const result = linesDiffComputers.getDefault().computeDiff([...original], [...modified], { + ignoreTrimWhitespace: false, + maxComputationTimeMs: GIT_GUTTER_DIFF_BUDGET_MS, + computeMoves: false, + }); + if (result.hitTimeout) return null; + return result.changes.map((change) => ({ + originalStart: change.original.startLineNumber, + originalEnd: change.original.endLineNumberExclusive, + modifiedStart: change.modified.startLineNumber, + modifiedEnd: change.modified.endLineNumberExclusive, + })); +}; diff --git a/windows/tauri/src/features/editor/styles/monaco-editor.css b/windows/tauri/src/features/editor/styles/monaco-editor.css index 5a019a46a..3fd46e5df 100644 --- a/windows/tauri/src/features/editor/styles/monaco-editor.css +++ b/windows/tauri/src/features/editor/styles/monaco-editor.css @@ -516,3 +516,99 @@ .monaco-vim-statusbar:empty { display: none !important; } + +/* Git change markers in the line-decoration gutter (#681). */ +.monaco-editor-shell .monaco-editor .lithe-git-gutter { + width: 3px !important; + margin-left: 3px; + cursor: pointer; +} + +.monaco-editor-shell .monaco-editor .lithe-git-gutter-added { + background: var(--git-added); +} + +.monaco-editor-shell .monaco-editor .lithe-git-gutter-modified { + background: var(--git-modified); +} + +/* A deletion owns no line; draw a wedge at the top of the following line. */ +.monaco-editor-shell .monaco-editor .lithe-git-gutter-deleted { + width: 0 !important; + height: 0 !important; + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + border-left: 6px solid var(--git-deleted); + margin-top: -4px; +} + +.monaco-editor-shell .monaco-editor .lithe-git-peek { + position: absolute; + left: 0; + box-sizing: border-box; + display: flex; + flex-direction: column; + border-top: 1px solid var(--primary); + border-bottom: 1px solid var(--primary); + background: var(--background); + color: var(--foreground); + z-index: 5; +} + +.monaco-editor-shell .monaco-editor .lithe-git-peek-header { + display: flex; + flex: none; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0 0.5rem; + background: color-mix(in srgb, var(--surface) 90%, var(--background)); + font-family: var(--app-font-family); + font-size: var(--ui-text-sm); +} + +.monaco-editor-shell .monaco-editor .lithe-git-peek-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.monaco-editor-shell .monaco-editor .lithe-git-peek-actions { + display: flex; + flex: none; + gap: 0.125rem; +} + +.monaco-editor-shell .monaco-editor .lithe-git-peek-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border: 0; + border-radius: var(--radius-sm, 3px); + background: transparent; + color: inherit; + cursor: pointer; +} + +.monaco-editor-shell .monaco-editor .lithe-git-peek-button:hover:not(:disabled) { + background: color-mix(in srgb, var(--foreground) 12%, transparent); + outline: none; +} + +.monaco-editor-shell .monaco-editor .lithe-git-peek-button:disabled { + opacity: 0.4; + cursor: default; +} + +.monaco-editor-shell .monaco-editor .lithe-git-peek-body { + position: relative; + flex: 1; + min-height: 0; +} + +.monaco-editor-shell .monaco-editor .lithe-git-peek-button:focus-visible { + outline: 1px solid var(--primary); + outline-offset: -1px; +} diff --git a/windows/tauri/src/features/file-explorer/components/file-navigator-sidebar.tsx b/windows/tauri/src/features/file-explorer/components/file-navigator-sidebar.tsx index 3d5765100..8418712a1 100644 --- a/windows/tauri/src/features/file-explorer/components/file-navigator-sidebar.tsx +++ b/windows/tauri/src/features/file-explorer/components/file-navigator-sidebar.tsx @@ -366,7 +366,7 @@ export const FileNavigatorSidebar = memo(function FileNavigatorSidebar({ document.addEventListener("pointermove", handlePointerMove); document.addEventListener("pointerup", handlePointerUp); - document.body.style.cursor = "col-resize"; + document.body.style.cursor = "ew-resize"; document.body.style.userSelect = "none"; }, [navigatorLayout.width, resizeTo], @@ -462,7 +462,7 @@ export const FileNavigatorSidebar = memo(function FileNavigatorSidebar({ )}
{isResizing ? ( -
+
) : null} ); diff --git a/windows/tauri/src/features/git/api/git-commits-api.test.ts b/windows/tauri/src/features/git/api/git-commits-api.test.ts index f5a6d61e7..0c1668ab1 100644 --- a/windows/tauri/src/features/git/api/git-commits-api.test.ts +++ b/windows/tauri/src/features/git/api/git-commits-api.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; +import { readFileSync } from "node:fs"; import * as gitEvents from "../events/git-events"; let gitWriteResult = { output: "", exitCode: 0 }; @@ -6,8 +7,15 @@ let gitWriteError: Error | null = null; let gitReadError: unknown = null; const emitGitChanged = spyOn(gitEvents, "emitGitChanged"); +let commitLookupResult: unknown = null; +let commitLookupError: unknown = null; + const invoke = mock(async (command: string, _args?: unknown): Promise => { if (command === "git_discover_repo") return "C:/repo"; + if (command === "git.commit") { + if (commitLookupError) throw commitLookupError; + return commitLookupResult; + } if (command === "git.write") { if (gitWriteError) throw gitWriteError; return gitWriteResult; @@ -24,11 +32,13 @@ mock.module("@/platform/tauri-core", () => ({ ...tauriCoreModule, invoke })); const { cherryPickCommit, commitSelectedChanges, + getCommitDescription, getGitHistoryPage, getGitReferences, getGitReferencesAtRoot, resetToCommit, revertCommit, + withCommitDescription, } = await import("./git-commits-api"); beforeEach(() => { @@ -37,6 +47,72 @@ beforeEach(() => { gitWriteResult = { output: "", exitCode: 0 }; gitWriteError = null; gitReadError = null; + commitLookupResult = null; + commitLookupError = null; +}); + +describe("Git commit message details", () => { + const listedCommit = { + hash: "detail-subject-only", + shortHash: "detail", + parentHashes: [], + message: "Fix commit details", + author: "Developer", + date: "2026/08/16 10:00", + decorations: "", + }; + + // Regression for #771: history rows only carry the subject, so the full + // message must come from the shared single-commit lookup. + test("reads the body through git.commit and reuses it for the same commit", async () => { + // The shared fixture is the Rust Core response shape for `git.commit`. + const fixture = JSON.parse( + readFileSync( + new URL("../../../../../../shared/fixtures/git/commit-lookup-response-v1.json", import.meta.url), + "utf8", + ), + ) as { body: string }; + commitLookupResult = fixture; + + expect(await getCommitDescription("C:/repo", "detail-cached")).toBe(fixture.body); + expect(await getCommitDescription("C:/repo", "detail-cached")).toBe(fixture.body); + + const lookups = invoke.mock.calls.filter(([command]) => command === "git.commit"); + expect(lookups).toEqual([["git.commit", { repoPath: "C:/repo", commit: "detail-cached" }]]); + }); + + test("treats a subject-only commit as an empty description", async () => { + commitLookupResult = { commit: { hash: listedCommit.hash }, body: "" }; + + expect(await withCommitDescription("C:/repo", listedCommit)).toEqual({ + ...listedCommit, + description: "", + }); + }); + + test("keeps the subject-only commit and retries later when the lookup fails", async () => { + const consoleError = spyOn(console, "error").mockImplementation(() => {}); + try { + commitLookupError = new Error("commit lookup failed"); + const commit = { ...listedCommit, hash: "detail-retry" }; + + expect(await withCommitDescription("C:/repo", commit)).toBe(commit); + expect(consoleError).toHaveBeenCalledWith("Failed to get commit details:", commitLookupError); + + commitLookupError = null; + commitLookupResult = { commit: { hash: "detail-retry" }, body: "Recovered body" }; + expect(await getCommitDescription("C:/repo", "detail-retry")).toBe("Recovered body"); + } finally { + consoleError.mockRestore(); + } + }); + + test("does not re-read a commit whose description is already known", async () => { + const commit = { ...listedCommit, hash: "detail-known", description: "Known body" }; + + expect(await withCommitDescription("C:/repo", commit)).toBe(commit); + expect(invoke.mock.calls.some(([command]) => command === "git.commit")).toBe(false); + }); }); describe("Git commit history reads", () => { diff --git a/windows/tauri/src/features/git/api/git-commits-api.ts b/windows/tauri/src/features/git/api/git-commits-api.ts index 68214f704..40102edc0 100644 --- a/windows/tauri/src/features/git/api/git-commits-api.ts +++ b/windows/tauri/src/features/git/api/git-commits-api.ts @@ -255,6 +255,70 @@ export const getCommitFiles = async ( } }; +interface GitCommitLookupResult { + commit?: { hash?: string }; + body?: string; +} + +/** + * History pages carry only the subject so paging stays compact; the message + * body is read on demand when one commit is inspected, copied, or diffed. + * A commit object is immutable, so a body never goes stale for its hash. + */ +const COMMIT_DESCRIPTION_CACHE_LIMIT = 200; +const commitDescriptionCache = new Map(); + +const rememberCommitDescription = (key: string, description: string) => { + commitDescriptionCache.delete(key); + commitDescriptionCache.set(key, description); + if (commitDescriptionCache.size > COMMIT_DESCRIPTION_CACHE_LIMIT) { + const oldest = commitDescriptionCache.keys().next().value; + if (oldest !== undefined) commitDescriptionCache.delete(oldest); + } +}; + +/** Returns the commit message body after the subject, or null when it cannot be read. */ +export const getCommitDescription = async ( + repoPath: string, + commitHash: string, +): Promise => { + try { + const resolvedRepoPath = await resolveRepositoryPath(repoPath); + if (!resolvedRepoPath) return null; + const cacheKey = JSON.stringify([resolvedRepoPath, commitHash]); + const cached = commitDescriptionCache.get(cacheKey); + if (cached !== undefined) { + rememberCommitDescription(cacheKey, cached); + return cached; + } + + const result = await runGitRead(resolvedRepoPath, `commit-details:${commitHash}`, () => + tauriInvoke("git.commit", { + repoPath: resolvedRepoPath, + commit: commitHash, + }), + ); + const description = typeof result?.body === "string" ? result.body : ""; + rememberCommitDescription(cacheKey, description); + return description; + } catch (error) { + if (!isNotGitRepositoryError(error)) { + console.error("Failed to get commit details:", error); + } + return null; + } +}; + +/** Fills `description` from the full message; keeps the subject-only commit on failure. */ +export const withCommitDescription = async ( + repoPath: string, + commit: GitCommit, +): Promise => { + if (commit.description !== undefined) return commit; + const description = await getCommitDescription(repoPath, commit.hash); + return description === null ? commit : { ...commit, description }; +}; + export const resetToCommit = ( repoPath: string, revision: string, diff --git a/windows/tauri/src/features/git/api/git-diff-api.ts b/windows/tauri/src/features/git/api/git-diff-api.ts index 6e1561c7a..aa2bac9c6 100644 --- a/windows/tauri/src/features/git/api/git-diff-api.ts +++ b/windows/tauri/src/features/git/api/git-diff-api.ts @@ -238,11 +238,22 @@ export const getFileDiff = async ( } }; +/** + * Git's largest accepted `--unified` value. A single-file review asks for the + * whole file as context so omitted regions can be revealed without inventing + * source text; Git emits one hunk per file at this size. + */ +const FULL_FILE_CONTEXT_LINES = 2_147_483_647; + +const markFullContext = (diff: GitDiff | null): GitDiff | null => + diff ? { ...diff, is_full_context: true } : diff; + export const getWorkingTreePathDiff = async ( repoPath: string, filePath: string, _untracked = false, originalPath?: string, + fullContext = false, ): Promise => { try { const resolved = await resolveRepositoryForFile(repoPath, filePath); @@ -251,17 +262,19 @@ export const getWorkingTreePathDiff = async ( const filePaths = [ ...new Set([originalPath, resolved.filePath].filter(Boolean) as string[]), ]; - return await runGitRead( + const diff = await runGitRead( resolved.repoPath, - `working-tree-path-diff:${resolved.filePath}`, + `working-tree-path-diff:${fullContext ? "full:" : ""}${resolved.filePath}`, () => tauriInvoke("git_diff_file", { repoPath: resolved.repoPath, filePath: resolved.filePath, ...(filePaths.length > 1 ? { filePaths } : {}), worktreeSnapshot: true, + ...(fullContext ? { contextLines: FULL_FILE_CONTEXT_LINES } : {}), }), ); + return fullContext ? markFullContext(diff) : diff; } catch (error) { if (!isNotGitRepositoryError(error) && !isNoDiffFoundError(error)) { console.error("Failed to get working-tree path diff:", error); @@ -270,6 +283,40 @@ export const getWorkingTreePathDiff = async ( } }; +/** + * Index or worktree diff of one file with the whole file as context. It is not + * cached with `getFileDiff` results because gutter and AI consumers expect the + * regular sparse patch for the same key. + */ +export const getFullContextFileDiff = async ( + repoPath: string, + filePath: string, + staged = false, +): Promise => { + try { + const resolved = await resolveRepositoryForFile(repoPath, filePath); + if (!resolved) return null; + + const diff = await runGitRead( + resolved.repoPath, + `full-context-diff:${staged ? "staged" : "unstaged"}:${resolved.filePath}`, + () => + tauriInvoke("git_diff_file", { + repoPath: resolved.repoPath, + filePath: resolved.filePath, + staged, + contextLines: FULL_FILE_CONTEXT_LINES, + }), + ); + return markFullContext(diff); + } catch (error) { + if (!isNotGitRepositoryError(error) && !isNoDiffFoundError(error)) { + console.error("Failed to get full-context file diff:", error); + } + return null; + } +}; + export const getUntrackedFileDiff = async ( repoPath: string, filePath: string, diff --git a/windows/tauri/src/features/git/api/git-status-api.test.ts b/windows/tauri/src/features/git/api/git-status-api.test.ts index bcb49ac8e..a01a489db 100644 --- a/windows/tauri/src/features/git/api/git-status-api.test.ts +++ b/windows/tauri/src/features/git/api/git-status-api.test.ts @@ -5,9 +5,13 @@ let unavailableRepo: string | null = null; let statusFailure: Error | null = null; let interceptWrite: ((args: Record) => Promise) | undefined; +// Keyed by command: repository discovery may or may not run first, depending +// on what earlier tests in a randomized order have already cached. +let fileDiffResponse: unknown = null; const invoke = mock(async (command: string, args?: Record): Promise => { if (command === "git.write" && interceptWrite) return interceptWrite(args ?? {}); + if (command === "git_diff_file") return fileDiffResponse; if (command === "git_discover_repo") { const path = String(args?.path ?? ""); return path.startsWith("C:/workspace/") ? path : "C:/repo"; @@ -37,18 +41,21 @@ let invokeSpy: ReturnType>; const { addPathsToGitignore, addPathsToLocalGitExclude, + discardHunk, rollbackFilesChanges, setFilesStaged, getWorkspaceGitStatus, getWorkspaceRootGitStatus, getGitStatus, } = await import("./git-status-api"); -const { getWorkingTreePathDiff } = await import("./git-diff-api"); +const { getFullContextFileDiff, getWorkingTreePathDiff } = await import("./git-diff-api"); +const gitEvents = await import("../events/git-events"); beforeEach(() => { invokeSpy = spyOn(tauriCore, "invoke").mockImplementation(invoke as typeof tauriCore.invoke); invoke.mockClear(); interceptWrite = undefined; + fileDiffResponse = null; unavailableRepo = null; statusFailure = null; }); @@ -98,6 +105,44 @@ describe("Git status batch mutations", () => { }); }); + test("discards one hunk in its owning repository and announces the file change", async () => { + const hunk = { + file_path: "src/App.tsx", + lines: [ + { line_type: "header" as const, content: "@@ -1 +1 @@" }, + { line_type: "removed" as const, content: "old", old_line_number: 1 }, + { line_type: "added" as const, content: "new", new_line_number: 1 }, + ], + }; + const emitted = spyOn(gitEvents, "emitGitChanged"); + const recorded = () => emitted.mock.calls.map(([change]) => change); + try { + await expect(discardHunk("C:/workspace/service-a", hunk)).resolves.toBe(true); + expect(invoke).toHaveBeenLastCalledWith("git_discard_hunk", { + repoPath: "C:/workspace/service-a", + hunk, + }); + expect(recorded()).toEqual([{ + repoPath: "C:/workspace/service-a", + filePath: "src/App.tsx", + scopes: ["working-tree"], + source: "discard-hunk", + }]); + + // A rejected patch reports failure without announcing a change. Route by + // command: repository discovery may already be cached in random order. + invokeSpy.mockImplementation((async (command: string, args?: Record) => { + if (command === "git_discard_hunk") throw new Error("patch does not apply"); + return invoke(command, args); + }) as typeof tauriCore.invoke); + const error = spyOn(console, "error").mockImplementation(() => {}); + try { + await expect(discardHunk("C:/repo", hunk)).resolves.toBe(false); + } finally { error.mockRestore(); } + expect(recorded()).toHaveLength(1); + } finally { emitted.mockRestore(); } + }); + test("adds selected paths to the repository gitignore", async () => { await expect(addPathsToGitignore("C:/repo", ["generated/", "local.env"])).resolves.toBe(true); @@ -173,6 +218,36 @@ describe("Git status review diffs", () => { worktreeSnapshot: true, }); }); + + // #557: a single-file review needs every source line so folded unchanged + // regions can be expanded; the result is marked for the renderer. + test("requests the whole file as context for a single-file review", async () => { + fileDiffResponse = { file_path: "src/App.tsx", lines: [] }; + await expect( + getWorkingTreePathDiff("C:/repo", "src/App.tsx", false, undefined, true), + ).resolves.toMatchObject({ is_full_context: true }); + + expect(invoke).toHaveBeenLastCalledWith("git_diff_file", { + repoPath: "C:/repo", + filePath: "src/App.tsx", + worktreeSnapshot: true, + contextLines: 2_147_483_647, + }); + }); + + test("requests a full-context staged diff without the sparse cache", async () => { + fileDiffResponse = { file_path: "src/App.tsx", lines: [] }; + await expect(getFullContextFileDiff("C:/repo", "src/App.tsx", true)).resolves.toMatchObject({ + is_full_context: true, + }); + + expect(invoke).toHaveBeenLastCalledWith("git_diff_file", { + repoPath: "C:/repo", + filePath: "src/App.tsx", + staged: true, + contextLines: 2_147_483_647, + }); + }); }); function deferred() { diff --git a/windows/tauri/src/features/git/api/git-status-api.ts b/windows/tauri/src/features/git/api/git-status-api.ts index 2ca4cb5d8..b8c271b35 100644 --- a/windows/tauri/src/features/git/api/git-status-api.ts +++ b/windows/tauri/src/features/git/api/git-status-api.ts @@ -299,6 +299,28 @@ export const unstageHunk = async (repoPath: string, hunk: GitHunk): Promise => { + try { + const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); + await tauriInvoke("git_discard_hunk", { repoPath: resolvedRepoPath, hunk }); + emitGitChanged({ + repoPath: resolvedRepoPath, + filePath: hunk.file_path, + scopes: ["working-tree"], + source: "discard-hunk", + }); + return true; + } catch (error) { + console.error("Failed to discard hunk:", error); + return false; + } +}; + export const discardAllChanges = async (repoPath: string): Promise => { try { const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); diff --git a/windows/tauri/src/features/git/components/diff/git-diff-editor-stack.tsx b/windows/tauri/src/features/git/components/diff/git-diff-editor-stack.tsx index bc82e74a0..2eda5beca 100644 --- a/windows/tauri/src/features/git/components/diff/git-diff-editor-stack.tsx +++ b/windows/tauri/src/features/git/components/diff/git-diff-editor-stack.tsx @@ -340,6 +340,7 @@ const GitDiffEditorStack = memo(function GitDiffEditorStack({ const [isFileTreeVisible, setIsFileTreeVisible] = useState(true); const [fileNavigatorViewMode, setFileNavigatorViewMode] = useState("tree"); const isWorkingTree = multiDiff.commitHash === "working-tree"; + const showsFileList = !isWorkingTree && !multiDiff.hideFileList; const isWorkingTreeBuffer = activeBuffer?.path === "diff://working-tree/all-files"; const isActiveMultiDiff = activeBuffer?.type === "diff" && activeBuffer.diffData === multiDiff; const isRefreshingRef = useRef(false); @@ -798,7 +799,7 @@ const GitDiffEditorStack = memo(function GitDiffEditorStack({ > - {!isWorkingTree ? ( + {showsFileList ? ( - {!isWorkingTree && isFileTreeVisible ? ( + {showsFileList && isFileTreeVisible ? ( monacoDiffRows(diff), [diff]); const sourcePath = diff.new_path || diff.old_path || diff.file_path; + const fullContext = diff.is_full_context === true; const latest = useRef({ rows, sourcePath }); const updating = useRef(false); const repoPath = staging?.repoPath; const isStaged = staging?.isStaged ?? false; + const canDiscard = staging?.canDiscard === true; const actionTitle = isStaged ? t("git.diff.unstage") : t("git.diff.stage"); + const discardTitle = t("git.rollback"); + // The confirmation runs from an owner created per patch; read the current + // translator instead of rebuilding the owner when the language changes. + const translate = useRef(t); + translate.current = t; const { fontSize, fontFamily, lineHeight, tabSize, themeId, editorItalicComments, editorFontLigatures } = useMonacoEditorSettings(); useEffect(() => { @@ -99,28 +108,38 @@ export default function MonacoGitDiff({ diff, viewMode = "split", showWhitespace }, []); useEffect(() => { - const owner = createMonacoDiffHunkActions(diff, repoPath ? { repoPath, isStaged } : undefined, - { stage: stageHunk, unstage: unstageHunk }); + const owner = createMonacoDiffHunkActions(diff, repoPath ? { repoPath, isStaged, canDiscard } : undefined, + { stage: stageHunk, unstage: unstageHunk, discard: discardHunk, + confirmDiscard: async hunk => { + // Reuse the product-wide "confirm before discard" preference that + // already guards file and repository rollbacks. + if (!useSettingsStore.getState().settings.confirmBeforeDiscard) return true; + const t = translate.current; + return showConfirmDialog(t("git.diff.rollbackHunkConfirm", { path: hunk.file_path }), { + title: t("git.diff.rollbackHunk"), confirmLabel: t("git.rollback") }); + } }); hunkActions.current = owner; setActionFailed(false); return () => { owner.dispose(); if (hunkActions.current === owner) hunkActions.current = null; }; - }, [diff, repoPath, isStaged]); + }, [diff, repoPath, isStaged, canDiscard]); useEffect(() => { let cancelled = false; setError(undefined); updating.current = true; - const action = hunkActions.current?.action; + const titles = { stage: actionTitle, unstage: actionTitle, discard: discardTitle }; + const actions = (hunkActions.current?.actions ?? []).map(id => ({ id, title: titles[id] })); + // Only a full-file patch may fold: Monaco's fold bands reveal hidden lines + // in place, which is meaningless for a sparse patch whose gaps are absent. void review.current!.update({ rows, language: toMonacoLanguageId(detectLanguageFromPath(sourcePath)), - sideBySide: viewMode === "split", collapse: false, overview: !embedded, - actions: action ? [{ id: action, title: actionTitle }] : [] }) + sideBySide: viewMode === "split", collapse: fullContext, overview: !embedded, actions }) .then(() => { if (!cancelled) { latest.current = { rows, sourcePath }; updating.current = false; } }) .catch(error => { if (!cancelled) setError(String(error)); }); return () => { cancelled = true; }; - }, [rows, sourcePath, viewMode, embedded, repoPath, isStaged, actionTitle]); + }, [rows, sourcePath, fullContext, viewMode, embedded, repoPath, isStaged, canDiscard, actionTitle, discardTitle]); useEffect(() => { review.current?.select({ matches: searchMatches.map(match => ({ rowID: `line-${match.lineIndex}`, diff --git a/windows/tauri/src/features/git/components/log/git-commit-details.test.tsx b/windows/tauri/src/features/git/components/log/git-commit-details.test.tsx new file mode 100644 index 000000000..fde8e19bf --- /dev/null +++ b/windows/tauri/src/features/git/components/log/git-commit-details.test.tsx @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { LocaleProvider } from "@/i18n/locale-provider"; +import { installHappyDom } from "@/test-utils/happy-dom"; +import type { GitCommit } from "../../types/git.types"; + +const commitsApi = await import("../../api/git-commits-api"); +const { GitCommitDetails } = await import("./git-commit-details"); + +const commit = (hash: string, message: string): GitCommit => ({ + hash, + shortHash: hash.slice(0, 7), + parentHashes: [], + message, + author: "Developer", + email: "developer@example.invalid", + date: "2026/08/16 10:00", + decorations: "", +}); + +const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }; +let originalActEnvironment: boolean | undefined; +let restoreDom: () => void; +let container: HTMLDivElement; +let root: Root; +const spies: Array<{ mockRestore: () => void }> = []; +const descriptionResolvers = new Map void>(); + +beforeEach(() => { + restoreDom = installHappyDom(); + originalActEnvironment = actGlobal.IS_REACT_ACT_ENVIRONMENT; + actGlobal.IS_REACT_ACT_ENVIRONMENT = true; + descriptionResolvers.clear(); + spies.push( + // Each lookup stays pending until the test releases it, so the order in + // which answers arrive is explicit. + spyOn(commitsApi, "getCommitDescription").mockImplementation( + (_repoPath: string, hash: string) => + new Promise((resolve) => descriptionResolvers.set(hash, resolve)), + ), + ); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + for (const spy of spies.splice(0)) spy.mockRestore(); + await act(async () => { + root.unmount(); + for (const resolve of descriptionResolvers.values()) resolve(null); + descriptionResolvers.clear(); + await Promise.resolve(); + }); + container.remove(); + if (originalActEnvironment === undefined) delete actGlobal.IS_REACT_ACT_ENVIRONMENT; + else actGlobal.IS_REACT_ACT_ENVIRONMENT = originalActEnvironment; + restoreDom(); +}); + +const renderDetails = async (selected: GitCommit) => { + await act(async () => { + root.render( + + + , + ); + }); +}; + +const resolveDescription = async (hash: string, description: string | null) => { + const resolve = descriptionResolvers.get(hash); + if (!resolve) throw new Error(`No pending description lookup for ${hash}`); + await act(async () => { + resolve(description); + await Promise.resolve(); + }); +}; + +describe("Git commit details message", () => { + // Regression for #771: the details pane showed only the subject line. + test("shows the full multi-line message body for the selected commit", async () => { + await renderDetails(commit("aaaaaaa1", "Fix commit details")); + expect(container.textContent).toContain("Fix commit details"); + + await resolveDescription("aaaaaaa1", "First body line\n\nSecond paragraph"); + + const body = [...container.querySelectorAll("div")].find( + (element) => element.textContent === "First body line\n\nSecond paragraph", + ); + expect(body).toBeDefined(); + expect(body?.className).toContain("whitespace-pre-wrap"); + }); + + test("ignores a late body for a commit that is no longer selected", async () => { + await renderDetails(commit("aaaaaaa1", "First subject")); + await renderDetails(commit("bbbbbbb2", "Second subject")); + + await resolveDescription("aaaaaaa1", "Stale body from the first commit"); + expect(container.textContent).not.toContain("Stale body from the first commit"); + + await resolveDescription("bbbbbbb2", "Body of the second commit"); + expect(container.textContent).toContain("Second subject"); + expect(container.textContent).toContain("Body of the second commit"); + }); + + test("keeps the subject when the body cannot be read", async () => { + await renderDetails(commit("ccccccc3", "Subject survives")); + await resolveDescription("ccccccc3", null); + + expect(container.textContent).toContain("Subject survives"); + expect(container.textContent).toContain("ccccccc3"); + }); +}); diff --git a/windows/tauri/src/features/git/components/log/git-commit-details.tsx b/windows/tauri/src/features/git/components/log/git-commit-details.tsx new file mode 100644 index 000000000..872e28658 --- /dev/null +++ b/windows/tauri/src/features/git/components/log/git-commit-details.tsx @@ -0,0 +1,67 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "@/i18n/locale-provider"; +import { getCommitDescription } from "../../api/git-commits-api"; +import type { GitCommit } from "../../types/git.types"; + +/** + * History rows carry only the subject. Reads the rest of the message for the + * inspected commit and ignores answers for a commit that is no longer shown. + */ +function useCommitDescription(repoPath: string | null, commit: GitCommit | null): string { + const hash = commit?.hash ?? null; + const knownDescription = commit?.description; + const [loaded, setLoaded] = useState<{ key: string; description: string } | null>(null); + const key = repoPath && hash ? JSON.stringify([repoPath, hash]) : null; + + useEffect(() => { + if (!repoPath || !hash || knownDescription !== undefined) return; + let current = true; + void getCommitDescription(repoPath, hash).then((description) => { + if (current && description !== null) { + setLoaded({ key: JSON.stringify([repoPath, hash]), description }); + } + }); + return () => { + current = false; + }; + }, [repoPath, hash, knownDescription]); + + if (knownDescription !== undefined) return knownDescription; + return loaded && loaded.key === key ? loaded.description : ""; +} + +/** Full message and metadata of the commit shown in the Git Log inspector. */ +export function GitCommitDetails({ + repoPath, + commit, +}: { + repoPath: string | null; + commit: GitCommit | null; +}) { + const { t } = useTranslation(); + const description = useCommitDescription(repoPath, commit); + + if (!commit) { + return ( +
+ {t("git.log.commitDetails")} +
+ ); + } + + return ( +
+
{commit.message}
+ {description ? ( +
{description}
+ ) : null} +
+ {commit.shortHash} · {commit.author} + {commit.email ? ` <${commit.email}>` : ""} +
+
{commit.date}
+ {commit.decorations ?
{commit.decorations}
: null} +
{commit.hash}
+
+ ); +} diff --git a/windows/tauri/src/features/git/components/log/git-commit-file-tree.test.ts b/windows/tauri/src/features/git/components/log/git-commit-file-tree.test.ts index 2a0d857ad..ddc935602 100644 --- a/windows/tauri/src/features/git/components/log/git-commit-file-tree.test.ts +++ b/windows/tauri/src/features/git/components/log/git-commit-file-tree.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { buildPathTree } from "@/features/sidebar/lib/path-tree"; import type { GitCommitFile } from "../../types/git.types"; -import { countCommitFileTreeLeaves } from "./git-commit-file-tree"; +import { countCommitFileTreeLeaves, getFirstCommitFilePath } from "./git-commit-file-tree"; describe("GitCommitFileTree", () => { test("counts every descendant file in a nested directory", () => { @@ -18,4 +18,33 @@ describe("GitCommitFileTree", () => { expect(root?.type).toBe("branch"); expect(root ? countCommitFileTreeLeaves(root) : 0).toBe(3); }); + + test("shows the file count beside folder names and no status letter on file rows", async () => { + const css = await Bun.file( + new URL("../../../file-explorer/styles/file-explorer-tree.css", import.meta.url), + ).text(); + const source = await Bun.file(new URL("./git-commit-file-tree.tsx", import.meta.url)).text(); + + // No CSS targets a Git-specific tree class, so the shared file-tree styling applies unchanged. + expect(source).not.toContain("git-commit-file-tree"); + // The count rides in the label's description slot so it sits right after the folder name. + expect(source).toContain("git.log.fileCountOne"); + expect(source).not.toContain("trailing="); + expect(source).not.toContain("{file.status}"); + // No trailing span remains, so the shared `:last-child` flex rule must not be overridden. + expect(css).not.toContain(".git-commit-file-tree .file-tree-row > span:last-child"); + }); + + test("picks the first file in the order the tree renders, not input order", () => { + const files: GitCommitFile[] = [ + { path: "windows/tauri/src/b.ts", status: "M" }, + { path: "docs/development/a.md", status: "M" }, + { path: "shared/c.json", status: "M" }, + ]; + + const first = getFirstCommitFilePath(files); + expect(first).not.toBeNull(); + expect(files.map((file) => file.path)).toContain(first as string); + expect(getFirstCommitFilePath([])).toBeNull(); + }); }); diff --git a/windows/tauri/src/features/git/components/log/git-commit-file-tree.tsx b/windows/tauri/src/features/git/components/log/git-commit-file-tree.tsx index f9f5bcc78..7f7cd5515 100644 --- a/windows/tauri/src/features/git/components/log/git-commit-file-tree.tsx +++ b/windows/tauri/src/features/git/components/log/git-commit-file-tree.tsx @@ -15,7 +15,6 @@ import { } from "@/features/sidebar/lib/path-tree"; import { useTranslation } from "@/i18n/locale-provider"; import { bindScrollContainerWheel } from "@/ui/scroll-container-wheel"; -import { cn } from "@/utils/cn"; import type { GitCommitFile } from "../../types/git.types"; import { getCommitFileStatusColorClassName } from "../../utils/git-file-status-visuals"; @@ -24,6 +23,25 @@ export function countCommitFileTreeLeaves(node: PathTreeNode): nu return node.children.reduce((count, child) => count + countCommitFileTreeLeaves(child), 0); } +/** Path of the first file in the order the tree renders, so previews match what the user sees. */ +export function getFirstCommitFilePath(files: readonly GitCommitFile[]): string | null { + const firstLeaf = (nodes: readonly PathTreeNode[]): string | null => { + for (const node of nodes) { + if (node.type === "leaf") return node.path; + const nested = firstLeaf(node.children); + if (nested) return nested; + } + return null; + }; + + return firstLeaf( + buildPathTree([...files], { + getPath: (file) => file.path, + getKey: (file) => file.path, + }), + ); +} + function FileNode({ node, depth, @@ -51,6 +69,7 @@ function FileNode({ : { branch: node, label: node.name }; const branch = compacted.branch; const expanded = !collapsed.has(branch.path); + const fileCount = countCommitFileTreeLeaves(branch); return (
@@ -73,9 +92,11 @@ function FileNode({ /> ) : null } - trailing={ - - {countCommitFileTreeLeaves(branch)} + description={ + + {t(fileCount === 1 ? "git.log.fileCountOne" : "git.log.filesCount", { + count: fileCount, + })} } title={branch.path} @@ -123,11 +144,6 @@ function FileNode({ /> ) : null } - trailing={ - - {file.status} - - } title={t("git.log.openFileDiff")} className="h-full py-0.5" style={{ height: presentation.rowHeight }} diff --git a/windows/tauri/src/features/git/components/log/git-commit-inspector.tsx b/windows/tauri/src/features/git/components/log/git-commit-inspector.tsx index ef8760b07..ee39475db 100644 --- a/windows/tauri/src/features/git/components/log/git-commit-inspector.tsx +++ b/windows/tauri/src/features/git/components/log/git-commit-inspector.tsx @@ -14,7 +14,8 @@ import { resolveGitCommitSelectionDiff, type GitCommitSelectionDiff, } from "../../utils/git-commit-selection-diff"; -import { GitCommitFileTree } from "./git-commit-file-tree"; +import { GitCommitDetails } from "./git-commit-details"; +import { GitCommitFileTree, getFirstCommitFilePath } from "./git-commit-file-tree"; type FilesLoadState = "idle" | "loading" | "ready" | "failed"; @@ -25,6 +26,8 @@ export function GitCommitInspector({ onOpenDiff, onOpenRangeDiff, onOpenSelectionDiff, + onPreviewFile, + previewRequest, }: { repoPath: string | null; commit: GitCommit | null; @@ -38,6 +41,10 @@ export function GitCommitInspector({ selection: Extract, filePath?: string, ) => void; + /** Shows only one file's diff in the editor preview tab. */ + onPreviewFile: (selection: GitCommitSelectionDiff, filePath: string) => void; + /** Bumped when the user selects a commit; the first file is previewed once its files load. */ + previewRequest: number; }) { const { t } = useTranslation(); const [files, setFiles] = useState([]); @@ -47,10 +54,28 @@ export function GitCommitInspector({ const inspectorPanelLayout = useGitLogPreferencesStore.use.inspectorPanelLayout(); const { setInspectorPanelLayout } = useGitLogPreferencesStore.use.actions(); const selectionDiff = useMemo(() => resolveGitCommitSelectionDiff(commits), [commits]); + // The selection `files` were loaded for; guards against previewing the previous commit's files + // during the render in which a new selection has arrived but its files have not loaded yet. + const [loadedSelection, setLoadedSelection] = useState(null); + const handledPreviewRequestRef = useRef(previewRequest); + + // Selecting a commit previews its first file, like the IDEA Git log. Only explicit user + // selections bump `previewRequest`, so opening the Log never steals the editor on its own. + useEffect(() => { + if (!selectionDiff || loadedSelection !== selectionDiff) return; + if (handledPreviewRequestRef.current === previewRequest) return; + handledPreviewRequestRef.current = previewRequest; + + const firstPath = getFirstCommitFilePath(files); + if (!firstPath) return; + setSelectedPath(firstPath); + onPreviewFile(selectionDiff, firstPath); + }, [files, loadedSelection, onPreviewFile, previewRequest, selectionDiff]); useEffect(() => { const requestId = ++requestIdRef.current; setFiles([]); + setLoadedSelection(null); setSelectedPath(null); if (!repoPath || !selectionDiff) { setLoadState("idle"); @@ -84,6 +109,7 @@ export function GitCommitInspector({ return; } setFiles(loadedFiles); + setLoadedSelection(selectionDiff); setLoadState("ready"); }); @@ -93,7 +119,7 @@ export function GitCommitInspector({ }, [repoPath, selectionDiff]); return ( -
+
-
+
{t("git.log.commitFiles")} {loadState === "loading" @@ -148,13 +174,12 @@ export function GitCommitInspector({ { + setSelectedPath(path); + if (selectionDiff) onPreviewFile(selectionDiff, path); + }} onOpen={(path) => { - if (selectionDiff?.kind === "commit") onOpenDiff(selectionDiff.commit, path); - else if (selectionDiff?.kind === "range") onOpenRangeDiff(selectionDiff, path); - else if (selectionDiff?.kind === "selection") { - onOpenSelectionDiff(selectionDiff, path); - } + if (selectionDiff) onPreviewFile(selectionDiff, path); }} /> )} @@ -163,31 +188,7 @@ export function GitCommitInspector({
- {commit ? ( -
-
{commit.message}
- {commit.description ? ( -
- {commit.description} -
- ) : null} -
- {commit.shortHash} · {commit.author} - {commit.email ? ` <${commit.email}>` : ""} -
-
{commit.date}
- {commit.decorations ? ( -
{commit.decorations}
- ) : null} -
- {commit.hash} -
-
- ) : ( -
- {t("git.log.commitDetails")} -
- )} +
diff --git a/windows/tauri/src/features/git/components/log/git-commit-table.tsx b/windows/tauri/src/features/git/components/log/git-commit-table.tsx index 656a53b00..d5d2c2161 100644 --- a/windows/tauri/src/features/git/components/log/git-commit-table.tsx +++ b/windows/tauri/src/features/git/components/log/git-commit-table.tsx @@ -27,6 +27,7 @@ import { import { Button } from "@/ui/button"; import { cn } from "@/utils/cn"; import { useTranslation } from "@/i18n/locale-provider"; +import { useGitLogColumnResize } from "../../hooks/use-git-log-column-resize"; import { type GitLogFilterScope, useGitLogPreferencesStore, @@ -39,6 +40,8 @@ import { selectedCommitsInHistoryOrder, } from "../../utils/git-history-selection"; import { GitGraphRow } from "./git-graph-row"; +import { GitLogColumnResizeHandle } from "./git-log-column-resize-handle"; +import { GitLogDateCell } from "./git-log-date-cell"; import { isGitHeadCommit } from "../../utils/git-history-message"; const ROW_HEIGHT = 30; @@ -121,6 +124,7 @@ export function GitCommitTable({ estimateSize: () => ROW_HEIGHT, overscan: 14, }); + const { columnStyle, activeColumn, startResize } = useGitLogColumnResize(scrollRef); useLayoutEffect(() => { const element = scrollRef.current; @@ -202,7 +206,7 @@ export function GitCommitTable({ return (
-
+
-
- {t("git.log.commit")} - {t("git.log.author")} - {t("git.log.date")} -
-
{visibleRows.length === 0 ? ( commits.length === 0 && emptyState ? ( @@ -278,7 +277,10 @@ export function GitCommitTable({ ) ) : ( <> -
+
{virtualizer.getVirtualItems().map((virtualRow) => { const row = visibleRows[virtualRow.index]; const isSelected = selectedCommitHashes.has(row.commit.hash); @@ -323,12 +325,19 @@ export function GitCommitTable({ title={t("git.log.openDiffHint")} > - - {row.commit.author} - - - {row.commit.date} - +
+ + {row.commit.author} + + +
+
+ + +
{hasMultipleContextCommits ? ( @@ -460,6 +469,8 @@ export function GitCommitTable({ )}
+ {/* Shields the rows from hover and the trailing click while a column is being dragged. */} + {activeColumn ?
: null}
); } diff --git a/windows/tauri/src/features/git/components/log/git-log-column-resize-handle.tsx b/windows/tauri/src/features/git/components/log/git-log-column-resize-handle.tsx new file mode 100644 index 000000000..acf831294 --- /dev/null +++ b/windows/tauri/src/features/git/components/log/git-log-column-resize-handle.tsx @@ -0,0 +1,33 @@ +import { useTranslation } from "@/i18n/locale-provider"; +import type { GitLogColumn } from "../../utils/git-log-columns"; + +/** + * Drag handle on the left edge of an author or date cell. Every row renders one, so the whole + * column edge is draggable. It reaches 4px outside the edge and 8px (the cell's text inset) into + * the cell, so the resize cursor appears right up to the text; it stays unstyled (no highlight) + * like the app's other splitters. + */ +export function GitLogColumnResizeHandle({ + column, + onStartResize, +}: { + column: GitLogColumn; + onStartResize: (column: GitLogColumn, event: React.PointerEvent) => void; +}) { + const { t } = useTranslation(); + + return ( +
onStartResize(column, event)} + // A drag ends with a click on a common ancestor; keep clicks off the row's own handlers. + onClick={(event) => event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + /> + ); +} diff --git a/windows/tauri/src/features/git/components/log/git-log-date-cell.test.tsx b/windows/tauri/src/features/git/components/log/git-log-date-cell.test.tsx new file mode 100644 index 000000000..1daa382e8 --- /dev/null +++ b/windows/tauri/src/features/git/components/log/git-log-date-cell.test.tsx @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, expect, setSystemTime, test } from "bun:test"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { LocaleProvider } from "@/i18n/locale-provider"; +import { installHappyDom } from "@/test-utils/happy-dom"; +import { GitLogDateCell } from "./git-log-date-cell"; + +// The commit is authored in the machine's own zone so relative labels apply. +const commitTime = new Date(2026, 8, 10, 15, 0); +const localOffset = -commitTime.getTimezoneOffset(); + +let restoreDom: () => void; +let container: HTMLElement; +let root: Root; +const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }; +let previousActEnvironment: boolean | undefined; + +beforeEach(async () => { + restoreDom = installHappyDom(); + previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT; + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + setSystemTime(new Date(2026, 8, 10, 15, 5)); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + await act(async () => { + root.render( + +
+ +
+
, + ); + }); +}); + +afterEach(async () => { + await act(async () => { + root.unmount(); + }); + setSystemTime(); + actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment; + restoreDom(); +}); + +test("relative date refreshes when the pointer enters the row, not on its own", async () => { + const row = container.querySelector('[data-testid="row"]') as HTMLElement; + expect(row.textContent).toBe("5 minutes ago"); + + setSystemTime(new Date(2026, 8, 10, 15, 20)); + expect(row.textContent).toBe("5 minutes ago"); + + await act(async () => { + row.dispatchEvent(new Event("mouseenter")); + }); + expect(row.textContent).toBe("20 minutes ago"); +}); diff --git a/windows/tauri/src/features/git/components/log/git-log-date-cell.tsx b/windows/tauri/src/features/git/components/log/git-log-date-cell.tsx new file mode 100644 index 000000000..c0bc63814 --- /dev/null +++ b/windows/tauri/src/features/git/components/log/git-log-date-cell.tsx @@ -0,0 +1,37 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "@/i18n/locale-provider"; +import { formatGitLogDate } from "../../utils/git-log-date"; + +/** + * Commit date cell of a Git log row. Relative labels ("5 minutes ago", "Today") are computed + * against the time the row was rendered and refreshed when the pointer enters the row, instead + * of re-rendering the whole table on a timer. + */ +export function GitLogDateCell({ + date, + utcOffsetMinutes, +}: { + date: string; + utcOffsetMinutes: number | undefined; +}) { + const { t } = useTranslation(); + const cellRef = useRef(null); + const [now, setNow] = useState(() => new Date()); + + useEffect(() => { + const row = cellRef.current?.closest("[data-git-commit-index]"); + if (!row) return; + const refresh = () => setNow(new Date()); + row.addEventListener("mouseenter", refresh); + return () => row.removeEventListener("mouseenter", refresh); + }, []); + + return ( + + {formatGitLogDate(date, t, utcOffsetMinutes, now)} + + ); +} diff --git a/windows/tauri/src/features/git/components/log/git-log-title-bar.tsx b/windows/tauri/src/features/git/components/log/git-log-title-bar.tsx index 879a60371..d95edc8c3 100644 --- a/windows/tauri/src/features/git/components/log/git-log-title-bar.tsx +++ b/windows/tauri/src/features/git/components/log/git-log-title-bar.tsx @@ -25,7 +25,7 @@ export function GitLogTitleBar({ const { t } = useTranslation(); return ( -
+
{t("workbench.gitLog")} diff --git a/windows/tauri/src/features/git/components/log/git-log-tool-window.tsx b/windows/tauri/src/features/git/components/log/git-log-tool-window.tsx index 5f50d16c1..030ff87e9 100644 --- a/windows/tauri/src/features/git/components/log/git-log-tool-window.tsx +++ b/windows/tauri/src/features/git/components/log/git-log-tool-window.tsx @@ -32,6 +32,7 @@ import { rebaseOntoBranch, type IntegrationOutcome, } from "../../api/git-integration-api"; +import { withCommitDescription } from "../../api/git-commits-api"; import { deleteRemoteBranch, fetchChanges } from "../../api/git-remotes-api"; import { normalizeRepositoryPath } from "../../api/git-repo-api"; import { showGitRebaseDialog } from "../../services/git-rebase-dialog-service"; @@ -93,6 +94,7 @@ export function GitLogToolWindow() { if (!rootFolderPath || availableRepoPaths.length > 0) return; void syncWorkspaceRepositories(rootFolderPath); }, [availableRepoPaths.length, rootFolderPath, syncWorkspaceRepositories]); + const isBottomPaneVisible = useUIState((state) => state.isBottomPaneVisible); const setIsBottomPaneVisible = useUIState((state) => state.setIsBottomPaneVisible); const openSettingsDialog = useUIState((state) => state.openSettingsDialog); const pendingReferenceSelectionRef = useRef(null); @@ -112,6 +114,7 @@ export function GitLogToolWindow() { const pullWorkflow = useGitPullWorkflow({ repoPath: repoPath ?? "", refresh }); const [selectedCommit, setSelectedCommit] = useState(null); const [selectedCommitHashes, setSelectedCommitHashes] = useState>(new Set()); + const [previewRequest, setPreviewRequest] = useState(0); const [isReferenceOperating, setIsReferenceOperating] = useState(false); const [showFetchOptions, setShowFetchOptions] = useState(false); const [showRemoteManager, setShowRemoteManager] = useState(false); @@ -199,12 +202,17 @@ export function GitLogToolWindow() { const { isLoadingCommitDiff, viewCommitDiff, + previewCommitFileDiff, viewCommitRangeDiff, viewCommitSelectionDiff, viewBranchDiff, viewReferenceWorkingTreeDiff, } = useGitDiffActions({ activeRepoPath: repoPath, + commitPreviewScope: + isBottomPaneVisible && panel === "log" && selectedCommits.length > 0 + ? JSON.stringify(selectedCommits.map((commit) => commit.hash)) + : null, gitFileByPath: emptyGitFileByPath, workingTreeDiffEntriesByScope: emptyWorkingTreeEntries, commitByHash, @@ -230,6 +238,7 @@ export function GitLogToolWindow() { ? commit.hash : visibleCommitHashes.find((hash) => result.selected.has(hash)); setSelectedCommit(activeHash ? (commitByHash.get(activeHash) ?? null) : null); + setPreviewRequest((request) => request + 1); }; const selectCommitForContextMenu = (commit: GitCommit) => { @@ -708,10 +717,15 @@ export function GitLogToolWindow() { onCopyHash={(commit) => void copyCommitText(commit.hash, t("git.log.commitHash"))} onCopyShortHash={(commit) => void copyCommitText(commit.shortHash, commit.shortHash)} onCopyMessage={(commit) => - void copyCommitText( - [commit.message, commit.description].filter(Boolean).join("\n\n"), - t("git.log.commitMessage"), - ) + void (async () => { + const detailed = repoPath + ? await withCommitDescription(repoPath, commit) + : commit; + await copyCommitText( + [detailed.message, detailed.description].filter(Boolean).join("\n\n"), + t("git.log.commitMessage"), + ); + })() } onEditMessage={(commit) => void editMessage(commit)} onUndo={(commit) => void undoCommit(commit)} @@ -735,6 +749,8 @@ export function GitLogToolWindow() { repoPath={repoPath} commit={activeSelectedCommit} commits={selectedCommits} + previewRequest={previewRequest} + onPreviewFile={previewCommitFileDiff} onOpenDiff={openDiff} onOpenRangeDiff={(range, filePath) => { if (isLoadingCommitDiff) return; diff --git a/windows/tauri/src/features/git/components/log/git-reference-tree.tsx b/windows/tauri/src/features/git/components/log/git-reference-tree.tsx index 86e496d84..60ce88db5 100644 --- a/windows/tauri/src/features/git/components/log/git-reference-tree.tsx +++ b/windows/tauri/src/features/git/components/log/git-reference-tree.tsx @@ -405,7 +405,7 @@ function GitReferenceToolbar({ return (
{primaryActions.map((action) => renderAction(action))}
@@ -419,7 +419,7 @@ function GitReferenceToolbar({ return (
{visibleActions.map((action) => renderAction(action))}
@@ -1022,7 +1022,7 @@ export function GitReferenceTree({ ); return ( -
+
- ) : ( - - - - - {t("git.workingTreeClean")} - - )} + ) : null} ({ + hash, + shortHash: hash, + parentHashes: [], + message: hash, + author: "Test", + date: "", + decorations: "", +}); +const diff = (additions: number, path = "a.ts"): GitDiff => ({ + file_path: path, + is_new: false, + is_deleted: false, + is_renamed: false, + lines: [], + additions, + deletions: 0, +}); +let preview: ReturnType; +let root: Root | undefined; +let container: HTMLElement; +let restoreDom: () => void; +let previousAct: boolean | undefined; +const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }; +let published: MultiFileDiff[]; +let releases: Array<() => void>; +let inFlight: Promise[]; +let loadCommit: ReturnType>; +let loadRange: ReturnType>; +const openPreview = (_path: string, _name: string, data: MultiFileDiff) => { + published.push(data); +}; + +function Harness({ scope = "A", repo = "C:/repo" }: { scope?: string | null; repo?: string }) { + preview = useCommitFilePreview(repo, scope, openPreview); + return null; +} +async function render(scope: string | null, repo = "C:/repo") { + await act(async () => { + root!.render(); + }); +} +function pendingDiff() { + let resolve!: (diffs: GitDiff[]) => void; + const promise = new Promise((done) => { + resolve = done; + }); + releases.push(() => resolve([])); + return { promise, resolve }; +} +function start(hash = "A") { + const task = preview({ kind: "commit", commit: commit(hash) }, "a.ts"); + inFlight.push(task); + return task; +} + +beforeEach(async () => { + restoreDom = installHappyDom(); + previousAct = actGlobal.IS_REACT_ACT_ENVIRONMENT; + actGlobal.IS_REACT_ACT_ENVIRONMENT = true; + published = []; + releases = []; + inFlight = []; + loadCommit = spyOn(api, "getCommitDiff").mockResolvedValue([diff(1)]); + loadRange = spyOn(api, "getRefDiff").mockResolvedValue([diff(2)]); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + await render("A"); +}); +afterEach(async () => { + try { + await act(async () => { + root?.unmount(); + }); + // Resolve every controlled request even when an assertion fails. + for (const release of releases) release(); + await Promise.all(inFlight); + } finally { + loadCommit.mockRestore(); + loadRange.mockRestore(); + container.remove(); + actGlobal.IS_REACT_ACT_ENVIRONMENT = previousAct; + restoreDom(); + } +}); + +for (const reason of [ + "deselected", + "hidden", + "new-selection", + "new-repository", + "unmounted", +] as const) { + test(`does not publish a pending preview after ${reason}`, async () => { + const pending = pendingDiff(); + loadCommit.mockReturnValue(pending.promise); + const task = start(); + if (reason === "unmounted") { + await act(async () => { + root!.unmount(); + }); + root = undefined; + } else if (reason === "new-repository") { + await render("A", "C:/other"); + } else { + await render(reason === "new-selection" ? "B" : null); + } + pending.resolve([diff(1)]); + await task; + expect(published).toEqual([]); + }); +} + +test("a later file click supersedes an earlier request and only opens once", async () => { + const first = pendingDiff(); + const second = pendingDiff(); + loadCommit.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); + const older = start("A"); + const newer = start("B"); + second.resolve([diff(2)]); + await newer; + first.resolve([diff(1)]); + await older; + expect(published.map((data) => data.totalAdditions)).toEqual([2]); +}); + +test("disconnected commits retain every matching revision and its label", async () => { + loadCommit + .mockResolvedValueOnce([diff(3), diff(20, "other.ts")]) + .mockResolvedValueOnce([diff(7)]); + await preview({ kind: "selection", commits: [commit("C"), commit("A")] }, "a.ts"); + expect(published).toHaveLength(1); + expect(published[0].files.map((entry) => entry.additions)).toEqual([3, 7]); + expect(published[0].fileLabels).toEqual(["C", "A"]); + expect(published[0].fileKeys).toEqual(["C:a.ts:0", "A:a.ts:0"]); + expect(published[0].totalFiles).toBe(1); + expect(published[0].hideFileList).toBe(true); +}); + +test("contiguous selections still use the range diff and missing files do not open a tab", async () => { + await preview( + { kind: "range", baseRef: "A", targetRef: "C", oldest: commit("B"), newest: commit("C") }, + "a.ts", + ); + expect(loadRange).toHaveBeenCalledWith("C:/repo", "A", "C"); + expect(published).toHaveLength(1); + expect(published[0].totalAdditions).toBe(2); + loadCommit.mockResolvedValue([]); + await start(); + expect(published).toHaveLength(1); +}); diff --git a/windows/tauri/src/features/git/hooks/use-commit-file-preview.ts b/windows/tauri/src/features/git/hooks/use-commit-file-preview.ts new file mode 100644 index 000000000..8e144eaca --- /dev/null +++ b/windows/tauri/src/features/git/hooks/use-commit-file-preview.ts @@ -0,0 +1,79 @@ +import { useCallback, useLayoutEffect, useRef } from "react"; +import { getCommitDiff, getRefDiff } from "../api/git-diff-api"; +import type { MultiFileDiff } from "../types/git-diff.types"; +import type { GitDiff } from "../types/git.types"; +import { mapGitReadsInBatches } from "../utils/git-async-batch"; +import { + aggregateSelectedCommitDiffs, + type GitCommitSelectionDiff, +} from "../utils/git-commit-selection-diff"; +import { createCommitFileDiffPreview } from "../utils/multi-file-diff"; +import { createRequestGeneration } from "../utils/request-generation"; + +/** Owns automatic preview publication for one visible Git Log selection. */ +export function useCommitFilePreview( + repoPath: string | null, + scopeKey: string | null, + openPreview: (path: string, name: string, data: MultiFileDiff) => unknown, +) { + const requests = useRef(createRequestGeneration()); + // Invalidate before passive effects can start a preview for the new selection. + // Cleanup also prevents a closed/unmounted Log from activating a global editor buffer. + useLayoutEffect(() => { + requests.current.begin(); + return () => { + requests.current.begin(); + }; + }, [repoPath, scopeKey]); + + return useCallback( + async (selection: GitCommitSelectionDiff, filePath: string) => { + if (!repoPath || scopeKey === null) return; + const requestId = requests.current.begin(); + try { + let diffs: readonly GitDiff[] | null; + let commitHash: string; + let label: string; + let fileKeys: string[] | undefined; + let fileLabels: string[] | undefined; + if (selection.kind === "commit") { + diffs = await getCommitDiff(repoPath, selection.commit.hash); + commitHash = selection.commit.hash; + label = selection.commit.shortHash; + } else if (selection.kind === "range") { + diffs = await getRefDiff(repoPath, selection.baseRef, selection.targetRef); + commitHash = `${selection.baseRef ?? "root"}..${selection.targetRef}`; + label = `${selection.oldest.shortHash}..${selection.newest.shortHash}`; + } else { + const results = await mapGitReadsInBatches(selection.commits, async (commit) => ({ + commit, + diffs: await getCommitDiff(repoPath, commit.hash), + })); + if (results.some((result) => result.diffs === null)) return; + const aggregate = aggregateSelectedCommitDiffs( + results.map((result) => ({ commit: result.commit, diffs: result.diffs ?? [] })), + ); + diffs = aggregate.diffs; + fileKeys = aggregate.fileKeys; + fileLabels = aggregate.fileLabels; + commitHash = selection.commits[0]?.hash ?? "selection"; + label = String(selection.commits.length); + } + if (!requests.current.isCurrent(requestId) || !diffs) return; + const preview = createCommitFileDiffPreview({ + repoPath, + commitHash, + diffs, + filePath, + fileKeys, + fileLabels, + label, + }); + if (preview) openPreview(preview.virtualPath, preview.displayName, preview.diffData); + } catch (error) { + console.error("Error previewing commit file diff:", error); + } + }, + [repoPath, scopeKey, openPreview], + ); +} diff --git a/windows/tauri/src/features/git/hooks/use-git-diff-actions.test.tsx b/windows/tauri/src/features/git/hooks/use-git-diff-actions.test.tsx new file mode 100644 index 000000000..e8419b43f --- /dev/null +++ b/windows/tauri/src/features/git/hooks/use-git-diff-actions.test.tsx @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { LocaleProvider } from "@/i18n/locale-provider"; +import { installHappyDom } from "@/test-utils/happy-dom"; +import * as dialogs from "@/ui/dialog"; +import * as paneSync from "@/features/editor/stores/buffer-pane-sync"; +import { useBufferStore } from "@/features/editor/stores/buffer.store"; +import * as commitsApi from "../api/git-commits-api"; +import * as diffApi from "../api/git-diff-api"; +import type { GitCommit, GitDiff } from "../types/git.types"; +import { useGitDiffActions } from "./use-git-diff-actions"; + +const commit = (hash: string): GitCommit => ({ + hash, + shortHash: hash, + parentHashes: [], + message: hash, + author: "Developer", + date: "2026/09/01", + decorations: "", +}); +const commits = new Map(["first", "second"].map((hash) => [hash, commit(hash)])); +const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }; +let originalAct: boolean | undefined; +let restoreDom: () => void; +let container: HTMLDivElement; +let root: Root; +let actions: ReturnType; +let openBuffer: ReturnType; +const spies: Array<{ mockRestore(): void }> = []; +const pending = new Map void>(); +const requests: Promise[] = []; + +function Harness({ repo }: { repo: string }) { + actions = useGitDiffActions({ + activeRepoPath: repo, + commitByHash: commits, + gitFileByPath: new Map(), + workingTreeDiffEntriesByScope: { all: [], staged: [], unstaged: [] }, + }); + return null; +} +async function render(repo = "C:/repo-a") { + await act(async () => { + root.render( + + + , + ); + }); +} +async function start(hash: string) { + await act(async () => { + requests.push(actions.viewCommitDiff(hash)); + await Promise.resolve(); + }); + expect(pending.has(hash)).toBe(true); +} +async function finish(hash: string) { + const release = pending.get(hash); + if (!release) throw new Error(`Missing pending message for ${hash}`); + await act(async () => { + release(); + await Promise.resolve(); + }); + pending.delete(hash); +} + +beforeEach(() => { + restoreDom = installHappyDom(); + originalAct = actGlobal.IS_REACT_ACT_ENVIRONMENT; + actGlobal.IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + openBuffer = spyOn(useBufferStore.getState().actions, "openBuffer").mockReturnValue( + "diff-buffer", + ); + spies.push( + openBuffer, + spyOn(dialogs, "showAlertDialog").mockResolvedValue(undefined), + spyOn(paneSync, "activateMainEditorPane").mockReturnValue(null), + spyOn(diffApi, "getCommitDiff").mockResolvedValue([ + { file_path: "file.txt", lines: [], is_new: false, is_deleted: false, is_renamed: false }, + ] satisfies GitDiff[]), + spyOn(commitsApi, "withCommitDescription").mockImplementation( + (_repo, selected) => + new Promise((resolve) => + pending.set(selected.hash, () => + resolve({ ...selected, description: `Body ${selected.hash}` }), + ), + ), + ), + ); +}); +afterEach(async () => { + try { + await act(async () => { + root.unmount(); + for (const release of pending.values()) release(); + await Promise.all(requests); + }); + } finally { + pending.clear(); + requests.length = 0; + for (const spy of spies.splice(0)) spy.mockRestore(); + container.remove(); + restoreDom(); + if (originalAct === undefined) delete actGlobal.IS_REACT_ACT_ENVIRONMENT; + else actGlobal.IS_REACT_ACT_ENVIRONMENT = originalAct; + } +}); + +test("opens the selected commit diff with its loaded message", async () => { + await render(); + await start("first"); + await finish("first"); + expect(openBuffer).toHaveBeenCalledTimes(1); + expect(openBuffer.mock.calls[0]?.[7]).toMatchObject({ + commitDescription: "Body first", + repoPath: "C:/repo-a", + }); + expect(actions.isLoadingCommitDiff).toBe(false); +}); +test("late message cannot open an old diff or clear the newer loading state", async () => { + await render(); + await start("first"); + await start("second"); + await finish("first"); + expect(openBuffer).not.toHaveBeenCalled(); + expect(actions.isLoadingCommitDiff).toBe(true); + await finish("second"); + expect(openBuffer).toHaveBeenCalledTimes(1); + expect(openBuffer.mock.calls[0]?.[7]).toMatchObject({ commitDescription: "Body second" }); +}); +test("switching repositories invalidates a pending message even when returning to the original", async () => { + await render(); + await start("first"); + await render("C:/repo-b"); + await render(); + await finish("first"); + expect(openBuffer).not.toHaveBeenCalled(); + expect(actions.isLoadingCommitDiff).toBe(false); +}); +test("unmount prevents a pending message from opening an editor", async () => { + await render(); + await start("first"); + await act(async () => { + root.render(null); + }); + await finish("first"); + expect(openBuffer).not.toHaveBeenCalled(); +}); diff --git a/windows/tauri/src/features/git/hooks/use-git-diff-actions.ts b/windows/tauri/src/features/git/hooks/use-git-diff-actions.ts index a043264bd..afbaea607 100644 --- a/windows/tauri/src/features/git/hooks/use-git-diff-actions.ts +++ b/windows/tauri/src/features/git/hooks/use-git-diff-actions.ts @@ -1,11 +1,12 @@ -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { activateMainEditorPane } from "@/features/editor/stores/buffer-pane-sync"; import { useBufferStore } from "@/features/editor/stores/buffer.store"; import { useTranslation } from "@/i18n/locale-provider"; import { showAlertDialog } from "@/ui/dialog"; +import { withCommitDescription } from "../api/git-commits-api"; import { getCommitDiff, - getFileDiff, + getFullContextFileDiff, getRefDiff, getStashDiff, getTypedReferenceDiff, @@ -30,6 +31,8 @@ import { createRequestGeneration, type RequestGeneration } from "../utils/reques import { createCommitDiffBuffer, createMultiFileDiff } from "../utils/multi-file-diff"; import { createSingleFileWorkingTreeDiff } from "../utils/working-tree-multi-diff"; +import { useCommitFilePreview } from "./use-commit-file-preview"; + const WORKING_TREE_TITLES: Record = { all: "git.diff.uncommitted", unstaged: "git.diff.unstagedChanges", @@ -73,6 +76,7 @@ export function useGitDiffActions({ currentBranch, currentReference, onBranchDiffOpened, + commitPreviewScope = null, }: { activeRepoPath: string | null; onFileSelect?: (path: string, isDir: boolean) => void; @@ -82,6 +86,7 @@ export function useGitDiffActions({ currentBranch?: string; currentReference?: GitReference; onBranchDiffOpened?: () => void; + commitPreviewScope?: string | null; }) { const { t } = useTranslation(); const [isLoadingCommitDiff, setIsLoadingCommitDiff] = useState(false); @@ -92,8 +97,21 @@ export function useGitDiffActions({ ? latestFileDiffRequestRef.current : createRequestGeneration(); latestFileDiffRequestRef.current = latestFileDiffRequest; + const commitDiffRequests = useRef(createRequestGeneration()).current; + useEffect(() => { + setIsLoadingCommitDiff(false); + // Invalidate on repository changes (including A -> B -> A) and unmount. + return () => { + commitDiffRequests.begin(); + }; + }, [activeRepoPath, commitDiffRequests]); const activeRepoPathRef = useRef(activeRepoPath); activeRepoPathRef.current = activeRepoPath; + const previewCommitFileDiff = useCommitFilePreview( + activeRepoPath, + commitPreviewScope, + openDiffBuffer, + ); const openOriginalFile = useCallback( async (filePath: string) => { @@ -142,6 +160,7 @@ export function useGitDiffActions({ ...(originalRelativePath ? { originalPath: originalRelativePath } : {}), untracked, ...(staged ? { staged: true } : {}), + ...(!staged && file.staged ? { hasStagedChanges: true } : {}), }, }; const loadingDiff: MultiFileDiff = { @@ -165,12 +184,13 @@ export function useGitDiffActions({ const bufferId = openDiffBuffer("diff://working-tree/all-files", title, loadingDiff); void (async () => { const diff = staged - ? await getFileDiff(fileRepoPath, relativePath, true) + ? await getFullContextFileDiff(fileRepoPath, relativePath, true) : await getWorkingTreePathDiff( fileRepoPath, relativePath, untracked, originalRelativePath, + true, ); if ( !latestFileDiffRequest.isCurrent(requestId) || @@ -196,7 +216,7 @@ export function useGitDiffActions({ return; } - const diff = await getFileDiff(activeRepoPath, actualFilePath, staged); + const diff = await getFullContextFileDiff(activeRepoPath, actualFilePath, staged); if ( !latestFileDiffRequest.isCurrent(requestId) || activeRepoPathRef.current !== activeRepoPath @@ -295,9 +315,13 @@ export function useGitDiffActions({ async (commitHash: string, filePath?: string) => { if (!activeRepoPath) return; + const request = commitDiffRequests.begin(); + const isCurrent = () => + commitDiffRequests.isCurrent(request) && activeRepoPathRef.current === activeRepoPath; setIsLoadingCommitDiff(true); try { const diffs = await getCommitDiff(activeRepoPath, commitHash); + if (!isCurrent()) return; if (!diffs?.length) { await showAlertDialog( filePath @@ -308,7 +332,11 @@ export function useGitDiffActions({ return; } - const commit = commitByHash.get(commitHash); + const listedCommit = commitByHash.get(commitHash); + const commit = listedCommit + ? await withCommitDescription(activeRepoPath, listedCommit) + : undefined; + if (!isCurrent()) return; const buffer = createCommitDiffBuffer({ repoPath: activeRepoPath, commitHash, @@ -318,6 +346,7 @@ export function useGitDiffActions({ }); openDiffBuffer(buffer.virtualPath, buffer.displayName, buffer.diffData); } catch (error) { + if (!isCurrent()) return; console.error("Error getting commit diff:", error); await showAlertDialog( t("git.diff.getCommitDiffFailed", { @@ -327,10 +356,10 @@ export function useGitDiffActions({ t("git.diff.title"), ); } finally { - setIsLoadingCommitDiff(false); + if (isCurrent()) setIsLoadingCommitDiff(false); } }, - [activeRepoPath, commitByHash, t], + [activeRepoPath, commitByHash, commitDiffRequests, t], ); const viewCommitRangeDiff = useCallback( @@ -624,6 +653,7 @@ export function useGitDiffActions({ viewFileDiff, viewWorkingTreeDiff, viewCommitDiff, + previewCommitFileDiff, viewCommitRangeDiff, viewCommitSelectionDiff, viewStashDiff, diff --git a/windows/tauri/src/features/git/hooks/use-git-gutter-base.ts b/windows/tauri/src/features/git/hooks/use-git-gutter-base.ts new file mode 100644 index 000000000..deda99bea --- /dev/null +++ b/windows/tauri/src/features/git/hooks/use-git-gutter-base.ts @@ -0,0 +1,91 @@ +import { useEffect, useRef, useState } from "react"; +import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; +import { getFullContextFileDiff } from "../api/git-diff-api"; +import { resolveRepositoryForFile } from "../api/git-repo-api"; +import { isGitChangeRelevant, subscribeToGitChanges } from "../events/git-events"; +import { useGitStore } from "../stores/git.store"; +import { + gitGutterBaseFromDiff, + isGitGutterFileUntracked, + splitGitGutterLines, + type GitGutterBaseSource, +} from "../utils/git-gutter-changes"; + +/** The Git index version of one editor file, which gutter markers compare against. */ +export interface GitGutterBase { + repoPath: string; + /** Repository-relative path with `/` separators, as hunk patches require. */ + filePath: string; + lines: string[]; +} + +/** + * Resolves the comparison base for a file. A null result means the file has + * no markers: it is untracked, outside a repository, binary, or the read failed. + */ +export async function loadGitGutterBase( + rootPath: string, + filePath: string, + savedContent: () => string, +): Promise { + const resolved = await resolveRepositoryForFile(rootPath, filePath); + if (!resolved) return null; + const status = useGitStore.getState().workspaceGitStatus; + if (isGitGutterFileUntracked(status?.files ?? [], resolved.repoPath, resolved.filePath)) { + return null; + } + + const source: GitGutterBaseSource | null = gitGutterBaseFromDiff( + await getFullContextFileDiff(resolved.repoPath, resolved.filePath, false), + ); + if (!source) return null; + return { + ...resolved, + // No index-to-worktree difference: the saved file is the index version. + lines: source.kind === "lines" ? source.lines : splitGitGutterLines(savedContent()), + }; +} + +/** + * Loads the gutter base for the active editor and reloads it on Git changes + * (save, stage, external edits). Results for a previous file or an older + * request never replace the current one. + */ +export function useGitGutterBase( + filePath: string | undefined, + savedContent: () => string, + savedRevision: unknown, +): GitGutterBase | null { + const rootFolderPath = useFileSystemStore((state) => state.rootFolderPath); + const [state, setState] = useState<{ key: string; base: GitGutterBase | null } | null>(null); + const [refresh, setRefresh] = useState(0); + const key = filePath && rootFolderPath ? JSON.stringify([rootFolderPath, filePath]) : null; + // Read at request time; `savedRevision` is the dependency that says it changed. + const savedContentRef = useRef(savedContent); + savedContentRef.current = savedContent; + + useEffect(() => { + if (!filePath || !rootFolderPath) return; + return subscribeToGitChanges((change) => { + if (isGitChangeRelevant(change, rootFolderPath, filePath)) setRefresh((value) => value + 1); + }); + }, [filePath, rootFolderPath]); + + useEffect(() => { + if (!key || !filePath || !rootFolderPath) return; + let cancelled = false; + void loadGitGutterBase(rootFolderPath, filePath, () => savedContentRef.current()) + .catch((error) => { + console.error("Failed to load Git gutter base:", error); + return null; + }) + .then((base) => { + if (!cancelled) setState({ key, base }); + }); + return () => { + cancelled = true; + }; + }, [key, filePath, rootFolderPath, refresh, savedRevision]); + + return state && state.key === key ? state.base : null; +} diff --git a/windows/tauri/src/features/git/hooks/use-git-log-column-resize.test.tsx b/windows/tauri/src/features/git/hooks/use-git-log-column-resize.test.tsx new file mode 100644 index 000000000..16a2167d2 --- /dev/null +++ b/windows/tauri/src/features/git/hooks/use-git-log-column-resize.test.tsx @@ -0,0 +1,155 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { act, useRef } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { GitLogColumnResizeHandle } from "@/features/git/components/log/git-log-column-resize-handle"; +import { LocaleProvider } from "@/i18n/locale-provider"; +import { installHappyDom } from "@/test-utils/happy-dom"; +import { useGitLogPreferencesStore } from "../stores/git-log-preferences.store"; +import { GIT_LOG_COLUMN_DEFAULT_WIDTHS } from "../utils/git-log-columns"; +import { useGitLogColumnResize } from "./use-git-log-column-resize"; + +let rowClicks = 0; +// Manual animation-frame queue so live widths are applied deterministically, without real timers. +let pendingFrames = new Map(); +let nextFrameHandle = 1; +const frameScheduler = { + scheduleFrame: (callback: FrameRequestCallback) => { + const handle = nextFrameHandle++; + pendingFrames.set(handle, callback); + return handle; + }, + cancelFrame: (handle: number) => { + pendingFrames.delete(handle); + }, +}; + +// Mirrors the table: one scroll container whose CSS variables drive every row's column widths. +function Harness() { + const scrollRef = useRef(null); + const { columnStyle, activeColumn, startResize } = useGitLogColumnResize( + scrollRef, + frameScheduler, + ); + + return ( + +
+
(rowClicks += 1)}> +
+ +
+
+ +
+
+
+ {activeColumn ?? "none"} +
+ ); +} + +function pointerEvent(type: string, init: { clientX: number; button?: number }) { + const event = new Event(type, { bubbles: true, cancelable: true }); + return Object.assign(event, { button: 0, ...init }); +} + +const flushFrames = () => { + const frames = [...pendingFrames.values()]; + pendingFrames = new Map(); + for (const frame of frames) frame(0); +}; + +let restoreDom: () => void; +let container: HTMLElement; +let root: Root; +const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }; +let previousActEnvironment: boolean | undefined; + +beforeEach(async () => { + restoreDom = installHappyDom(); + previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT; + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + rowClicks = 0; + pendingFrames = new Map(); + useGitLogPreferencesStore.setState({ + authorColumnWidth: GIT_LOG_COLUMN_DEFAULT_WIDTHS.author, + dateColumnWidth: GIT_LOG_COLUMN_DEFAULT_WIDTHS.date, + }); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + await act(async () => { + root.render(); + }); +}); + +afterEach(async () => { + await act(async () => { + root.unmount(); + }); + actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment; + restoreDom(); +}); + +const query = (testId: string) => + container.querySelector(`[data-testid="${testId}"]`) as HTMLElement; +const handle = (cellTestId: string) => + query(cellTestId).querySelector('[role="separator"]') as HTMLElement; + +test("dragging a column's left edge leftwards widens it live and persists once on release", async () => { + const scroll = query("scroll"); + expect(scroll.style.getPropertyValue("--git-log-author-width")).toBe("112px"); + + await act(async () => { + handle("author-cell").dispatchEvent(pointerEvent("pointerdown", { clientX: 300 })); + }); + expect(query("active").textContent).toBe("author"); + + await act(async () => { + document.dispatchEvent(pointerEvent("pointermove", { clientX: 260 })); + flushFrames(); + }); + // The live width lands on the DOM, but nothing is written to the store mid-drag. + expect(scroll.style.getPropertyValue("--git-log-author-width")).toBe("152px"); + expect(useGitLogPreferencesStore.getState().authorColumnWidth).toBe(112); + + await act(async () => { + document.dispatchEvent(pointerEvent("pointerup", { clientX: 260 })); + }); + expect(useGitLogPreferencesStore.getState().authorColumnWidth).toBe(152); + expect(useGitLogPreferencesStore.getState().dateColumnWidth).toBe(144); + expect(scroll.style.getPropertyValue("--git-log-author-width")).toBe("152px"); + expect(query("active").textContent).toBe("none"); +}); + +test("the date column resizes independently and respects its bounds", async () => { + const scroll = query("scroll"); + + await act(async () => { + handle("date-cell").dispatchEvent(pointerEvent("pointerdown", { clientX: 500 })); + }); + await act(async () => { + document.dispatchEvent(pointerEvent("pointermove", { clientX: -5000 })); + flushFrames(); + }); + expect(scroll.style.getPropertyValue("--git-log-date-width")).toBe("280px"); + + await act(async () => { + document.dispatchEvent(pointerEvent("pointerup", { clientX: -5000 })); + }); + expect(useGitLogPreferencesStore.getState().dateColumnWidth).toBe(280); + expect(useGitLogPreferencesStore.getState().authorColumnWidth).toBe(112); +}); + +test("pressing the handle neither selects the row nor starts a drag with the right button", async () => { + await act(async () => { + handle("author-cell").dispatchEvent(pointerEvent("pointerdown", { clientX: 10, button: 2 })); + }); + expect(query("active").textContent).toBe("none"); + + await act(async () => { + handle("author-cell").dispatchEvent(new Event("click", { bubbles: true })); + handle("author-cell").dispatchEvent(new Event("dblclick", { bubbles: true })); + }); + expect(rowClicks).toBe(0); +}); diff --git a/windows/tauri/src/features/git/hooks/use-git-log-column-resize.ts b/windows/tauri/src/features/git/hooks/use-git-log-column-resize.ts new file mode 100644 index 000000000..9c8f4cf65 --- /dev/null +++ b/windows/tauri/src/features/git/hooks/use-git-log-column-resize.ts @@ -0,0 +1,84 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; +import { + startDocumentResizeSession, + type DocumentResizeSession, + type DocumentResizeSessionOptions, +} from "@/utils/document-resize-session"; +import { useGitLogPreferencesStore } from "../stores/git-log-preferences.store"; +import { + GIT_LOG_COLUMN_CSS_VARIABLES, + GIT_LOG_MESSAGE_MIN_WIDTH, + clampGitLogColumnWidth, + type GitLogColumn, +} from "../utils/git-log-columns"; + +/** + * Owns the commit table's resizable author and date columns. + * + * Widths reach every row through CSS variables on the scroll container. While dragging, the + * variable is written straight to the DOM (once per frame) so the virtualized rows are not + * re-rendered; the final width is persisted once when the drag ends. + * + * `frameScheduler` replaces `requestAnimationFrame` so tests can flush frames deterministically. + */ +export function useGitLogColumnResize( + scrollRef: React.RefObject, + frameScheduler?: Pick, +) { + const authorWidth = useGitLogPreferencesStore.use.authorColumnWidth(); + const dateWidth = useGitLogPreferencesStore.use.dateColumnWidth(); + const { setColumnWidth } = useGitLogPreferencesStore.use.actions(); + const sessionRef = useRef(null); + const [activeColumn, setActiveColumn] = useState(null); + + const startResize = useCallback( + (column: GitLogColumn, event: React.PointerEvent) => { + // Only the primary button resizes; the secondary button must still open the row menu. + if (event.button !== 0) return; + event.preventDefault(); + // Keep the row from treating the press as a selection or the start of a double-click open. + event.stopPropagation(); + sessionRef.current?.dispose({ commit: true }); + + const variable = GIT_LOG_COLUMN_CSS_VARIABLES[column]; + sessionRef.current = startDocumentResizeSession({ + startX: event.clientX, + startWidth: column === "author" ? authorWidth : dateWidth, + // Both columns hug the right edge, so dragging their left edge leftwards widens them. + direction: -1, + cursor: "ew-resize", + clampWidth: (value) => clampGitLogColumnWidth(column, value), + applyWidth: (width) => scrollRef.current?.style.setProperty(variable, `${width}px`), + commitWidth: (width) => { + setColumnWidth(column, width); + sessionRef.current = null; + }, + onActiveChange: (active) => setActiveColumn(active ? column : null), + ...frameScheduler, + }); + }, + [authorWidth, dateWidth, frameScheduler, scrollRef, setColumnWidth], + ); + + // Persist an in-flight drag if the table unmounts mid-gesture, and drop listeners and styles. + useEffect( + () => () => { + sessionRef.current?.dispose({ commit: true }); + sessionRef.current = null; + }, + [], + ); + + const columnStyle = useMemo( + () => + ({ + [GIT_LOG_COLUMN_CSS_VARIABLES.author]: `${authorWidth}px`, + [GIT_LOG_COLUMN_CSS_VARIABLES.date]: `${dateWidth}px`, + // Rows never get narrower than their columns plus the message area; overflow scrolls. + "--git-log-row-min-width": `calc(var(${GIT_LOG_COLUMN_CSS_VARIABLES.author}) + var(${GIT_LOG_COLUMN_CSS_VARIABLES.date}) + ${GIT_LOG_MESSAGE_MIN_WIDTH}px)`, + }) as CSSProperties, + [authorWidth, dateWidth], + ); + + return { columnStyle, activeColumn, startResize }; +} diff --git a/windows/tauri/src/features/git/services/git-gutter-actions.ts b/windows/tauri/src/features/git/services/git-gutter-actions.ts new file mode 100644 index 000000000..58787ddea --- /dev/null +++ b/windows/tauri/src/features/git/services/git-gutter-actions.ts @@ -0,0 +1,44 @@ +import { activateMainEditorPane } from "@/features/editor/stores/buffer-pane-sync"; +import { useBufferStore } from "@/features/editor/stores/buffer.store"; +import { getWorkingTreePathDiff } from "../api/git-diff-api"; +import { stageHunk } from "../api/git-status-api"; +import type { GitGutterBase } from "../hooks/use-git-gutter-base"; +import { gitGutterChangeHunk, type GitGutterChange } from "../utils/git-gutter-changes"; +import { createSingleFileWorkingTreeDiff } from "../utils/working-tree-multi-diff"; + +/** + * Stages one gutter change through the existing hunk API. The patch is built + * against the index text the change was computed from, so Git rejects it + * instead of staging the wrong lines if the index moved in the meantime. + */ +export function stageGitGutterChange( + base: GitGutterBase, + baseLines: readonly string[], + change: GitGutterChange, +): Promise { + return stageHunk(base.repoPath, gitGutterChangeHunk(base.filePath, baseLines, change)); +} + +/** + * Opens the same single-file working-tree Diff tab as the Changes panel + * (HEAD to working tree, full context, per-hunk staging). + */ +export async function openGitGutterFullDiff( + base: GitGutterBase, + title: string, +): Promise { + const diff = await getWorkingTreePathDiff(base.repoPath, base.filePath, false, undefined, true); + if (!diff || (diff.lines.length === 0 && !diff.is_image && !diff.is_binary)) return null; + const fileKey = `unstaged:${base.filePath}`; + const multiDiff = createSingleFileWorkingTreeDiff({ + repoPath: base.repoPath, + fileKey, + diff, + title, + target: { repoPath: base.repoPath, filePath: base.filePath, untracked: false }, + }); + activateMainEditorPane(); + return useBufferStore + .getState() + .actions.openBuffer("diff://working-tree/all-files", title, "", false, undefined, true, true, multiDiff); +} diff --git a/windows/tauri/src/features/git/services/working-tree-diff-refresh.test.ts b/windows/tauri/src/features/git/services/working-tree-diff-refresh.test.ts index 56af5ef96..029a612d6 100644 --- a/windows/tauri/src/features/git/services/working-tree-diff-refresh.test.ts +++ b/windows/tauri/src/features/git/services/working-tree-diff-refresh.test.ts @@ -196,6 +196,34 @@ describe("refreshWorkingTreeFileDiff", () => { expect(diffLoads).toBe(0); }); + test("closes after the last block is rolled back and the file leaves Git status", async () => { + // #688: rolling back the only remaining block makes the file clean. The + // review must close rather than keep a patch whose blocks no longer exist. + const buffers = bufferPort(openedDiff()); + const outcome = await refreshWorkingTreeFileDiff( + { bufferId: BUFFER_ID, fileKey: FILE_KEY }, + { buffers: buffers.port, loadStatus: async () => status([]), loadDiff: async () => diffWithLines(0) }, + ); + expect(outcome).toBe("closed"); + expect(buffers.closed).toEqual([BUFFER_ID]); + }); + + test("records staged edits so an unstaged snapshot stops offering rollback", async () => { + // Staging one block of a worktree-only file turns it into MM; the + // HEAD-to-worktree snapshot then also shows the staged block. + const buffers = bufferPort(openedDiff()); + const outcome = await refreshWorkingTreeFileDiff( + { bufferId: BUFFER_ID, fileKey: FILE_KEY }, + { + buffers: buffers.port, + loadStatus: async () => status([statusFile({ staged: true })]), + loadDiff: async () => diffWithLines(2), + }, + ); + expect(outcome).toBe("updated"); + expect(buffers.current()?.workingTreeTargets?.[FILE_KEY]).toEqual({ ...TARGET, hasStagedChanges: true }); + }); + test("keeps current content when status or diff reads fail", async () => { const opened = openedDiff(); const buffers = bufferPort(opened); diff --git a/windows/tauri/src/features/git/services/working-tree-diff-refresh.ts b/windows/tauri/src/features/git/services/working-tree-diff-refresh.ts index 438caf999..fcdaf7929 100644 --- a/windows/tauri/src/features/git/services/working-tree-diff-refresh.ts +++ b/windows/tauri/src/features/git/services/working-tree-diff-refresh.ts @@ -1,6 +1,6 @@ import equal from "fast-deep-equal"; import { normalizePath } from "@/utils/path-helpers"; -import { getFileDiff, getWorkingTreePathDiff } from "../api/git-diff-api"; +import { getFullContextFileDiff, getWorkingTreePathDiff } from "../api/git-diff-api"; import { getGitStatus } from "../api/git-status-api"; import type { MultiFileDiff, WorkingTreeDiffTarget } from "../types/git-diff.types"; import type { GitDiff, GitFile, GitStatus } from "../types/git.types"; @@ -65,8 +65,8 @@ export async function refreshWorkingTreeFileDiff( loadStatus = getGitStatus, loadDiff = (root, path, untracked, originalPath, staged) => staged - ? getFileDiff(root, path, true) - : getWorkingTreePathDiff(root, path, untracked, originalPath), + ? getFullContextFileDiff(root, path, true) + : getWorkingTreePathDiff(root, path, untracked, originalPath, true), }: WorkingTreeDiffRefreshDependencies, ): Promise { const startingDiff = buffers.read(bufferId); @@ -93,6 +93,9 @@ export async function refreshWorkingTreeFileDiff( ...(originalPath ? { originalPath } : {}), untracked: statusFile.status === "untracked", ...(target.staged ? { staged: true } : {}), + // Staging a block from an unstaged review turns the file into MM; the + // review keeps its key, so discard eligibility must follow live status. + ...(!target.staged && statusFile.staged ? { hasStagedChanges: true } : {}), }; const diff = await loadDiff( nextTarget.repoPath, diff --git a/windows/tauri/src/features/git/stores/git-log-preferences.store.ts b/windows/tauri/src/features/git/stores/git-log-preferences.store.ts index cc909a107..68729879b 100644 --- a/windows/tauri/src/features/git/stores/git-log-preferences.store.ts +++ b/windows/tauri/src/features/git/stores/git-log-preferences.store.ts @@ -4,6 +4,12 @@ import { createSelectors } from "@/utils/zustand-selectors"; import { createSafeJSONStorage } from "@/utils/zustand-storage"; import { normalizeRepositoryPath } from "../api/git-repo-api"; import type { GitReferenceKind } from "../types/git.types"; +import { + GIT_LOG_COLUMN_DEFAULT_WIDTHS, + clampGitLogColumnWidth, + normalizeGitLogColumnWidth, + type GitLogColumn, +} from "../utils/git-log-columns"; export type GitLogFilterScope = "text" | "author" | "branch"; @@ -19,6 +25,8 @@ interface GitLogPreferencesStore { showWorktreeRepositories: boolean; mainPanelLayout: GitLogPanelLayout; inspectorPanelLayout: GitLogPanelLayout; + authorColumnWidth: number; + dateColumnWidth: number; collapsedReferenceSections: GitReferenceKind[]; collapsedReferenceGroups: string[]; markedReferenceFullNamesByRepository: Record; @@ -30,6 +38,8 @@ interface GitLogPreferencesStore { setShowWorktreeRepositories: (show: boolean) => void; setMainPanelLayout: (layout: GitLogPanelLayout) => void; setInspectorPanelLayout: (layout: GitLogPanelLayout) => void; + /** Persists a commit-table column width; call once when a drag ends, not per pointer move. */ + setColumnWidth: (column: GitLogColumn, width: number) => void; toggleReferenceSection: (kind: GitReferenceKind) => void; toggleReferenceGroup: (id: string) => void; setReferenceExpansion: (sections: GitReferenceKind[], groups: string[]) => void; @@ -67,6 +77,8 @@ const useGitLogPreferencesStoreBase = create()( showWorktreeRepositories: true, mainPanelLayout: DEFAULT_MAIN_LAYOUT, inspectorPanelLayout: DEFAULT_INSPECTOR_LAYOUT, + authorColumnWidth: GIT_LOG_COLUMN_DEFAULT_WIDTHS.author, + dateColumnWidth: GIT_LOG_COLUMN_DEFAULT_WIDTHS.date, collapsedReferenceSections: [], collapsedReferenceGroups: [], markedReferenceFullNamesByRepository: {}, @@ -79,6 +91,12 @@ const useGitLogPreferencesStoreBase = create()( set({ showWorktreeRepositories }), setMainPanelLayout: (mainPanelLayout) => set({ mainPanelLayout }), setInspectorPanelLayout: (inspectorPanelLayout) => set({ inspectorPanelLayout }), + setColumnWidth: (column, width) => + set( + column === "author" + ? { authorColumnWidth: clampGitLogColumnWidth(column, width) } + : { dateColumnWidth: clampGitLogColumnWidth(column, width) }, + ), toggleReferenceSection: (kind) => set((state) => ({ collapsedReferenceSections: toggleListItem(state.collapsedReferenceSections, kind), @@ -148,6 +166,11 @@ const useGitLogPreferencesStoreBase = create()( markedReferenceFullNamesByRepository: persistedPreferences.markedReferenceFullNamesByRepository ?? {}, showWorktreeRepositories: persistedPreferences.showWorktreeRepositories ?? true, + authorColumnWidth: normalizeGitLogColumnWidth( + "author", + persistedPreferences.authorColumnWidth, + ), + dateColumnWidth: normalizeGitLogColumnWidth("date", persistedPreferences.dateColumnWidth), actions: currentState.actions, }; }, diff --git a/windows/tauri/src/features/git/types/git-diff.types.ts b/windows/tauri/src/features/git/types/git-diff.types.ts index 0912597ae..8451665c5 100644 --- a/windows/tauri/src/features/git/types/git-diff.types.ts +++ b/windows/tauri/src/features/git/types/git-diff.types.ts @@ -111,6 +111,12 @@ export interface WorkingTreeDiffTarget { /** Repository-relative source path for renames. */ originalPath?: string; untracked: boolean; + /** + * The index differs from HEAD for this path. Unstaged reviews are + * HEAD-to-worktree snapshots, so their blocks then include staged edits and + * must not be reverse-applied to the worktree. + */ + hasStagedChanges?: boolean; } export interface MultiFileDiff { @@ -129,6 +135,8 @@ export interface MultiFileDiff { fileLabels?: string[]; initiallyExpandedFileKey?: string; initiallySelectedFileKey?: string; + /** Hides the changed-files navigator, e.g. for a single-file commit preview. */ + hideFileList?: boolean; /** Working-tree refresh identities keyed by file key. */ workingTreeTargets?: Record; isLoading?: boolean; diff --git a/windows/tauri/src/features/git/types/git.types.ts b/windows/tauri/src/features/git/types/git.types.ts index d173d81cc..2ef12a13a 100644 --- a/windows/tauri/src/features/git/types/git.types.ts +++ b/windows/tauri/src/features/git/types/git.types.ts @@ -33,6 +33,8 @@ export interface GitCommit { author: string; email?: string; date: string; + /** Author's UTC offset in minutes (east positive) for `date`, when Core reports one. */ + dateUtcOffsetMinutes?: number; decorations: string; } @@ -134,6 +136,11 @@ export interface GitDiff { additions?: number; deletions?: number; is_truncated?: boolean; + /** + * Every line of the file is present as patch context, so a review can fold + * and reveal unchanged regions locally instead of showing sparse hunks. + */ + is_full_context?: boolean; split_hunks?: GitDiffSplitRow[][]; } diff --git a/windows/tauri/src/features/git/utils/git-gutter-changes.test.ts b/windows/tauri/src/features/git/utils/git-gutter-changes.test.ts new file mode 100644 index 000000000..1c612f3a3 --- /dev/null +++ b/windows/tauri/src/features/git/utils/git-gutter-changes.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, test } from "bun:test"; +import { monacoLineDiff } from "@/features/editor/engines/monaco/monaco-line-diff"; +import type { GitDiff, GitDiffLine } from "../types/git.types"; +import { + adjacentGitGutterChangeIndex, + isGitGutterFileUntracked, + type GitGutterChange, + computeGitGutterChanges, + gitGutterBaseFromDiff, + gitGutterChangeHunk, + gitGutterChangeIndexAtLine, + gitGutterMarkerLines, + gitGutterPeekAnchorLine, + gitGutterPeekRows, + splitGitGutterLines, +} from "./git-gutter-changes"; + +const changesOf = (base: string[], editor: string[]) => + computeGitGutterChanges(base, editor, monacoLineDiff) ?? []; + +const fullContextDiff = (lines: GitDiffLine[], header: string): GitDiff => ({ + file_path: "src/app.ts", + is_new: false, + is_deleted: false, + is_renamed: false, + is_full_context: true, + lines: [{ line_type: "header", content: header }, ...lines], +}); + +describe("Git gutter change locations", () => { + test("classifies added, modified and deleted lines against the base", () => { + const changes = changesOf( + ["a", "b", "c", "d", "e"], + ["a", "B", "c", "e", "f"], + ); + + expect(changes.map((change) => change.kind)).toEqual(["modified", "deleted", "added"]); + expect(changes[0]).toMatchObject({ originalLines: ["b"], modifiedLines: ["B"], modifiedStart: 2 }); + expect(changes[1]).toMatchObject({ originalLines: ["d"], modifiedLines: [], modifiedStart: 4 }); + expect(changes[2]).toMatchObject({ originalLines: [], modifiedLines: ["f"], modifiedStart: 5 }); + }); + + test("marks a deletion on the following line, or the last line at end of file", () => { + const [middle] = changesOf(["a", "b", "c"], ["a", "c"]); + const [end] = changesOf(["a", "b", "c"], ["a", "b"]); + + expect(gitGutterMarkerLines(middle, 2)).toEqual({ start: 2, end: 2 }); + expect(gitGutterMarkerLines(end, 2)).toEqual({ start: 2, end: 2 }); + expect(gitGutterPeekAnchorLine(middle)).toBe(1); + expect(gitGutterPeekAnchorLine(end)).toBe(2); + }); + + test("resolves a clicked line to the change that owns it", () => { + const changes = changesOf(["a", "b", "c", "d"], ["a", "x", "y", "d", "z"]); + expect(gitGutterChangeIndexAtLine(changes, 3, 5)).toBe(0); + expect(gitGutterChangeIndexAtLine(changes, 5, 5)).toBe(1); + expect(gitGutterChangeIndexAtLine(changes, 4, 5)).toBe(-1); + }); + + test("selects a deletion through the line that follows it", () => { + // "b" is deleted, so its marker sits on "c" (line 2); "x" is a separate addition. + const changes = changesOf(["a", "b", "c"], ["a", "c", "x"]); + expect(changes.map((change) => change.kind)).toEqual(["deleted", "added"]); + expect(gitGutterChangeIndexAtLine(changes, 2, 3)).toBe(0); + expect(gitGutterChangeIndexAtLine(changes, 3, 3)).toBe(1); + }); + + test("a modification wins over a deletion marked on the same line", () => { + const changes: GitGutterChange[] = [ + { kind: "deleted", originalStart: 2, originalEnd: 3, modifiedStart: 2, modifiedEnd: 2, + originalLines: ["b"], modifiedLines: [] }, + { kind: "modified", originalStart: 3, originalEnd: 4, modifiedStart: 2, modifiedEnd: 3, + originalLines: ["c"], modifiedLines: ["C"] }, + ]; + expect(gitGutterChangeIndexAtLine([...changes], 2, 3)).toBe(1); + }); + + test("wraps previous and next navigation around the change list", () => { + expect(adjacentGitGutterChangeIndex(3, 2, "next")).toBe(0); + expect(adjacentGitGutterChangeIndex(3, 0, "previous")).toBe(2); + expect(adjacentGitGutterChangeIndex(3, 1, "next")).toBe(2); + expect(adjacentGitGutterChangeIndex(0, 0, "next")).toBe(-1); + }); + + test("compares an empty base or editor without calling the line diff", () => { + expect(changesOf([], ["a", "b"])).toEqual([ + expect.objectContaining({ kind: "added", modifiedStart: 1, modifiedEnd: 3 }), + ]); + expect(changesOf(["a"], [])).toEqual([ + expect.objectContaining({ kind: "deleted", originalStart: 1, originalEnd: 2 }), + ]); + expect(changesOf([], [])).toEqual([]); + }); + + test("shows no markers when the comparison exceeds its budget", () => { + expect(computeGitGutterChanges(["a"], ["b"], () => null)).toBeNull(); + }); +}); + +describe("Git gutter comparison base", () => { + test("rebuilds the index text from a full-context patch", () => { + const diff = fullContextDiff( + [ + { line_type: "context", content: "a", old_line_number: 1, new_line_number: 1 }, + { line_type: "removed", content: "b", old_line_number: 2 }, + { line_type: "added", content: "B", new_line_number: 2 }, + { line_type: "context", content: "c", old_line_number: 3, new_line_number: 3 }, + ], + "@@ -1,3 +1,3 @@", + ); + expect(gitGutterBaseFromDiff(diff)).toEqual({ kind: "lines", lines: ["a", "b", "c"] }); + }); + + test("treats an empty patch as unchanged and rejects partial or binary patches", () => { + expect(gitGutterBaseFromDiff({ ...fullContextDiff([], "@@ -1 +1 @@"), lines: [] })).toEqual({ + kind: "unchanged", + }); + const sparse = { ...fullContextDiff([], "@@ -10,2 +10,2 @@"), is_full_context: false }; + expect(gitGutterBaseFromDiff(sparse)).toBeNull(); + expect(gitGutterBaseFromDiff({ ...fullContextDiff([], "@@ -1 +1 @@"), is_binary: true })).toBeNull(); + expect(gitGutterBaseFromDiff(null)).toBeNull(); + }); + + test("rejects a patch whose old side does not cover the whole index file", () => { + const diff = fullContextDiff( + [{ line_type: "context", content: "a", old_line_number: 1, new_line_number: 1 }], + "@@ -1,2 +1,2 @@", + ); + expect(gitGutterBaseFromDiff(diff)).toBeNull(); + }); + + test("splits text like an editor, ignoring one trailing line break", () => { + expect(splitGitGutterLines("a\r\nb\n")).toEqual(["a", "b"]); + expect(splitGitGutterLines("a\rb")).toEqual(["a", "b"]); + expect(splitGitGutterLines("")).toEqual([]); + }); +}); + +describe("Git gutter inline review rows", () => { + test("shows the change with real line numbers and context up to the neighbours", () => { + const base = ["a", "b", "c", "d", "e", "f"]; + const editor = ["a", "X", "c", "d", "e", "F"]; + const changes = changesOf(base, editor); + const rows = gitGutterPeekRows(changes, 0, editor); + + expect(rows.map((row) => [row.kind, row.oldLine, row.newLine, row.left ?? row.right])).toEqual([ + ["context", 1, 1, "a"], + ["removal", 2, null, "b"], + ["addition", null, 2, "X"], + ["context", 3, 3, "c"], + ["context", 4, 4, "d"], + ]); + // The next change starts at editor line 6; its context stops before it. + const next = gitGutterPeekRows(changes, 1, editor); + expect(next.map((row) => row.newLine ?? row.oldLine)).toEqual([4, 5, 6, 6]); + }); + + test("keeps old line numbers aligned after an earlier insertion", () => { + const base = ["a", "b", "c"]; + const editor = ["new", "a", "b", "C"]; + const changes = changesOf(base, editor); + const rows = gitGutterPeekRows(changes, 1, editor); + expect(rows.find((row) => row.kind === "context" && row.newLine === 3)?.oldLine).toBe(2); + }); +}); + +describe("Git gutter staging patch", () => { + test("builds a hunk whose context comes from the index text", () => { + const base = ["1", "2", "3", "4", "5", "6", "7", "8"]; + const [change] = changesOf(base, ["1", "2", "3", "4", "FIVE", "6", "7", "8"]); + const hunk = gitGutterChangeHunk("src/app.ts", base, change); + + expect(hunk.file_path).toBe("src/app.ts"); + expect(hunk.lines.map((line) => `${line.line_type}:${line.content}`)).toEqual([ + "header:@@ -2,7 +2,7 @@", + "context:2", + "context:3", + "context:4", + "removed:5", + "added:FIVE", + "context:6", + "context:7", + "context:8", + ]); + }); + + test("uses index context even when a neighbouring unstaged change is nearby", () => { + // Line 2 and line 4 both changed; staging the second must not stage "B". + const base = ["a", "b", "c", "d", "e"]; + const editor = ["a", "B", "c", "D", "e"]; + const changes = changesOf(base, editor); + const hunk = gitGutterChangeHunk("f.txt", base, changes[1]); + + expect(hunk.lines.map((line) => `${line.line_type}:${line.content}`)).toEqual([ + "header:@@ -1,5 +1,5 @@", + "context:a", + "context:b", + "context:c", + "removed:d", + "added:D", + "context:e", + ]); + }); + + test("names Git's empty ranges for an insertion at the start and a full deletion", () => { + const [insertion] = changesOf([], ["x"]); + expect(gitGutterChangeHunk("f", [], insertion).lines[0].content).toBe("@@ -0,0 +1,1 @@"); + + const [deletion] = changesOf(["x"], []); + expect(gitGutterChangeHunk("f", ["x"], deletion).lines[0].content).toBe("@@ -1,1 +0,0 @@"); + }); +}); + + +describe("Git gutter repository identity", () => { + test("an untracked sibling path does not hide the tracked file's markers", () => { + const files = [{ + path: "a/src/app.ts", repositoryPath: "C:/workspace/a", + repositoryRelativePath: "src/app.ts", status: "untracked" as const, staged: false, + }]; + expect(isGitGutterFileUntracked(files, "C:/workspace/b", "src/app.ts")).toBe(false); + expect(isGitGutterFileUntracked(files, "C:/workspace/a/", "src/app.ts")).toBe(true); + expect(isGitGutterFileUntracked(files, "C:/workspace/a", "src/other.ts")).toBe(false); + }); + + test("single-repository status retains undecorated path support", () => { + expect(isGitGutterFileUntracked([ + { path: "src/app.ts", status: "untracked", staged: false }, + ], "C:/workspace/a", "src/app.ts")).toBe(true); + }); +}); + +describe("Git gutter isolated patch coordinates", () => { + test("earlier unstaged deletions do not make the selected patch start negative", () => { + const base = Array.from({ length: 10 }, (_, index) => String(index + 1)); + // Exercise an isolated range directly: Monaco may coalesce nearby edits, + // but patch coordinates must remain valid for any selected range. + const change: GitGutterChange = { + kind: "modified", originalStart: 10, originalEnd: 11, + modifiedStart: 2, modifiedEnd: 3, originalLines: ["10"], modifiedLines: ["TEN"], + }; + const hunk = gitGutterChangeHunk("f", base, change); + expect(hunk.lines[0].content).toBe("@@ -7,4 +7,4 @@"); + expect(hunk.lines.slice(1).map((line) => line.content)).toEqual(["7", "8", "9", "10", "TEN"]); + }); + + test("earlier unstaged insertions do not shift the selected patch", () => { + const base = ["a", "b", "c", "d", "e", "f"]; + const changes = changesOf(base, ["new", ...base.slice(0, -1), "F"]); + expect(changes).toHaveLength(2); + expect(gitGutterChangeHunk("f", base, changes[1]).lines[0].content).toBe("@@ -3,4 +3,4 @@"); + }); +}); diff --git a/windows/tauri/src/features/git/utils/git-gutter-changes.ts b/windows/tauri/src/features/git/utils/git-gutter-changes.ts new file mode 100644 index 000000000..b04a3aac8 --- /dev/null +++ b/windows/tauri/src/features/git/utils/git-gutter-changes.ts @@ -0,0 +1,288 @@ +import type { ReviewRow } from "@lithe/editor/diff-review"; +import type { GitDiff, GitDiffLine, GitFile, GitHunk } from "../types/git.types"; +import { normalizeRepositoryPath } from "../api/git-repository-path"; +import { parseDiffHunkRange } from "./git-diff-helpers"; + +/** Aggregated workspace status must match both repository and relative path. */ +export function isGitGutterFileUntracked( + files: readonly GitFile[], + repoPath: string, + filePath: string, +): boolean { + return files.some((file) => + file.status === "untracked" && + (!file.repositoryPath || + normalizeRepositoryPath(file.repositoryPath) === normalizeRepositoryPath(repoPath)) && + (file.repositoryRelativePath ?? file.path) === filePath, + ); +} + +export type GitGutterChangeKind = "added" | "modified" | "deleted"; + +/** One-based, end-exclusive line ranges, as Monaco's line diff reports them. */ +export interface GitGutterLineRange { + originalStart: number; + originalEnd: number; + modifiedStart: number; + modifiedEnd: number; +} + +/** One contiguous change between the Git base text and the editor text. */ +export interface GitGutterChange extends GitGutterLineRange { + kind: GitGutterChangeKind; + originalLines: string[]; + modifiedLines: string[]; +} + +/** Returns null when the comparison could not finish within its budget. */ +export type GitGutterLineDiff = ( + original: readonly string[], + modified: readonly string[], +) => GitGutterLineRange[] | null; + +/** + * Where the editor's comparison base comes from. `unchanged` means Git found + * no difference between the index and the file on disk, so the saved disk + * text is the base. + */ +export type GitGutterBaseSource = { kind: "lines"; lines: string[] } | { kind: "unchanged" }; + +/** Context kept around a staged gutter change. Matches Git's default `--unified=3`. */ +const GITGUTTER_HUNK_CONTEXT_LINES = 3; + +/** + * Splits text the way an editor counts lines. A trailing line break does not + * start another line, so `"a\n"` and `"a"` both compare as `["a"]`; the Git + * diff parser drops end-of-file newline markers in the same way. + */ +export function splitGitGutterLines(text: string): string[] { + if (text === "") return []; + const lines = text.split(/\r\n|\r|\n/); + if (lines[lines.length - 1] === "") lines.pop(); + return lines; +} + +/** Drops the empty line Monaco reports after a final line break. */ +export function trimFinalEmptyLine(lines: readonly string[]): string[] { + return lines.length > 0 && lines[lines.length - 1] === "" ? lines.slice(0, -1) : [...lines]; +} + +/** + * Rebuilds the index text from a full-context index-to-worktree patch. The + * old side of such a patch is the complete index version: every context and + * removed line, in order. Sparse, truncated or binary patches cannot supply + * that text and return null instead of a partial base. + */ +export function gitGutterBaseFromDiff(diff: GitDiff | null): GitGutterBaseSource | null { + // A null diff means the read failed or the file is outside a repository; + // an empty patch (no lines) is how Git reports an unchanged file. + if (!diff || diff.is_binary || diff.is_image || diff.is_truncated) return null; + const headers = diff.lines.filter((line) => line.line_type === "header"); + if (headers.length === 0) return diff.lines.length === 0 ? { kind: "unchanged" } : null; + if (headers.length > 1 || !diff.is_full_context || diff.lines[0]?.line_type !== "header") { + return null; + } + const range = parseDiffHunkRange(headers[0].content); + if (!range) return null; + const lines = diff.lines + .filter((line) => line.line_type === "context" || line.line_type === "removed") + .map((line) => line.content); + const expectedStart = range.oldCount === 0 ? 0 : 1; + if (range.oldStart !== expectedStart || lines.length !== range.oldCount) return null; + return { kind: "lines", lines }; +} + +export function computeGitGutterChanges( + original: readonly string[], + modified: readonly string[], + lineDiff: GitGutterLineDiff, +): GitGutterChange[] | null { + const ranges = lineDiff(original, modified); + if (!ranges) return null; + return ranges.map((range) => ({ + ...range, + kind: + range.originalStart === range.originalEnd + ? "added" + : range.modifiedStart === range.modifiedEnd + ? "deleted" + : "modified", + originalLines: original.slice(range.originalStart - 1, range.originalEnd - 1), + modifiedLines: modified.slice(range.modifiedStart - 1, range.modifiedEnd - 1), + })); +} + +/** + * Editor lines that carry a change's gutter marker. Deleted lines have no + * editor line of their own, so they mark the line that now follows them, or + * the last line when the deletion is at the end of the file (as on macOS). + */ +export function gitGutterMarkerLines( + change: GitGutterChange, + lineCount: number, +): { start: number; end: number } { + const lastLine = Math.max(1, lineCount); + if (change.kind === "deleted") { + const line = Math.min(Math.max(1, change.modifiedStart), lastLine); + return { start: line, end: line }; + } + return { + start: Math.min(change.modifiedStart, lastLine), + end: Math.min(change.modifiedEnd - 1, lastLine), + }; +} + +/** The editor line below which a change's inline review opens. Zero means above line 1. */ +export function gitGutterPeekAnchorLine(change: GitGutterChange): number { + return change.kind === "deleted" ? change.modifiedStart - 1 : change.modifiedEnd - 1; +} + +export function gitGutterChangeIndexAtLine( + changes: readonly GitGutterChange[], + line: number, + lineCount: number, +): number { + // A deletion can share its marker line with the start of the next change; + // the change that owns real editor lines wins that click. + let deletion = -1; + for (let index = 0; index < changes.length; index++) { + const { start, end } = gitGutterMarkerLines(changes[index], lineCount); + if (line < start || line > end) continue; + if (changes[index].kind !== "deleted") return index; + if (deletion < 0) deletion = index; + } + return deletion; +} + +/** Previous and next wrap around, as VS Code's and IDEA's change navigation do. */ +export function adjacentGitGutterChangeIndex( + total: number, + index: number, + direction: "previous" | "next", +): number { + if (total <= 0) return -1; + return (index + (direction === "next" ? 1 : total - 1)) % total; +} + +/** Unchanged lines shown above and below a change in its inline review. */ +const GIT_GUTTER_PEEK_CONTEXT_LINES = 2; + +/** + * Rows for one change's read-only inline review: the change plus a little + * unchanged context, with real line numbers on both sides. Context stops at + * the neighbouring changes so it never shows another change as unchanged. + */ +export function gitGutterPeekRows( + changes: readonly GitGutterChange[], + index: number, + editorLines: readonly string[], + contextLines = GIT_GUTTER_PEEK_CONTEXT_LINES, +): ReviewRow[] { + const change = changes[index]; + if (!change) return []; + const previousEnd = index > 0 ? changes[index - 1].modifiedEnd : 1; + const nextStart = + index + 1 < changes.length ? changes[index + 1].modifiedStart : editorLines.length + 1; + const context = (line: number, offset: number): ReviewRow => { + const text = editorLines[line - 1] ?? ""; + return { + id: `context-${line}`, + oldLine: line + offset, + newLine: line, + left: text, + right: text, + kind: "context", + hunkID: null, + }; + }; + + const rows: ReviewRow[] = []; + const offsetBefore = change.originalStart - change.modifiedStart; + for ( + let line = Math.max(previousEnd, change.modifiedStart - contextLines); + line < change.modifiedStart; + line++ + ) { + rows.push(context(line, offsetBefore)); + } + change.originalLines.forEach((text, offset) => + rows.push({ + id: `removed-${offset}`, + oldLine: change.originalStart + offset, + newLine: null, + left: text, + right: null, + kind: "removal", + hunkID: null, + }), + ); + change.modifiedLines.forEach((text, offset) => + rows.push({ + id: `added-${offset}`, + oldLine: null, + newLine: change.modifiedStart + offset, + left: null, + right: text, + kind: "addition", + hunkID: null, + }), + ); + const offsetAfter = change.originalEnd - change.modifiedEnd; + const contextEnd = Math.min(nextStart, change.modifiedEnd + contextLines); + for (let line = change.modifiedEnd; line < contextEnd; line++) { + rows.push(context(line, offsetAfter)); + } + return rows; +} + +/** Identity of a change's content, used to keep a review open across refreshes. */ +export function gitGutterChangeKey(change: GitGutterChange): string { + return JSON.stringify([ + change.originalStart, + change.originalEnd, + change.modifiedStart, + change.modifiedEnd, + change.originalLines, + change.modifiedLines, + ]); +} + +/** + * Builds a patch that stages exactly one change against the index. Context + * comes from the base (index) text, never from the editor, so a neighbouring + * unstaged change inside the context window stays out of the index. + */ +export function gitGutterChangeHunk( + filePath: string, + base: readonly string[], + change: GitGutterChange, +): GitHunk { + const context = GITGUTTER_HUNK_CONTEXT_LINES; + const removedStart = change.originalStart - 1; + const removedEnd = change.originalEnd - 1; + const contextStart = Math.max(0, removedStart - context); + const contextEnd = Math.min(base.length, removedEnd + context); + const leading = removedStart - contextStart; + const trailing = contextEnd - removedEnd; + const contextLine = (content: string): GitDiffLine => ({ line_type: "context", content }); + + const oldCount = contextEnd - contextStart; + const newCount = leading + change.modifiedLines.length + trailing; + // Git names the line before an empty range, or 0 at the start of the file. + const oldStart = oldCount === 0 ? contextStart : contextStart + 1; + // This patch applies only this change to the index. Earlier unstaged + // insertions/deletions must not shift its new-side coordinates. + const firstNewLine = contextStart + 1; + const newStart = newCount === 0 ? firstNewLine - 1 : firstNewLine; + + return { + file_path: filePath, + lines: [ + { line_type: "header", content: `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@` }, + ...base.slice(contextStart, removedStart).map(contextLine), + ...change.originalLines.map((content): GitDiffLine => ({ line_type: "removed", content })), + ...change.modifiedLines.map((content): GitDiffLine => ({ line_type: "added", content })), + ...base.slice(removedEnd, contextEnd).map(contextLine), + ], + }; +} diff --git a/windows/tauri/src/features/git/utils/git-log-columns.test.ts b/windows/tauri/src/features/git/utils/git-log-columns.test.ts new file mode 100644 index 000000000..4f60e3008 --- /dev/null +++ b/windows/tauri/src/features/git/utils/git-log-columns.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test"; +import { + GIT_LOG_COLUMN_DEFAULT_WIDTHS, + clampGitLogColumnWidth, + normalizeGitLogColumnWidth, +} from "./git-log-columns"; + +describe("git log column widths", () => { + test("clamps drag results to a readable range per column", () => { + expect(clampGitLogColumnWidth("author", 0)).toBe(56); + expect(clampGitLogColumnWidth("author", 9999)).toBe(360); + expect(clampGitLogColumnWidth("date", 0)).toBe(72); + expect(clampGitLogColumnWidth("date", 9999)).toBe(280); + expect(clampGitLogColumnWidth("author", 140.4)).toBe(140); + }); + + test("restores defaults for missing or corrupt persisted values", () => { + expect(normalizeGitLogColumnWidth("author", undefined)).toBe( + GIT_LOG_COLUMN_DEFAULT_WIDTHS.author, + ); + expect(normalizeGitLogColumnWidth("date", "wide")).toBe(GIT_LOG_COLUMN_DEFAULT_WIDTHS.date); + expect(normalizeGitLogColumnWidth("date", Number.NaN)).toBe(GIT_LOG_COLUMN_DEFAULT_WIDTHS.date); + expect(normalizeGitLogColumnWidth("author", 5000)).toBe(360); + }); +}); diff --git a/windows/tauri/src/features/git/utils/git-log-columns.ts b/windows/tauri/src/features/git/utils/git-log-columns.ts new file mode 100644 index 000000000..950f426f7 --- /dev/null +++ b/windows/tauri/src/features/git/utils/git-log-columns.ts @@ -0,0 +1,41 @@ +export type GitLogColumn = "author" | "date"; + +export interface GitLogColumnWidths { + author: number; + date: number; +} + +export const GIT_LOG_COLUMN_DEFAULT_WIDTHS: GitLogColumnWidths = { author: 112, date: 144 }; + +// Bounds keep a column readable and stop a runaway drag from pushing the commit message out. +const GIT_LOG_COLUMN_MIN_WIDTHS: GitLogColumnWidths = { author: 56, date: 72 }; +const GIT_LOG_COLUMN_MAX_WIDTHS: GitLogColumnWidths = { author: 360, date: 280 }; + +/** + * Width reserved for the graph and commit message, on top of both columns and the row padding. + * It equals the previous fixed 520px row minimum minus the default author and date widths. + */ +export const GIT_LOG_MESSAGE_MIN_WIDTH = 280; + +/** CSS variables carrying the live column widths; set on the table's scroll container. */ +export const GIT_LOG_COLUMN_CSS_VARIABLES: Record = { + author: "--git-log-author-width", + date: "--git-log-date-width", +}; + +export function clampGitLogColumnWidth(column: GitLogColumn, width: number): number { + if (!Number.isFinite(width)) return GIT_LOG_COLUMN_DEFAULT_WIDTHS[column]; + return Math.round( + Math.min( + Math.max(width, GIT_LOG_COLUMN_MIN_WIDTHS[column]), + GIT_LOG_COLUMN_MAX_WIDTHS[column], + ), + ); +} + +/** Restores a persisted width, falling back to the default for missing or corrupt values. */ +export function normalizeGitLogColumnWidth(column: GitLogColumn, width: unknown): number { + return typeof width === "number" + ? clampGitLogColumnWidth(column, width) + : GIT_LOG_COLUMN_DEFAULT_WIDTHS[column]; +} diff --git a/windows/tauri/src/features/git/utils/git-log-date.test.ts b/windows/tauri/src/features/git/utils/git-log-date.test.ts new file mode 100644 index 000000000..b021e4727 --- /dev/null +++ b/windows/tauri/src/features/git/utils/git-log-date.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from "bun:test"; +import { formatGitLogDate } from "./git-log-date"; + +// Echoes the key and values so assertions stay independent of locale copy. +const t = (key: string, values?: Record) => + values ? `${key}:${JSON.stringify(values)}` : key; + +const am = (time: string) => `git.log.timeAm:${JSON.stringify({ time })}`; +const pm = (time: string) => `git.log.timePm:${JSON.stringify({ time })}`; + +// Local time on purpose: Git's displayed date is interpreted in the machine's zone, and the +// offset passed alongside it is the local one so relative labels apply. +const now = new Date(2026, 8, 10, 15, 30); +const local = -now.getTimezoneOffset(); + +describe("formatGitLogDate", () => { + test("shows minutes ago for commits within the last hour", () => { + expect(formatGitLogDate("2026/09/10 15:30", t, local, now)).toBe("git.log.dateJustNow"); + expect(formatGitLogDate("2026/09/10 15:29", t, local, now)).toBe( + 'git.log.dateMinuteAgoOne:{"count":1}', + ); + expect(formatGitLogDate("2026/09/10 15:05", t, local, now)).toBe( + 'git.log.dateMinutesAgo:{"count":25}', + ); + expect(formatGitLogDate("2026/09/10 14:31", t, local, now)).toBe( + 'git.log.dateMinutesAgo:{"count":59}', + ); + }); + + test("shows Today and Yesterday with a localized 12-hour time once past an hour", () => { + expect(formatGitLogDate("2026/09/10 14:30", t, local, now)).toBe( + `git.log.dateToday:${JSON.stringify({ time: pm("2:30") })}`, + ); + expect(formatGitLogDate("2026/09/10 00:05", t, local, now)).toBe( + `git.log.dateToday:${JSON.stringify({ time: am("12:05") })}`, + ); + expect(formatGitLogDate("2026/09/09 23:59", t, local, now)).toBe( + `git.log.dateYesterday:${JSON.stringify({ time: pm("11:59") })}`, + ); + expect(formatGitLogDate("2026/09/09 12:00", t, local, now)).toBe( + `git.log.dateYesterday:${JSON.stringify({ time: pm("12:00") })}`, + ); + }); + + test("shows the calendar date with a 12-hour time for older commits", () => { + expect(formatGitLogDate("2026/09/08 09:07", t, local, now)).toBe( + `git.log.dateAbsolute:${JSON.stringify({ year: "2026", month: "09", day: "08", time: am("9:07") })}`, + ); + expect(formatGitLogDate("2025/12/31 21:45", t, local, now)).toBe( + `git.log.dateAbsolute:${JSON.stringify({ year: "2025", month: "12", day: "31", time: pm("9:45") })}`, + ); + }); + + test("measures Yesterday by calendar day, not by 24 elapsed hours", () => { + const justAfterMidnight = new Date(2026, 8, 10, 0, 10); + expect(formatGitLogDate("2026/09/09 23:50", t, local, justAfterMidnight)).toBe( + 'git.log.dateMinutesAgo:{"count":20}', + ); + expect(formatGitLogDate("2026/09/09 22:00", t, local, justAfterMidnight)).toBe( + `git.log.dateYesterday:${JSON.stringify({ time: pm("10:00") })}`, + ); + }); + + // Regression: a commit authored in another zone carries that zone's wall-clock time, so + // comparing it with local "now" would produce wrong "minutes ago" and "Today" labels. + test("shows only the calendar date when the author's zone differs or is unknown", () => { + const absoluteToday = `git.log.dateAbsolute:${JSON.stringify({ year: "2026", month: "09", day: "10", time: pm("3:25") })}`; + expect(formatGitLogDate("2026/09/10 15:25", t, local + 60, now)).toBe(absoluteToday); + expect(formatGitLogDate("2026/09/10 15:25", t, null, now)).toBe(absoluteToday); + expect(formatGitLogDate("2026/09/10 15:25", t, undefined, now)).toBe(absoluteToday); + }); + + test("falls back to absolute formats for future dates and passes bad input through", () => { + expect(formatGitLogDate("2026/09/10 16:00", t, local, now)).toBe( + `git.log.dateToday:${JSON.stringify({ time: pm("4:00") })}`, + ); + expect(formatGitLogDate("not a date", t, local, now)).toBe("not a date"); + }); +}); diff --git a/windows/tauri/src/features/git/utils/git-log-date.ts b/windows/tauri/src/features/git/utils/git-log-date.ts new file mode 100644 index 000000000..dfd011dea --- /dev/null +++ b/windows/tauri/src/features/git/utils/git-log-date.ts @@ -0,0 +1,65 @@ +type Translate = (key: string, values?: Record) => string; + +// Matches the displayed part of Core's `--date=format:%Y/%m/%d %H:%M %z` commit dates; Core +// moves the `%z` offset into `dateUtcOffsetMinutes`. +const GIT_LOG_DATE_PATTERN = /^(\d{4})\/(\d{2})\/(\d{2}) (\d{2}):(\d{2})$/; +const MS_PER_MINUTE = 60_000; +const MINUTES_PER_HOUR = 60; + +function formatTwelveHourTime(hours: number, minutes: number, t: Translate): string { + const displayHour = hours % 12 === 0 ? 12 : hours % 12; + const time = `${displayHour}:${String(minutes).padStart(2, "0")}`; + // The period word and its position are locale copy: "2:30 PM" in English, "下午 2:30" in Chinese. + return t(hours < 12 ? "git.log.timeAm" : "git.log.timePm", { time }); +} + +function startOfDay(date: Date): number { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); +} + +/** + * Formats a Git log commit date the way IDEA's commit history does: "N minutes ago" within the + * last hour, "Today"/"Yesterday" with a 12-hour time, and the calendar date with a 12-hour time + * otherwise. Unparseable input is returned unchanged so unusual backend values stay visible. + * + * Git reports the date in the author's time zone. Relative labels are only meaningful when that + * zone matches the local one, so a missing or different `utcOffsetMinutes` always produces the + * calendar date. + */ +export function formatGitLogDate( + dateString: string, + t: Translate, + utcOffsetMinutes: number | null | undefined, + now: Date = new Date(), +): string { + const match = GIT_LOG_DATE_PATTERN.exec(dateString); + if (!match) return dateString; + + const [year, month, day, hours, minutes] = match.slice(1).map(Number); + const date = new Date(year, month - 1, day, hours, minutes); + if (Number.isNaN(date.getTime())) return dateString; + + const time = formatTwelveHourTime(hours, minutes, t); + const absolute = () => + // Field order is locale copy: English reads day/month/year, Chinese year/month/day. + t("git.log.dateAbsolute", { year: match[1], month: match[2], day: match[3], time }); + + // `getTimezoneOffset` is minutes west of UTC; Core reports minutes east. + const isLocalTimeZone = utcOffsetMinutes != null && utcOffsetMinutes === -date.getTimezoneOffset(); + if (!isLocalTimeZone) return absolute(); + + const elapsedMinutes = Math.floor((now.getTime() - date.getTime()) / MS_PER_MINUTE); + // Negative values come from clock skew; fall through to the absolute formats instead of + // showing a nonsensical "-3 minutes ago". + if (elapsedMinutes >= 0 && elapsedMinutes < MINUTES_PER_HOUR) { + if (elapsedMinutes < 1) return t("git.log.dateJustNow"); + return t(elapsedMinutes === 1 ? "git.log.dateMinuteAgoOne" : "git.log.dateMinutesAgo", { + count: elapsedMinutes, + }); + } + + const dayDifference = Math.round((startOfDay(now) - startOfDay(date)) / (24 * 60 * MS_PER_MINUTE)); + if (dayDifference === 0) return t("git.log.dateToday", { time }); + if (dayDifference === 1) return t("git.log.dateYesterday", { time }); + return absolute(); +} diff --git a/windows/tauri/src/features/git/utils/monaco-diff-hunk-actions.test.ts b/windows/tauri/src/features/git/utils/monaco-diff-hunk-actions.test.ts index 1ddf4ef85..958bcec40 100644 --- a/windows/tauri/src/features/git/utils/monaco-diff-hunk-actions.test.ts +++ b/windows/tauri/src/features/git/utils/monaco-diff-hunk-actions.test.ts @@ -23,6 +23,14 @@ function operations() { unstage: mock(async (_repo: string, _hunk: GitHunk) => true), }; } +const discardContext = { ...context, canDiscard: true }; +function rollbackOperations() { + return { + ...operations(), + discard: mock(async (_repo: string, _hunk: GitHunk) => true), + confirmDiscard: mock(async (_hunk: GitHunk) => true), + }; +} function deferred() { let resolve!: (value: boolean) => void; const promise = new Promise(accept => { resolve = accept; }); @@ -115,6 +123,123 @@ describe("Windows Monaco diff hunk actions", () => { } finally { pending.resolve(false); current.dispose(); } }); + test("offers rollback only for refreshable unstaged reviews of worktree-only edits", () => { + const target = { repoPath: context.repoPath, filePath: "src/Sample.java", untracked: false }; + const review = (targets: Record) => + ({ commitHash: "working-tree", repoPath: context.repoPath, workingTreeTargets: targets }); + expect(workingTreeStagingContext(review({ "unstaged:src/Sample.java": target }), "unstaged:src/Sample.java")) + .toEqual({ ...context, canDiscard: true }); + for (const [targets, key] of [ + // Multi-file reviews have no refresh target and would keep a stale patch. + [{}, "unstaged:src/Sample.java"], + [{ "staged:src/Sample.java": { ...target, staged: true } }, "staged:src/Sample.java"], + [{ "unstaged:src/Sample.java": { ...target, untracked: true } }, "unstaged:src/Sample.java"], + // A HEAD-to-worktree snapshot of an MM file also contains staged edits. + [{ "unstaged:src/Sample.java": { ...target, hasStagedChanges: true } }, "unstaged:src/Sample.java"], + ] as const) { + expect(workingTreeStagingContext(review(targets), key)?.canDiscard).toBeUndefined(); + } + }); + + test("routes a confirmed rollback to the original hunk and owning repository", async () => { + const api = rollbackOperations(); + const owner = createMonacoDiffHunkActions(diff, discardContext, api); + expect(owner.actions).toEqual(["stage", "discard"]); + expect(await owner.apply("hunk-4", "discard")).toBe("applied"); + const hunk = { file_path: "src/Sample.java", lines: diff.lines.slice(4) }; + expect(api.confirmDiscard).toHaveBeenCalledWith(hunk); + expect(api.discard).toHaveBeenCalledWith(context.repoPath, hunk); + expect(api.stage).not.toHaveBeenCalled(); + // The patch is stale until the Git change event replaces it. + expect(await owner.apply("hunk-0", "discard")).toBe("ignored"); + expect(await owner.apply("hunk-0", "stage")).toBe("ignored"); + expect(api.discard).toHaveBeenCalledTimes(1); + owner.dispose(); + }); + + test("rolls back a block re-derived from a full-context patch, not the whole file", async () => { + const full: GitDiff = { + ...diff, + is_full_context: true, + lines: [ + { line_type: "header", content: "@@ -1,12 +1,12 @@" }, + ...Array.from({ length: 12 }, (_, index) => index === 9 + ? [{ line_type: "removed" as const, content: "old 10", old_line_number: 10 }, + { line_type: "added" as const, content: "new 10", new_line_number: 10 }] + : [{ line_type: "context" as const, content: `line ${index + 1}`, + old_line_number: index + 1, new_line_number: index + 1 }]).flat(), + ], + }; + const api = rollbackOperations(); + const owner = createMonacoDiffHunkActions(full, discardContext, api); + const anchor = monacoDiffRows(full).find(row => row.actionAnchor)!; + expect(await owner.apply(anchor.hunkID!, "discard")).toBe("applied"); + expect(api.discard).toHaveBeenCalledWith(context.repoPath, { + file_path: "src/Sample.java", + lines: [{ line_type: "header", content: "@@ -7,6 +7,6 @@" }, ...full.lines.slice(7, 14)], + }); + owner.dispose(); + }); + + test("a cancelled confirmation writes nothing and leaves the block actionable", async () => { + const api = rollbackOperations(); + api.confirmDiscard.mockResolvedValueOnce(false); + const owner = createMonacoDiffHunkActions(diff, discardContext, api); + expect(await owner.apply("hunk-0", "discard")).toBe("cancelled"); + expect(api.discard).not.toHaveBeenCalled(); + expect(await owner.apply("hunk-0", "discard")).toBe("applied"); + expect(api.discard).toHaveBeenCalledTimes(1); + owner.dispose(); + }); + + test("rejects clicks while the confirmation is open and ignores answers for a replaced patch", async () => { + const api = rollbackOperations(); + const answer = deferred(); + api.confirmDiscard.mockImplementationOnce(() => answer.promise); + const owner = createMonacoDiffHunkActions(diff, discardContext, api); + const first = owner.apply("hunk-0", "discard"); + try { + expect(await owner.apply("hunk-4", "discard")).toBe("ignored"); + expect(await owner.apply("hunk-4", "stage")).toBe("ignored"); + owner.dispose(); + answer.resolve(true); + expect(await first).toBe("ignored"); + expect(api.discard).not.toHaveBeenCalled(); + expect(api.stage).not.toHaveBeenCalled(); + } finally { answer.resolve(false); owner.dispose(); } + }); + + test("a failed rollback can be retried", async () => { + const api = rollbackOperations(); + api.discard.mockResolvedValueOnce(false).mockRejectedValueOnce(new Error("patch does not apply")); + const owner = createMonacoDiffHunkActions(diff, discardContext, api); + expect(await owner.apply("hunk-0", "discard")).toBe("failed"); + expect(await owner.apply("hunk-0", "discard")).toBe("failed"); + expect(await owner.apply("hunk-0", "discard")).toBe("applied"); + expect(api.discard).toHaveBeenCalledTimes(3); + owner.dispose(); + }); + + test("withholds rollback from staged, truncated, new, deleted and binary reviews", async () => { + const api = rollbackOperations(); + for (const disabled of [ + createMonacoDiffHunkActions(diff, { ...discardContext, isStaged: true }, api), + createMonacoDiffHunkActions(diff, context, api), + createMonacoDiffHunkActions(diff, discardContext, { stage: api.stage, unstage: api.unstage }), + createMonacoDiffHunkActions({ ...diff, is_truncated: true }, discardContext, api), + createMonacoDiffHunkActions({ ...diff, is_new: true }, discardContext, api), + createMonacoDiffHunkActions({ ...diff, is_deleted: true }, discardContext, api), + createMonacoDiffHunkActions({ ...diff, is_renamed: true }, discardContext, api), + createMonacoDiffHunkActions({ ...diff, is_binary: true }, discardContext, api), + ]) { + expect(disabled.actions).not.toContain("discard"); + expect(await disabled.apply("hunk-0", "discard")).toBe("ignored"); + disabled.dispose(); + } + expect(api.confirmDiscard).not.toHaveBeenCalled(); + expect(api.discard).not.toHaveBeenCalled(); + }); + test("rejects unknown identities, wrong actions, incomplete patches and read-only reviews", async () => { const api = operations(); const owner = createMonacoDiffHunkActions(diff, context, api); diff --git a/windows/tauri/src/features/git/utils/monaco-diff-hunk-actions.ts b/windows/tauri/src/features/git/utils/monaco-diff-hunk-actions.ts index 7cfa4fb54..eb9dd8191 100644 --- a/windows/tauri/src/features/git/utils/monaco-diff-hunk-actions.ts +++ b/windows/tauri/src/features/git/utils/monaco-diff-hunk-actions.ts @@ -5,41 +5,87 @@ import { monacoDiffHunk } from "./monaco-diff-rows"; export interface DiffStagingContext { repoPath: string; isStaged: boolean; + /** + * The review reloads itself after a Git change, so a discarded block + * disappears instead of leaving a stale patch on screen. Only unstaged + * reviews with a working-tree refresh target offer discard. + */ + canDiscard?: boolean; } export function workingTreeStagingContext( - review: Pick, + review: Pick, sectionKey: string, ): DiffStagingContext | undefined { if (review.commitHash !== "working-tree" || !review.repoPath) return; if (!sectionKey.startsWith("staged:") && !sectionKey.startsWith("unstaged:")) return; - return { repoPath: review.repoPath, isStaged: sectionKey.startsWith("staged:") }; + const isStaged = sectionKey.startsWith("staged:"); + const target = review.workingTreeTargets?.[sectionKey]; + const canDiscard = !isStaged && !!target && !target.staged && !target.untracked + && !target.hasStagedChanges; + return { repoPath: review.repoPath, isStaged, ...(canDiscard ? { canDiscard: true } : {}) }; } type HunkOperation = (repoPath: string, hunk: GitHunk) => Promise; -type ActionResult = "applied" | "failed" | "ignored"; +export type HunkActionID = "stage" | "unstage" | "discard"; +type ActionResult = "applied" | "failed" | "cancelled" | "ignored"; + +export interface MonacoDiffHunkOperations { + stage: HunkOperation; + unstage: HunkOperation; + /** Reverse-applies the block to the working tree. */ + discard?: HunkOperation; + /** Asks the user before a destructive write; false cancels without writing. */ + confirmDiscard?: (hunk: GitHunk) => Promise; +} + +/** Discard rewrites working-tree content. New and deleted files have no + * block-level inverse: reversing the only hunk of an untracked file leaves an + * empty file behind instead of removing it. Renames come from the index and + * are never part of an unstaged review; they are excluded defensively. */ +function canDiscardDiff(diff: GitDiff): boolean { + return !diff.is_new && !diff.is_deleted && !diff.is_renamed && !diff.is_binary && !diff.is_image; +} /** One immutable host patch owns its actions until the next diff refresh. */ export function createMonacoDiffHunkActions( diff: GitDiff, context: DiffStagingContext | undefined, - operations: { stage: HunkOperation; unstage: HunkOperation }, + operations: MonacoDiffHunkOperations, ) { - const action = context?.repoPath && !diff.is_truncated + const writable = Boolean(context?.repoPath) && !diff.is_truncated; + const action: "stage" | "unstage" | null = writable && context ? context.isStaged ? "unstage" : "stage" : null; + const discardEnabled = writable && !!context && !context.isStaged && context.canDiscard === true + && !!operations.discard && !!operations.confirmDiscard && canDiscardDiff(diff); + const actions: readonly HunkActionID[] = action + ? discardEnabled ? [action, "discard"] : [action] : []; let disposed = false; let pending = false; let applied = false; return { action, + actions, async apply(hunkID: string, requestedAction: string): Promise { - if (disposed || pending || applied || !action || !context || requestedAction !== action) return "ignored"; + if (disposed || pending || applied || !context + || !(actions as readonly string[]).includes(requestedAction)) return "ignored"; const hunk = monacoDiffHunk(diff, hunkID); if (!hunk) return "ignored"; + const requested = requestedAction as HunkActionID; + // Pending also covers the confirmation dialog, so a second click cannot + // queue another destructive write behind it. pending = true; try { - const success = await operations[action](context.repoPath, hunk); + if (requested === "discard") { + const confirmed = await operations.confirmDiscard!(hunk); + // A refresh while the dialog was open replaced this patch; its + // identity no longer describes the file, so the answer is moot. + if (disposed) return "ignored"; + if (!confirmed) return "cancelled"; + } + const operation = requested === "discard" ? operations.discard! : operations[requested]; + const success = await operation(context.repoPath, hunk); if (disposed) return "ignored"; // The patch is stale after a successful mutation. Wait for the host's // Git change event to replace it before accepting another action. diff --git a/windows/tauri/src/features/git/utils/monaco-diff-rows.test.ts b/windows/tauri/src/features/git/utils/monaco-diff-rows.test.ts index 18bf2931a..ba491cc06 100644 --- a/windows/tauri/src/features/git/utils/monaco-diff-rows.test.ts +++ b/windows/tauri/src/features/git/utils/monaco-diff-rows.test.ts @@ -2,7 +2,33 @@ import type { ReviewRow } from "@lithe/editor/diff-review"; import { describe, expect, test } from "bun:test"; import fixture from "../../../../../../shared/fixtures/editor/diff-review-v1.json"; import type { GitDiff } from "../types/git.types"; -import { monacoDiffRows } from "./monaco-diff-rows"; +import { parseRawDiffContent } from "./git-diff-parser"; +import { monacoDiffHunk, monacoDiffRows } from "./monaco-diff-rows"; + +const filePatch = (hunks: string) => + `diff --git a/f.txt b/f.txt\n--- a/f.txt\n+++ b/f.txt\n${hunks}`; +const parse = (hunks: string) => parseRawDiffContent(filePatch(hunks), "f.txt") as GitDiff; +const fileLines = (from: number, to: number) => + Array.from({ length: to - from + 1 }, (_, index) => ` line ${from + index}`); + +// Both patches are real `git diff` output for one edit of a 40-line file: +// `--unified=2147483647` for the review, and Git's default `--unified=3`. +const fullContext: GitDiff = { + ...parse([ + "@@ -1,40 +1,40 @@", + " line 1", "-line 2", "+line 2 changed", ...fileLines(3, 12), "+inserted after 12", + ...fileLines(13, 18), "-line 19", ...fileLines(20, 37), "-line 38", "+line 37 changed", + ...fileLines(39, 40), "", + ].join("\n")), + is_full_context: true, +}; +const gitDefaultHunks = parse([ + "@@ -1,5 +1,5 @@", " line 1", "-line 2", "+line 2 changed", ...fileLines(3, 5), + "@@ -10,13 +10,13 @@ line 9", ...fileLines(10, 12), "+inserted after 12", ...fileLines(13, 18), + "-line 19", ...fileLines(20, 22), + "@@ -35,6 +35,6 @@ line 34", ...fileLines(35, 37), "-line 38", "+line 37 changed", + ...fileLines(39, 40), "", +].join("\n")); describe("shared Monaco diff rows", () => { test("matches the cross-platform sparse patch fixture", () => { @@ -18,3 +44,59 @@ describe("shared Monaco diff rows", () => { expect(rows.map(row => row.id)).toEqual(["line-0", "line-1"]); }); }); + +describe("full-context Monaco diff rows", () => { + // Regression for #557: the review holds every source line, so Monaco can + // reveal folded regions instead of showing a sparse patch with nothing to expand. + test("project the whole file without the full-file header row", () => { + const rows = monacoDiffRows(fullContext); + expect(rows.some(row => row.kind === "information")).toBe(false); + expect(rows.filter(row => row.left !== null).map(row => row.oldLine)) + .toEqual(Array.from({ length: 40 }, (_, index) => index + 1)); + expect(rows.filter(row => row.right !== null).map(row => row.newLine)) + .toEqual(Array.from({ length: 40 }, (_, index) => index + 1)); + // Identity still addresses the host line array, so search results keep working. + expect(rows[0].id).toBe("line-1"); + }); + + test("anchor one action band per hunk and leave distant context unowned", () => { + const rows = monacoDiffRows(fullContext); + const anchors = rows.filter(row => row.actionAnchor); + expect(anchors.map(row => row.hunkID)).toEqual(["hunk-2", "hunk-14", "hunk-40"]); + expect(rows.find(row => row.oldLine === 30)?.hunkID).toBeNull(); + }); + + test("stage the same hunks Git reports at its default context", () => { + const anchors = monacoDiffRows(fullContext).filter(row => row.actionAnchor); + const derived = anchors.map(row => monacoDiffHunk(fullContext, row.hunkID!)); + const expected = gitDefaultHunks.lines.flatMap((line, index) => + line.line_type === "header" ? [monacoDiffHunk(gitDefaultHunks, `hunk-${index}`)] : []); + // Git appends the enclosing-line hint to headers; `git apply` ignores it. + const withoutHint = (hunk: ReturnType) => hunk && { + ...hunk, + lines: hunk.lines.map(line => line.line_type === "header" + ? { line_type: "header" as const, content: line.content.replace(/ @@.*$/, " @@") } + : line), + }; + expect(derived).toEqual(expected.map(withoutHint)); + }); + + test("name the preceding line for a hunk with an empty side", () => { + const added = parse("@@ -0,0 +1,2 @@\n+first\n+second\n"); + const hunk = monacoDiffHunk({ ...added, is_full_context: true }, "hunk-1"); + expect(hunk?.lines[0]).toEqual({ line_type: "header", content: "@@ -0,0 +1,2 @@" }); + }); + + test("keep sparse-patch hunks when the patch is not marked full-context", () => { + expect(monacoDiffRows(gitDefaultHunks).filter(row => row.kind === "information")).toHaveLength(3); + expect(monacoDiffHunk({ ...fullContext, is_full_context: false }, "hunk-0")?.lines) + .toHaveLength(fullContext.lines.length); + }); + + test("reject identities that do not start a derived hunk", () => { + for (const id of ["hunk-0", "hunk-3", "hunk-30", "line-2"]) { + expect(monacoDiffHunk(fullContext, id)).toBeNull(); + } + expect(monacoDiffHunk({ ...fullContext, is_truncated: true }, "hunk-2")).toBeNull(); + }); +}); diff --git a/windows/tauri/src/features/git/utils/monaco-diff-rows.ts b/windows/tauri/src/features/git/utils/monaco-diff-rows.ts index 0ba71453a..d4d3bdb16 100644 --- a/windows/tauri/src/features/git/utils/monaco-diff-rows.ts +++ b/windows/tauri/src/features/git/utils/monaco-diff-rows.ts @@ -1,10 +1,94 @@ import type { ReviewRow } from "@lithe/editor/diff-review"; -import type { GitDiff, GitHunk } from "../types/git.types"; +import type { GitDiff, GitDiffLine, GitHunk } from "../types/git.types"; import { parseDiffHunkRange } from "./git-diff-helpers"; +/** Context kept around each change when a full-file patch is split back into + * stageable hunks. Matches Git's default `--unified=3`. */ +const FULL_CONTEXT_HUNK_CONTEXT_LINES = 3; + +/** A stageable range of a full-context patch, as indices into `diff.lines`. */ +interface FullContextHunk { + id: string; + /** First changed line; the hunk's action band is rendered above it. */ + anchor: number; + start: number; + /** Exclusive end index. */ + end: number; +} + +const isChange = (line: GitDiffLine) => line.line_type === "added" || line.line_type === "removed"; + +/** Splits a full-context patch the way Git would at three context lines, so + * staging granularity does not grow to the whole file. Returns null for sparse + * patches, which keep the host's own hunks. */ +function fullContextHunks(diff: GitDiff): FullContextHunk[] | null { + const { lines } = diff; + if (!diff.is_full_context || lines[0]?.line_type !== "header" + || lines.some((line, index) => index > 0 && line.line_type === "header")) return null; + const context = FULL_CONTEXT_HUNK_CONTEXT_LINES; + const hunks: FullContextHunk[] = []; + for (let index = 1; index < lines.length; index++) { + if (!isChange(lines[index])) continue; + let last = index; + // Like Git, changes whose surrounding context would touch share one hunk. + for (let next = index + 1; next < lines.length && next - last - 1 <= 2 * context; next++) { + if (isChange(lines[next])) last = next; + } + hunks.push({ + id: `hunk-${index}`, + anchor: index, + start: Math.max(1, index - context), + end: Math.min(lines.length, last + context + 1), + }); + index = last; + } + return hunks; +} + +function hunkHeader(lines: GitDiffLine[], start: number, end: number): string { + const range = (key: "old_line_number" | "new_line_number") => { + const numbers = lines.slice(start, end).flatMap(line => line[key] ?? []); + if (numbers.length > 0) return `${numbers[0]},${numbers.length}`; + // Git names the line before an empty range, or 0 at the start of the file. + for (let index = start - 1; index >= 0; index--) { + const previous = lines[index][key]; + if (previous !== undefined) return `${previous},0`; + } + return "0,0"; + }; + return `@@ -${range("old_line_number")} +${range("new_line_number")} @@`; +} + +const rowKind = (line: GitDiffLine): ReviewRow["kind"] => + line.line_type === "header" ? "information" + : line.line_type === "added" ? "addition" + : line.line_type === "removed" ? "removal" : "context"; + /** Preserve patch order and identity. Monaco aligns the projections, while * search results continue to address the original Git line array. */ export function monacoDiffRows(diff: GitDiff): ReviewRow[] { + const derived = fullContextHunks(diff); + if (derived) { + // The single full-file header carries no information worth a row, and its + // range would stage the whole file; derived hunks own the actions instead. + const owners = new Map(); + for (const hunk of derived) for (let index = hunk.start; index < hunk.end; index++) owners.set(index, hunk); + return diff.lines.flatMap((line, index) => { + if (line.line_type === "header") return []; + const hunk = owners.get(index); + return [{ + id: `line-${index}`, + oldLine: line.old_line_number ?? null, + newLine: line.new_line_number ?? null, + left: line.line_type === "added" ? null : line.content, + right: line.line_type === "removed" ? null : line.content, + kind: rowKind(line), + hunkID: hunk?.id ?? null, + ...(hunk?.anchor === index ? { actionAnchor: true } : {}), + }]; + }); + } + let hunkID: string | null = null; return diff.lines.map((line, index) => { if (line.line_type === "header") hunkID = `hunk-${index}`; @@ -14,9 +98,7 @@ export function monacoDiffRows(diff: GitDiff): ReviewRow[] { newLine: line.new_line_number ?? null, left: line.line_type === "added" ? null : line.content, right: line.line_type === "removed" ? null : line.content, - kind: line.line_type === "header" ? "information" - : line.line_type === "added" ? "addition" - : line.line_type === "removed" ? "removal" : "context", + kind: rowKind(line), hunkID, }; }); @@ -25,6 +107,18 @@ export function monacoDiffRows(diff: GitDiff): ReviewRow[] { /** Resolve the host's patch identity, never Monaco's aligned display ranges. */ export function monacoDiffHunk(diff: GitDiff, hunkID: string): GitHunk | null { if (diff.is_truncated || !/^hunk-(0|[1-9]\d*)$/.test(hunkID)) return null; + const derived = fullContextHunks(diff); + if (derived) { + const hunk = derived.find(candidate => candidate.id === hunkID); + if (!hunk) return null; + return { + file_path: diff.file_path, + lines: [ + { line_type: "header", content: hunkHeader(diff.lines, hunk.start, hunk.end) }, + ...diff.lines.slice(hunk.start, hunk.end), + ], + }; + } const start = Number(hunkID.slice(5)); const header = diff.lines[start]; if (header?.line_type !== "header" || !parseDiffHunkRange(header.content)) return null; diff --git a/windows/tauri/src/features/git/utils/multi-file-diff.test.ts b/windows/tauri/src/features/git/utils/multi-file-diff.test.ts index 04a62a9d0..88fe9433f 100644 --- a/windows/tauri/src/features/git/utils/multi-file-diff.test.ts +++ b/windows/tauri/src/features/git/utils/multi-file-diff.test.ts @@ -1,6 +1,11 @@ import { describe, expect, test } from "bun:test"; import type { GitDiff } from "../types/git.types"; -import { createCommitDiffBuffer, createMultiFileDiff } from "./multi-file-diff"; +import { + COMMIT_FILE_PREVIEW_PATH, + createCommitDiffBuffer, + createCommitFileDiffPreview, + createMultiFileDiff, +} from "./multi-file-diff"; const diff = (filePath: string, additions: number, deletions: number): GitDiff => ({ file_path: filePath, @@ -49,12 +54,53 @@ describe("commit diff buffers", () => { }); expect(multiDiff.files).toBe(diffs); - expect(multiDiff.fileKeys).toEqual([ - "newest:src/shared.ts", - "oldest:src/shared.ts", - ]); + expect(multiDiff.fileKeys).toEqual(["newest:src/shared.ts", "oldest:src/shared.ts"]); expect(multiDiff.fileLabels).toEqual(["newest", "oldest"]); expect(multiDiff.initiallyExpandedFileKey).toBe("newest:src/shared.ts"); expect(multiDiff.initiallySelectedFileKey).toBe("newest:src/shared.ts"); }); }); + +describe("commit file preview", () => { + test("shows only the selected file without commit metadata in a reusable tab", () => { + const selected = diff("src/feature.ts", 4, 3); + const preview = createCommitFileDiffPreview({ + repoPath: "C:/repo", + commitHash: "1234567890abcdef", + diffs: [selected], + filePath: selected.file_path, + label: "1234567", + }); + + if (!preview) throw new Error("Expected file preview"); + expect(preview.virtualPath).toBe(COMMIT_FILE_PREVIEW_PATH); + expect(preview.displayName).toBe("feature.ts (1234567)"); + expect(preview.diffData.files).toEqual([selected]); + expect(preview.diffData.totalFiles).toBe(1); + expect(preview.diffData.commitMessage).toBeUndefined(); + expect(preview.diffData.hideFileList).toBe(true); + expect(preview.diffData.initiallySelectedFileKey).toBe("src/feature.ts:0"); + }); + + test("finds a diff by its new, current or original path", () => { + const renamed: GitDiff = { + ...diff("src/new.ts", 1, 1), + old_path: "src/old.ts", + new_path: "src/new.ts", + is_renamed: true, + }; + const diffs = [diff("src/main.ts", 2, 1), renamed]; + + const preview = (filePath: string) => + createCommitFileDiffPreview({ + repoPath: "C:/repo", + commitHash: "abc", + diffs, + filePath, + label: "abc", + }); + expect(preview("src/new.ts")?.diffData.files).toEqual([renamed]); + expect(preview("src/old.ts")?.diffData.files).toEqual([renamed]); + expect(preview("src/missing.ts")).toBeNull(); + }); +}); diff --git a/windows/tauri/src/features/git/utils/multi-file-diff.ts b/windows/tauri/src/features/git/utils/multi-file-diff.ts index b35fd1b1a..7a86902cf 100644 --- a/windows/tauri/src/features/git/utils/multi-file-diff.ts +++ b/windows/tauri/src/features/git/utils/multi-file-diff.ts @@ -87,3 +87,54 @@ export function createCommitDiffBuffer({ diffData, }; } + +/** Stable path so every commit-file preview reuses one editor tab instead of opening a new one. */ +export const COMMIT_FILE_PREVIEW_PATH = "diff://commit-file-preview"; + +/** + * Builds a single-file diff for the commit-files preview. Unlike the commit diff buffer it carries + * no commit message metadata: the preview shows only the selected file's changes. + */ +export function createCommitFileDiffPreview({ + repoPath, + commitHash, + diffs, + filePath, + fileKeys, + fileLabels, + label, +}: { + repoPath: string; + commitHash: string; + diffs: readonly GitDiff[]; + filePath: string; + fileKeys?: string[]; + fileLabels?: string[]; + /** Short revision label shown next to the file name in the tab title. */ + label: string; +}) { + // Disconnected selections can contain several revisions of the same path. + // Keep every matching section and its commit identity, rather than taking the first match. + const indexes = diffs.flatMap((diff, index) => + [diff.new_path, diff.file_path, diff.old_path].includes(filePath) ? [index] : [], + ); + if (indexes.length === 0) return null; + const fileName = filePath.split("/").pop() ?? filePath; + const diffData = createMultiFileDiff({ + title: fileName, + repoPath, + commitHash, + diffs: indexes.map((index) => diffs[index]), + initialFilePath: filePath, + fileKeys: fileKeys ? indexes.map((index) => fileKeys[index]) : undefined, + fileLabels: fileLabels ? indexes.map((index) => fileLabels[index]) : undefined, + }); + diffData.hideFileList = true; + diffData.totalFiles = 1; + + return { + virtualPath: COMMIT_FILE_PREVIEW_PATH, + displayName: `${fileName} (${label})`, + diffData, + }; +} diff --git a/windows/tauri/src/features/keymaps/defaults/default-keymaps.git-log.test.ts b/windows/tauri/src/features/keymaps/defaults/default-keymaps.git-log.test.ts index fd770c2db..d2eb20e51 100644 --- a/windows/tauri/src/features/keymaps/defaults/default-keymaps.git-log.test.ts +++ b/windows/tauri/src/features/keymaps/defaults/default-keymaps.git-log.test.ts @@ -31,12 +31,9 @@ describe("Git Log workbench entry points", () => { }); }); - test("places Git Log on the bottom activity rail above Settings", () => { + test("places Git Log on the bottom activity rail after Run", () => { expect(SIDEBAR_BOTTOM_ACTIVITY_ITEM_IDS.indexOf("run")).toBeLessThan( SIDEBAR_BOTTOM_ACTIVITY_ITEM_IDS.indexOf("gitLog"), ); - expect(SIDEBAR_BOTTOM_ACTIVITY_ITEM_IDS.indexOf("gitLog")).toBeLessThan( - SIDEBAR_BOTTOM_ACTIVITY_ITEM_IDS.indexOf("settings"), - ); }); }); diff --git a/windows/tauri/src/features/layout/components/bottom-pane/bottom-pane.tsx b/windows/tauri/src/features/layout/components/bottom-pane/bottom-pane.tsx index 496e0d8f7..1ca59af7c 100644 --- a/windows/tauri/src/features/layout/components/bottom-pane/bottom-pane.tsx +++ b/windows/tauri/src/features/layout/components/bottom-pane/bottom-pane.tsx @@ -223,29 +223,18 @@ const BottomPane = () => { const resizeGutter = !isFullScreen ? (
-
-
+ /> ) : null; const paneContent = (
+ {/* Both activity rails stay outside the middle column so they span the full + workbench height; the full-width bottom pane only stretches between them. */} -
-
- - - +
+ + +
+
+ + + +
+ {terminalWidthMode === "editor" && deferredSurfacesReady && ( + + + + )} +
+ +
- {terminalWidthMode === "editor" && deferredSurfacesReady && ( + + {terminalWidthMode === "full" && deferredSurfacesReady && ( )}
-
- - {terminalWidthMode === "full" && deferredSurfacesReady && ( -
- - - -
- )}
{showStatusBar ?