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 2ac763f39..ae783b3ce 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 @@ -134,6 +134,31 @@ drain 期间改名、改回或开始保存时,旧磁盘快照不得替换当 有界回调测试覆盖这两条路径的正常更新和三种失效,并验证 hold 始终释放。 这些测试不能替代真实 WKWebView 的 IME、剪贴板、系统窗口关闭和 WebView2 人工验收。 +### 轻点鼠标事件的 WebKit 兼容边界 + +Issue #948 在微信输入法下快速轻点触控板会进入意外选区,关闭系统拖移仍复现, +切换系统输入法后恢复。该对照与 [WebKit #219670](https://bugs.webkit.org/show_bug.cgi?id=219670) +记录的输入法导致按下/抬起事件乱序一致;尚未采集用户机器上的实际事件轨迹。 +在固定 Monaco 0.55.1 中回放“抬起先到,随后按下事件的 `buttons` 为 0”, +能证明后续无按键移动仍扩展选区:上游拖选监视器只比较按钮状态是否等于初始值, +初始值为 0 时,普通悬停移动也符合条件。 + +`macos/EditorFrontend/mouse-input.ts` 在 Monaco 收到事件前,仅为编辑器内 +`buttons == 0` 的左键/中键按下事件补回对应按钮位。保留原事件、点击次数、 +修饰键和坐标,继续使用 Monaco 的选词、拖选与按键释放处理;页面销毁移除监听。 +例如乱序轻点后移动仍保持插入点,同位置双击仍选词,真实按住拖动仍扩展选区。 +不要通过取消所有双击、禁用文本拖放、时间阈值或自建选区状态机规避问题。 +也不修改旧 `CodeTextView`,因为主编辑器已不经过该实现。 + +已检查固定版本及上游当前实现,没有可直接启用的配置修复这条分支;补偿限定在 +macOS 宿主,不修改第三方源码或 Windows WebView2。代价是需要持续检查上游 +输入事件语义;当 WebKit 修正事件顺序或 Monaco 拒绝零按钮拖选后,应重新验证并 +考虑删除此补偿。没有新增资源目录、缓存或运行时磁盘写入。 + +`mouse-input.integration.ts` 纳入原有 `--workbench-tests` 原生验证宿主,覆盖乱序轻点、 +选区内点击、正常拖选、双击、Shift 点击和监听清理。Linux Chromium 的真实 Monaco +回放中,修复前两项失败,修复后六项通过;这不等于 macOS 微信输入法实机验收。 + Java 继承/实现导航标记独立于 CodeVision,继续调用现有 JDTLS 导航标记与解析 服务。Monaco 左侧 glyph lane 承载原来的四类 SVG 图标,区分接口实现、类继承及 上下方向。资源由 macOS 宿主作为文本注入,共享包编码为内联图片,不复制图标或 diff --git a/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md b/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md index e8a815fd7..8942b1c3f 100644 --- a/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md +++ b/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md @@ -4,7 +4,7 @@ ## 先说结论 -Agent 对话默认关闭,打开某个项目的 Agent 面板时才启动本机 Agent。每个项目只有一个 Agent 进程,一个进程里可以有多个会话;会话历史由 Agent 自己保存;Lithe 负责显示,并单独保存收藏、自定义标题和可恢复的隐藏状态。第一阶段只支持用户自己的 API Key,不接任何官方账号登录。Agent 需要的 Node.js 由用户自己安装,Lithe 只负责检测;ACP 适配器由 Lithe 提供一键安装,安装时用的是用户本机的 npm。ACP(Agent Client Protocol,编辑器与 Agent 之间的对话协议)连接和进程管理写在同一个 Rust crate 里,Mac 与未来的 Windows 只各自实现界面。 +Agent 对话默认关闭,打开某个项目的 Agent 面板时才启动本机 Agent。每个项目的每种 Agent 只有一个对话连接,一个连接里可以有多个会话;会话历史由 Agent 自己保存;Lithe 负责显示,并单独保存收藏、自定义标题和可恢复的隐藏状态。供应商管理允许 Codex 在自定义 API Key 和本机 ChatGPT 订阅间切换;Claude 仍只支持 API Key。订阅额度放在输入框上下文的右侧,不新增页面。Agent 需要的 Node.js 由用户自己安装,Lithe 只负责检测;ACP 适配器由 Lithe 提供一键安装,安装时用的是用户本机的 npm。ACP(Agent Client Protocol,编辑器与 Agent 之间的对话协议)连接和进程管理写在同一个 Rust crate 里,Mac 与未来的 Windows 只各自实现界面。 ## 问题 @@ -19,13 +19,16 @@ Agent 对话默认关闭,打开某个项目的 Agent 面板时才启动本机 - **共享实现**:`rust/lithe-agent-host` 使用官方 `agent-client-protocol` SDK。一个 `AgentHandle` 对应一个项目的 Agent 进程和 ACP 连接,负责初始化、网关登录、会话新建/列出/加载、消息、权限、取消和进程树清理。Mac 通过 Rust Core C ABI(`lithe_agent_open_json`、`lithe_agent_send_json`、`lithe_agent_close`)调用;Windows 以后直接依赖同一个 crate。命令和事件的 JSON 形状由 `shared/fixtures/agent/acp-events-v1.json` 固定。 - **按需启动,跟着项目走**:`LitheAgentConversationModule` 是内置可选模块,默认禁用。每个项目有自己的模块运行时,所以会话天然属于项目。切换标签或窗口不会结束任何会话,后台项目的这一轮会继续跑完;只有关闭项目、关闭功能或退出应用时才停止 Agent。后台项目的会话在等待权限时,项目标签上会显示提醒点。 -- **只用 API Key 登录**:初始化时声明 `auth._meta.gateway = true`,然后只用 `gateway` 方式登录,把服务商地址和 `Authorization: Bearer ` 放进 `authenticate` 请求,经 stdio 传给 Agent。Key 不进命令行参数、环境变量或文件,Lithe 也不设置 `APP_SERVER_LOGS`。Agent 不提供 `gateway` 登录时直接报错,不会退而使用它的账号登录。第一阶段只支持 Responses 协议的服务商,也就是 codex-acp。 +- **API Key 模式显式登录**:初始化时声明 `auth._meta.gateway = true`,然后只用 `gateway` 方式登录,把服务商地址和 `Authorization: Bearer ` 放进 `authenticate` 请求,经 stdio 传给 Agent。Key 不进命令行参数、环境变量或文件,Lithe 也不设置 `APP_SERVER_LOGS`。Agent 不提供 `gateway` 登录时直接报错,不会退而使用它的账号登录。Codex 使用 Responses,Claude 使用 Anthropic Messages;二者均不静默回退到账户订阅。 +- **Codex 订阅是 Agent 绑定方式**:供应商管理增加“Codex 订阅”选项,但不往提交信息共用的 HTTP 服务商注册表写一个假供应商。已有配置缺少认证类型时仍解释为 API Key;订阅绑定不保存 provider ID,启动时不读取该服务商的 Key、URL 或默认模型。只在当前子进程中指定官方 `openai` 路由并清除继承的 API 覆盖,会话配置同时清空 `openai_base_url` 并指定官方 `chatgpt_base_url`,防止本机旧地址继续覆盖订阅路由;额度探测也使用同样的覆盖。保留用户的 `CODEX_HOME`、MCP 和 Skills。本机 Codex 拥有账号存储和刷新,Lithe 不读取或复制其订阅凭据。已有账号直接复用;未登录先提示,用户点击才通过 ACP `chat-gpt` 方法打开官方浏览器登录。等待有五分钟期限,取消停止连接并停留在可重试状态,避免 idle 空态自动重连;认证通知与请求响应先后不作为成功依据,必须等上游确认账号。账号退出或邮箱改变时断开旧连接,不静默改计费来源。订阅错误不附加未经脱敏的上游 stderr,因为 Lithe 并不知道其凭据值。 +- **额度复用官方查询能力**:核对 [codex-acp v1.13.1](https://github.com/agentclientprotocol/codex-acp/blob/v1.13.1/src/CodexAcpServer.ts) 的扩展方法后,确认它提供连接级认证状态推送,但没有结构化额度接口;`/status` 只有显示文本,token 统计也不是订阅窗口。因此共享 host 短暂启动已检测到的本机 `codex app-server`,用官方 `account/rateLimits/read` 查询,禁止创建线程或发送 prompt。查询前后读取账号并与 ACP 提供的邮箱比较;没有邮箱时不能确认归属,只显示未知。上游 ACP 没有稳定账户 ID,同邮箱切换组织仍是当前身份校验的限制。每连接最多一分钟一次、不并发,查询二十秒超时;连接取消或查询结束清理整个进程树。将来 ACP 暴露结构化额度后用同一连接替换此探测,不长期维护第二套认证或私有 HTTP API。 +- **额度显示和资源边界**:参考 PR #870 保留真实窗口时长、未知不当零、失败显示过期的原则,不整合其独立页面和日志扫描。输入框上下文栏右侧显示使用率最高的窗口,例如 `5h · 已用 68%`;悬停列出各额度窗口、重置与更新时间。窗口按实际上报时长标注,primary 可以是七天。面板可见且应用活跃时每分钟刷新,回复结束也请求一次受限刷新;历史页、隐藏面板停止定时请求。短暂失败保留并灰显旧值,断连与身份失败清除旧额度。API Key 模式没有额度查询。快照只在连接内存中存活;未增加下载、缓存文件或可复用工作树资源,CLI 仍使用自身用户目录,安装目录和发行 bundle 只读,不影响代码签名或 Sparkle delta。 - **历史以 Agent 为准**:会话列表来自 `session/list`,打开旧会话用 `session/load`,由 Agent 回放历史。Lithe 不自己保存聊天记录。Agent 进程重启后,旧会话必须先加载才能继续发消息。导出按 [ACP 会话加载契约](https://agentclientprotocol.com/protocol/v1/session-setup) 等待完整回放后返回的 `session/load` 响应;Swift 接收到 `sessionLoaded` 时先刷新缓冲文本,再生成导出快照,不靠固定延迟猜测回放完成。 - **历史页与本地管理**:参考 [CC GUI 历史页](https://github.com/zhukunpenglinyutong/jetbrains-cc-gui/tree/main/webview/src/components/history) 使用面板内返回、搜索、统计和分隔列表。搜索只查询标题和 ID,数量只统计已加载的用户/Agent 消息,不扫描 CLI 文件,也不伪造上游未提供的消息总数。`AgentHistoryFeatureModel` 通过 `AgentHistoryPersisting` 保存收藏、标题覆盖和隐藏标记;macOS adapter 使用既有偏好设置,键为 `lithe.agent-history.v1.`,按标准化工作区与配置 Agent ID 隔离。变更前重新读取,避免另一窗口的旧快照覆盖最近注释;解码或保存失败明确显示,不重置损坏状态。移除仅隐藏本地列表,提供“已移除的会话”筛选与恢复,不宣称删除 Agent 原始会话。单条和批量移除都先弹出确认框,取消不写元数据;确认操作使用弹窗出现时的会话 ID 快照,避免把后续选择变化带入删除。进入历史页保留对话视图和输入草稿,不停止正在进行的轮次。 - **导出与资源所有权**:导出为用户选择位置的 Markdown,包含用户、Agent 和工具消息,工具输入/输出/内容一并保留。未打开会话复用现有、有请求期限的 ACP 加载路径,逐个加载,不改变选中标签;加载失败不写部分导出;只有会话成功创建或完整加载后才标记历史快照可复用,消息非空不能证明完整性。失败或断连留下的部分回放必须重载,已完整加载的快照在断连后仍可导出。取消与模块关闭释放等待 continuation(异步结果回调),原加载请求可自然结束。正在回复或加载中的会话不导出。导出 Task 由历史功能模型拥有并在模块停止时取消、等待结束;平台 adapter 拥有保存对话框与原子文件写入,最大 32 MiB。偏好设置与用户导出都不是构建缓存,不新增下载、不跨 worktree 复制;见 `scripts/worktree-resources.json`。它们不写发行 bundle、不影响代码签名和 Sparkle delta。 - **上下文用量以上游为准**:输入框顶部参考 CC GUI 显示圆环与整数百分比,悬停显示一位小数百分比及已用/总容量 token。提示复用工作台已有的悬停浮层与面板局部 scope(浮层可绘制的范围),圆环和百分比共用完整命中区域,鼠标进入立即显示在指示器上方,移开或面板退出时关闭;不依赖系统原生 `.help` 的延迟提示,避免宿主视图内看不到详情。使用现有 ACP `usage_update.used/size`,共享 host 已按 SDK 原样转发,不另接 Codex 私有事件、不扫描历史文件、不根据文字长度或模型名推算。计费用量是累计消耗,不是当前上下文容量,不能混用。用量按会话保存,每条有效更新替换之前值,压缩后允许下降;没有上报、容量为零或无效数据在会话状态中仍保持未知;展示层按 CC GUI 使用 0% 占位,悬停仅提示“上下文: 0.0%”,真实零使用量也使用相同简短提示,不编造已用 token 或总容量。断连、确认模型改变、重新加载会话清除旧数据,权限或思考选项改变不清除。超过容量时保留上游数值与百分比,仅圆环限制为一整圈。数据只是内存会话状态,不写入 bundle 或新增缓存资源。 - **停止必须等上游确认**:只发送一次 `session/cancel`,撤销本轮权限请求,界面进入“正在停止”。收到原 prompt 的结束响应后才能发送下一轮。旧方案只屏蔽迟到的 prompt 响应,却不能阻止上游把新消息并入旧轮次,也不能识别没有轮次编号的迟到通知。因此改为十秒确认期限:超过期限则明确报错、停止该 Agent 的进程树,保留界面记录,用户重连后通过 `session/load` 恢复。正常取消不重启进程。这不是伪装成正常结束,用户会看到恢复原因;同一 Agent 进程里的其他会话也会断开,不能静默自动重试消息。 -- **会话配置由上游提供**:面板连接完成后准备空会话,让用户发第一条消息前就能选择模型、权限模式、思考强度。选项、分组和当前值均来自 `session/new`、`session/load`、配置更新通知及 `session/set_config_option` 的响应;不硬编码模型列表。每次启动 Agent 连接前,通过既有本机配置端口刷新已绑定导入服务商的默认模型,避免旧导入值(例如已移除的模型名)再次成为所有新会话的默认值;只刷新模型,不改提交信息的服务商选择、手动服务商、端点或凭据。模型读取独立于完整 API 配置:Codex 没有自定义服务商或 API Key 时仍能读取顶层 `model`,不能把其他 TOML 表里的同名字段当成默认模型;凭据校验失败时也更新面板的模型回退显示。读取模型不会放开第一阶段的 API Key 登录要求。仅刷新本机配置仍不够:codex-acp 1.13.1 会把不在实际模型目录中的配置值临时补进选项。共享 host 保留上游同时返回的旧式模型目录,并协商其版本化推荐值扩展(`jetbrains.air.recommendedValue`);只有当前值确实不在目录中、推荐值同时存在于目录和选项中时,新会话才通过标准 ACP 配置请求切到该推荐值。等待上游确认后再发布会话,两次请求共用创建会话的有界超时。配置中有效的模型、历史会话和用户文件不被自动改写;扩展或目录缺失时保留标准 ACP 行为,不猜默认模型、不按版本名或描述筛选。ACP 模型菜单使用该 Agent 已有的品牌 SVG,不使用通用 CPU 图标;显示标签仍由上游确认的当前值决定。请求完成前禁止重复配置和发送,失败保留之前确认的值并显示错误。适配器没暴露的配置不画假控件,也不替用户改变网关地址或全局 CLI 配置。 +- **会话配置由上游提供**:面板连接完成后准备空会话,让用户发第一条消息前就能选择模型、权限模式、思考强度。选项、分组和当前值均来自 `session/new`、`session/load`、配置更新通知及 `session/set_config_option` 的响应;不硬编码模型列表。每次启动 Agent 连接前,通过既有本机配置端口刷新已绑定导入服务商的默认模型,避免旧导入值(例如已移除的模型名)再次成为所有新会话的默认值;只刷新模型,不改提交信息的服务商选择、手动服务商、端点或凭据。模型读取独立于完整 API 配置:Codex 没有自定义服务商或 API Key 时仍能读取顶层 `model`,不能把其他 TOML 表里的同名字段当成默认模型;凭据校验失败时也更新面板的模型回退显示。API Key 模式仍校验凭据;订阅模式绕过这条服务商模型同步路径,使用 Codex 自己的模型目录。仅刷新本机配置仍不够:codex-acp 1.13.1 会把不在实际模型目录中的配置值临时补进选项。共享 host 保留上游同时返回的旧式模型目录,并协商其版本化推荐值扩展(`jetbrains.air.recommendedValue`);只有当前值确实不在目录中、推荐值同时存在于目录和选项中时,新会话才通过标准 ACP 配置请求切到该推荐值。等待上游确认后再发布会话,两次请求共用创建会话的有界超时。配置中有效的模型、历史会话和用户文件不被自动改写;扩展或目录缺失时保留标准 ACP 行为,不猜默认模型、不按版本名或描述筛选。ACP 模型菜单使用该 Agent 已有的品牌 SVG,不使用通用 CPU 图标;显示标签仍由上游确认的当前值决定。请求完成前禁止重复配置和发送,失败保留之前确认的值并显示错误。适配器没暴露的配置不画假控件,也不替用户改变网关地址或全局 CLI 配置。 - **工具证据与文档保护**:工具详情合并上游的部分更新,展示类型、输入、输出、文件位置和修改前后文本;权限卡复用已收到的工具证据,区分允许和拒绝。显示文本每段限制 32 Ki 字符,内容和位置各限制 100 条,避免大工具输出堵住界面;截断处标记 `[...]`。点击项目内文件交给现有编辑器导航,不在视图中直接读写文件。磁盘刷新、脏缓冲区保护和 Git diff 沿用文档/Git 模块。Agent 从项目目录启动,不需要先附加当前文件才能读写磁盘;未保存的编辑器快照是后续独立能力。 - **未发送的空会话不能当成历史恢复**:为了提前显示配置,界面会先创建空会话,但 Codex 在首条消息前还没有把会话内容写入磁盘(rollout),旧进程关闭后用原 ID 加载会报 `no rollout found`。连接模型只跟踪本连接新建、未成功提交消息且未出现在上游历史中的会话;断开时移除这些会话的本地标签和临时记录,重连后通过原有准备流程新建。已有消息、上游列出或成功加载的会话继续恢复,不根据“当前消息列表为空”推断历史不存在,也不按错误字符串给真实历史自动新建替身。这个操作不删除用户的上游历史文件;代价是未发送会话的临时配置选择需要在新连接重新确认。 - **历史加载失败不清空记录**:回放时保留原会话快照,成功后使用上游回放,失败或连接退出时恢复之前记录并丢弃不完整的回放片段。旧连接事件任务被取消后不再消费缓冲事件。 @@ -40,16 +43,16 @@ Agent 对话默认关闭,打开某个项目的 Agent 面板时才启动本机 - **CLI 更新保留安装来源**:以 PATH 中实际命令及其真实文件为准,不能仅看到用户装了 npm 就把所有 CLI 交给 npm。Homebrew 通过自己报告的 Cellar/Caskroom 位置和已安装记录确认归属,保留 cask/formula 及 `claude-code@latest` 等渠道;npm 必须确认当前 global root、包名、bin 声明和链接都指向同一 CLI,另一套 Node 环境不能代更新;Claude 标准原生 launcher 使用上游 `claude update`。未知来源、损坏链接、缺少原安装器或安装记录时拒绝自动覆盖,明确提供手动指引。更新后重新读取登录 shell 的 PATH 并验证最低版本;更新命令退出成功但实际 CLI 仍过旧也应失败。安装器可能在首次下载失败后重试成功,却保留非零退出状态,因此正常结束的命令无论退出状态如何,都要检查实际版本。只有新安装或数字版本严格提升且达到最低要求时,才能把非零退出降为“已成功、带警告”,返回有界日志并在界面折叠展示;原本可用但版本没变、降级、仍过旧或找不到命令时继续报错。不能根据日志中的“successfully upgraded”字样猜测成功,也不能把取消、超时或启动失败改判成功。读取来源只使用有界的本地查询,不更新包管理器索引、不改变用户配置。Homebrew 和原生下载归原安装器拥有,仅显示“正在更新”与耗时,不伪造字节进度;它们的全局安装和缓存不注册成可复制的工作树构建资源,排除清单与测试同步维护。 - **下载进度以 npm 的真实传输为准**:安装与 CLI 升级通过现有 Core 事件回调报告已接收软件包字节数、最近采样速度、耗时和等待时间。npm 没有提供整次安装的总量,且会继续发现依赖,所以不显示总体百分比。内嵌的 Node 观察模块只统计 HTTP 响应进入流缓冲区的字节,不添加消费数据的监听器,也不重写下载、代理、重试、校验或缓存行为。模块通过内存中的 data URL 加载,启动后先恢复用户原有 `NODE_OPTIONS`,防止 npm 子脚本继承观察器;不生成辅助文件或新的可复用缓存。正确做法是显示“已下载 25 MB、75 KB/秒、已用时 300 秒”;不要把 npm 静默时的日志时间或整个共享缓存大小当成下载进度。Core 事件只携带数字和阶段,界面按操作标识丢弃迟到事件,完成、失败或取消后清除进度。 - **Rust Core 命令**:`agent.status`、`agent.install`、`agent.uninstall`、`agent.installCli`,复用现有信封的取消和超时。 - - **Key 和模型的传法**:所有适配器都通过 ACP `gateway` 登录,Key 经 stdio 传给 Agent,请求头按协议选择:Responses 协议用 `Authorization: Bearer`,Anthropic 协议用 `x-api-key`。模型按适配器分别传:Codex 用 `CODEX_CONFIG`,Claude 用 `ANTHROPIC_MODEL`。服务商配置里的"模型"必须传给 Agent:实测某个网关禁用了 Codex 的默认模型,不传模型时 Agent 只会回复一条网关报错。 + - **Key 和模型的传法**:API Key 模式的适配器通过 ACP `gateway` 登录,Key 经 stdio 传给 Agent,请求头按协议选择:Responses 协议用 `Authorization: Bearer`,Anthropic 协议用 `x-api-key`。模型按适配器分别传:Codex 用 `CODEX_CONFIG`,Claude 用 `ANTHROPIC_MODEL`。服务商配置里的"模型"必须传给 Agent:实测某个网关禁用了 Codex 的默认模型,不传模型时 Agent 只会回复一条网关报错。 - **设置放在面板里,只有 Agent 管理一页**:Agent 的开关、预检清单(Node、npm、CLI、适配器、本机配置)、适配器和 CLI 的一键安装都在 Agent 面板右上角的设置视图里,不进全局设置窗口。布局仿照 Codeg 和 CC GUI:左侧图标栏,右侧标题加分段切换各个 Agent。 - **本机配置保留 CLI 所有权**:每个 Agent 通过本机配置行读取用户自己 CLI 的地址、模型和密钥(Codex 读 `~/.codex/config.toml` 和 `auth.json`,Claude 读 `~/.claude/settings.json` 和 `~/.claude.json`),生成的服务商配置绑定到该 Agent。本机模式的密钥不复制进 Lithe,启动时从用户文件现读;要改本机地址或密钥时编辑自己的文件再刷新。需要独立配置时使用上面的自定义供应商编辑器,不改写 CLI 文件,也不改变提交信息使用的服务商选择。 - **关闭时仍能找到入口**:工作台始终保留 Agent 的静态入口声明,首次安装没有模块配置、手动关闭后也能打开面板。模块仍默认禁用,入口读取不调用 factory,不创建连接;禁用模块的通用贡献目录和其他模块的入口规则保持原样。未配置的面板立即显示原因和“打开 Agent 设置”操作,用户在面板内开启开关,无需修改本机配置文件。开启后复用运行时入口,按 ID 避免重复;关闭后保留面板和入口。不要把默认开关改为启用来修复发现入口的问题,否则会改变所有用户的后台启动行为。 - **提示文案本地化**:Rust 返回的 `issues` 只决定能否安装;界面显示的原因由 Swift 按结构化状态(Node 版本、npm、CLI 版本)重新生成,这样中英文都能显示。 - **面板始终显示完整布局**:功能关闭或没有配置 Agent 时,面板照样显示会话标题、消息区和输入框,用户可以输入;发送时先校验(功能是否开启、是否有已配置的 Agent、模块是否启动完成),不通过就在输入框上方给出提示并提供进入设置的按钮,不会启动任何进程。 - **文件拖入对话框**:参考 CC GUI 普通文件的路径引用交互,原生输入框接受 Finder 和项目树的文件 URL,显示可移除标签,并支持文件选择器。一次最多 32 个引用,按拖入顺序去重;切换会话清除旧文件草稿,发送失败保留文字和引用。功能模型把引用与文字一起排队,Rust Host 用上游 ACP SDK 的 `resource_link` 发送,Agent 自己读取文件并遵守现有权限。文件 URL 是这次消息的原生上下文,不作为跨平台项目标识持久化。正确做法是发送“说明这两个文件”加两个引用;不要在 View 里读文件、复制进缓存或拼装隐式文件内容提示词。图片目前同样作为文件引用,没有缩略图或多模态字节上传;这避免了与本次需求无关的内存、大小和格式策略,后续需要图片输入时再按上游能力协商。 -- **对话面板**:停靠在编辑器右侧,与 Maven 共用右侧槽位。输入框下方可以切换 Agent,列出的是已经设置了服务商的 Agent;打开的会话以标签形式排列。每个 Agent 第一次被选中时才建立连接;项目里所有 Agent 的权限提醒会合并显示到项目标签上。 +- **对话面板**:停靠在编辑器右侧,与 Maven 共用右侧槽位。输入框下方可以切换 Agent,列出的是已经绑定服务商或 Codex 订阅的 Agent;打开的会话以标签形式排列。每个 Agent 第一次被选中时才建立连接;项目里所有 Agent 的权限提醒会合并显示到项目标签上。 - **对话区的视觉与交互**:按 CC GUI 参考设计使用单行会话标题、居中的品牌标志、上下文栏和底部工具栏。只有多会话或用户主动展开时才显示标签条,避免空会话标题重复。空态不放进滚动列表,否则无法在剩余高度内居中。输入区复用 `LitheSplitPaneView`,尺寸约束跟随可用高度,拖动只更新局部容器。搜索只筛选当前已加载消息,不请求 Agent 或改动历史。品牌 SVG 来自参考项目锁定的 `@lobehub/icons` 5.8.0,以静态源资源随 SwiftPM bundle 打包并携带 MIT 许可,不新增运行时下载或可复用缓存。配置选择器按参考项目的底栏设计:权限与模型入口位于输入区底部,模型弹出面板提供本地名称/ID/分组搜索、品牌图标、蓝色选中行和绿色勾选,不显示模型说明;权限弹出面板保留上游说明并本地化已有文案。思考强度、速度及其他上游配置放到模型面板底部的子菜单,未提供时不显示。选项数量和权限语义仍以 ACP 为准,不照截图补选项。选择后收起面板,原确认/失败状态机继续拥有当前值,等待确认时禁止再次配置。弹出与搜索状态只在视图内保存,会话切换重建选择器以清除旧会话的弹出状态;不重建输入框或清空草稿。使用 macOS 原生 popover 管理定位和关闭,设置子选项在同一个 popover 内展开侧栏,避免嵌套 popover 关闭父面板;不另写鼠标全局监听或位置缓存。上下文百分比使用 Agent 上报的用量,缺失时展示 0% 占位与“上下文: 0.0%”简短提示;占位不作为已上报用量写回会话状态。 -- **进程与诊断**:启动时把 Agent 可执行文件所在目录放到 `PATH` 最前面。npm 把 `codex-acp` 和 `node` 装在同一个目录,而 Mac 图形应用拿不到登录 shell 的 `PATH`。关闭时先向整棵进程树发 SIGTERM,并记录树里的每个进程 ID,稍等后再对仍存活的进程发 SIGKILL,因为 codex-acp 的 app-server 会比外层进程晚几秒退出。Agent 意外退出时,报错里附上 stderr 最后 20 行,其中的 Key 会被替换掉。 +- **进程与诊断**:启动时把 Agent 可执行文件所在目录放到 `PATH` 最前面。npm 把 `codex-acp` 和 `node` 装在同一个目录,而 Mac 图形应用拿不到登录 shell 的 `PATH`。关闭时先向整棵进程树发 SIGTERM,并记录树里的每个进程 ID,稍等后再对仍存活的进程发 SIGKILL,因为 codex-acp 的 app-server 会比外层进程晚几秒退出。API Key 模式下 Agent 意外退出时,报错里附上 stderr 最后 20 行,其中的 Key 会被替换掉;订阅模式仅显示受控错误,不附加账号工具的原始输出。 正确做法:新平台的界面通过平台适配器把 fixture 里的命令交给 `lithe-agent-host`,并把工具权限选择交给用户。 @@ -101,7 +104,7 @@ npm 的进度选项只面向终端,HTTP 日志通常在请求完成后才输 ## 后果 -两端共享同一套协议和清理逻辑。功能关闭或没打开面板时,不会有 Agent 进程。一个项目只起一个进程,会话再多也一样。 +两端共享同一套协议和清理逻辑。功能关闭或没打开面板时,不会有 Agent 进程。一个项目的每种 Agent 只建立一个对话连接,会话再多也一样。订阅额度额外使用有界的短时官方查询进程,不新增常驻服务。 代价: @@ -111,6 +114,9 @@ npm 的进度选项只面向终端,HTTP 日志通常在请求完成后才输 ## 验证 +- 订阅新增测试覆盖旧配置兼容、显式登录、已有账号、认证通知顺序、登录取消和超时、账号变更、额度真实窗口/缺失值/多 bucket、仅查询不发送 prompt,以及临时失败保留旧值。Linux 已运行 Agent Host 的逐测试计时套件;macOS Swift 编译、真实账号登录及深浅主题/窄宽布局仍须在目标环境验证,不将代码存在等同于运行验证。 +- 真实账号验收:先用 API Key 对话,空闲切换 Codex 订阅,确认请求使用本机账号;未登录时确认打开面板不会启动浏览器,点击登录与取消正确;上下文右侧额度每分钟更新,断网后灰显,换账号后旧值清除,关闭项目不残留探测进程。Claude 不出现订阅入口。Windows 已有共享协议和 host,但订阅选择和额度 UI 待接入。 + - `cargo test -p lithe-agent-host --manifest-path rust/Cargo.toml` - `cargo test -p lithe-core agent`(`agent.*` 命令与 `shared/fixtures/agent/agent-management-v1.json`) - 真实 Agent 端到端测试默认忽略,需要设置 `LITHE_ACP_E2E_*` 环境变量后运行:`cargo test -p lithe-agent-host --test real_agent -- --ignored`。设置 `LITHE_ACP_E2E_DATA_DIR` 时,会先用 npm 安装适配器,再从 Lithe 数据目录启动。 diff --git a/.agents/notes/implemented/feature/2026-09-27-workspace-git-commit-plans.md b/.agents/notes/implemented/feature/2026-09-27-workspace-git-commit-plans.md new file mode 100644 index 000000000..5479642e0 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-27-workspace-git-commit-plans.md @@ -0,0 +1,78 @@ +# Agent 笔记:工作区多仓库提交计划与重试 + +状态:已实现 + +## 先说结论 + +Changes 中的勾选继续代表 Git 的暂存状态;用户一次提交,系统按文件所属仓库分别执行。 +真正的子模块关系以 Git 索引中的引用条目(gitlink,记录子仓库提交号)为准,提交子仓库时默认联动更新已打开工作区内的父引用。 +确认前重新检查计划;执行失败保留已完成步骤,重试只做剩余提交或推送。 +macOS 和 Windows 都直接消费 Rust Core 的提交计划、依赖排序、失败传播、重试及 Git 检查写入;平台只负责界面和原生接线。 + +## 问题 + +只有目录包含关系,不能证明两个仓库互相依赖。真正的子模块中,父仓库管理的是提交号,子仓库才管理 `hello.ts` 的内容。 +只读取有暂存文件的仓库会遗漏干净的父仓库;确认后直接重算并执行会悄悄改变用户同意的范围;推送失败后再次提交则会重复创建提交。 + +## 决策 + +- 已发现的独立嵌套仓库不会作为父仓库的未跟踪目录参与勾选,避免“全部暂存”意外创建子模块引用。比较前必须统一规范化 Git 返回的路径和平台传入的仓库绑定,兼容 macOS 目录别名及 Windows 短路径/扩展路径。 +- Windows 的文件及仓库分组勾选也写入真实暂存区,取消原有仅限单仓库的提交入口。部分暂存文件的提交预览和 AI 说明只读取索引;工作树差异仍可从差异菜单查看。 +- 保留暂存区作为选择的唯一来源,外部 `git add` 也会反映到勾选状态。没有另建一份仅属于 UI 的提交选择。 +- Core 读取 Git 的 porcelain v2 状态,把子模块提交号变化和未提交文件分开。只有文件脏、引用未变的父条目展示提示,不参与批量勾选。 +- Core 读取所有已发现仓库的真实引用,再计算子到父的执行顺序。默认自动带上必要的父引用;确认窗口显示仓库、文件、顺序和引用更新,并允许关闭本次自动联动。 +- Core 的提交前置状态包括 HEAD、当前分支、索引对象号和暂存路径。确认时重新传入最新仓库列表,任何状态变化都更新计划并再次确认;实际写入前仍在已有仓库写入锁内核对。 +- 读取提交状态和写入子引用前确认 Git 的真实根目录。子仓库元数据消失时拒绝执行,避免 Git 向上搜索后错用父仓库。 +- 更新父引用使用 Git 的精确索引更新命令,只写已经确认的子提交号。父仓库其它未暂存文件保持原样。 +- Core 每次最多执行一次提交或推送,返回由 Core 管理的进度。平台只负责确认界面、原生认证与取消、项目生命周期、展示结果和继续调用;不得自己安排仓库、推导失败范围或重算重试。 +- Windows 按工作区保留 Core 返回的结果,任务生命周期由工作台持有,切换侧栏不停止批次;离开项目时取消当前原生请求并停止派发后续步骤,迟到结果只能更新原工作区的恢复记录。 +- 每个仓库提交或推送有独立的执行上下文。停止当前操作后继续独立仓库,依赖失败子仓库的父仓库等待重试。 +- 只选择父引用时,已发现且有本地分支的子仓库会显示为“仅推送”,不会重复提交。父仓库推送还通过 Git 自带的子模块发布检查,拒绝引用尚未发布的子提交;分离 HEAD 无法确定推送分支时保留失败供用户处理。 +- 结果按仓库保留。已提交但未推送的仓库重试时只推送;已成功的独立仓库不会再提交。重试也先展示当前计划,项目切换后旧异步结果不能写回界面或启动后续仓库操作。 + +正确示例:只勾选 `A/libs/B/hello.ts`,计划显示先提交 B,再只更新 A 中的 `libs/B` 引用;如果选择推送,B 推送成功后才执行 A。 +不要在 A 中 `git add --all` 来更新 B 的引用,这会把 A 的其它未选择文件一并提交。 + +## 考虑过的备选方案 + +- 独立于暂存区的提交勾选:没有采用。会改变已有勾选含义,且外部暂存、部分暂存都需要第二套同步规则。 +- 停止一个操作就结束整个批次:没有采用。无依赖仓库可以继续完成;只有依赖的父引用必须阻塞。 +- 确认后静默执行重算的计划:没有采用。用户可能在对话框打开期间暂存更多文件,执行范围必须重新确认。 +- 把 Git 命令输出解析放在 Swift:改为共享 Core 的类型化状态,保持 Git 自身为索引和引用的事实来源。 +- 只共享 Git 原语,把提交计划留在 Swift:没有采用。Windows 会被迫重复实现依赖和重试规则;现在两个端都直接消费 Core 生成的计划和下一步结果。 +- 在 Core 中维护常驻批次注册表:没有采用。当前只需要项目会话内恢复,使用无后台资源的 JSON 续接状态即可;每一步仍以真实 Git 状态校验防止重复提交。 + +## 后果 + +Git 的多个仓库没有跨仓库原子事务,已成功的提交不会因后续失败而回滚。Lithe 的写入锁只能约束自己的命令,外部 Git 客户端不参与该锁。 +提交钩子失败可能留下已经更新的父引用暂存项;重试必须重新读取状态。取消后的只读核对有独立的 5 秒上限,返回已完成提交的信息,不能用通用取消错误覆盖它。结果仅保留在当前项目会话中,退出项目或显式清除结果后不提供恢复记录。 +自动联动仅覆盖工作区已发现的仓库,不推断未打开的外部父仓库。读取某个仓库失败时停止建立计划,不能把读取失败当成没有依赖。 +不改变 Git Log 的已有分组和活动仓库规则。 + +## 验证 + +- `windows/tauri/src/features/git/services/git-workspace-commit-workflow.test.ts` 消费同一份共享夹具,验证计划转发、再次确认、重试、取消与迟到响应;`git-workspace-status-panel.test.tsx` 验证分组勾选写入所属仓库、忽略不可暂存的脏子模块引用。Tauri dispatcher 测试保护原生认证超时与部分结果透传。 + +- `macos/Tests/LitheGitModuleTests/GitModuleTests.swift` 消费共享夹具和预设 Core 响应,验证确认与再次确认、联动选项与重试转发、步骤驱动、复选框资格和项目切换隔离;不在测试替身中重新实现业务算法。 +- `rust/lithe-core/src/git/workspace_commit/tests.rs` 覆盖干净祖先联动、关闭联动、独立嵌套仓库、过期计划、失败依赖阻塞、仅重推和外部推进后再次推送。 +- `rust/lithe-core/src/tests/git_workspace_commit.rs` 使用真实 Git 验证过期索引拒绝、子模块状态、只更新引用且不带入父文件、首次提交前取消暂存保留后续编辑,暂存后在工作树删除的文件仍可见、子仓库元数据被删除后拒绝回退父仓库,以及共享夹具序列化。真实 Git 还验证共享计划执行、推送失败后的重试、过期步骤重放拒绝、取消与成功提交返回竞争。 +- 使用 `write-stable-tests` 的 Rust 逐例计时入口生成 HTML/JUnit;macOS 使用该 Skill 的 macOS 计时入口。 +- 运行共享契约、服务边界、功能矩阵、发行包只读边界和 Agent Notes 检查。 +- 当前实现环境是 Linux;本地执行共享 Core 和 Windows 前端测试,原生构建及测试交给对应 CI,macOS/Windows 界面实测仍需平台验收,不将逻辑测试通过记作界面已验证。 + +## 适用范围 + +- `macos/Sources/LitheGitModule/Application/GitFeatureModel+WorkspaceCommit.swift` +- `macos/Sources/LitheGitModule/Models/GitModels.swift` +- `macos/Sources/LitheGitModule/Services/GitService.swift` +- `macos/Sources/Lithe/Views/Git/CommitAreaView.swift` +- `macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift` +- `rust/lithe-core/src/git/commit_state.rs` +- `rust/lithe-core/src/git/workspace_commit.rs` +- `macos/Sources/LitheGitModule/Models/GitWorkspaceCommitModels.swift` +- `shared/contracts/rust-core-api.md` + +- `windows/tauri/src/features/git/services/git-workspace-commit-workflow.ts` +- `windows/tauri/src/features/git/components/git-workspace-commit-review.tsx` +- `windows/tauri/src/features/git/components/status/git-status-panel.tsx` +- `windows/tauri/src-tauri/src/platform.rs` diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index d0cf13e2e..55d54b65b 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -276,6 +276,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 workspace Git commit adapters and staging + 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-workspace-commit.json -- git-workspace-commit git-status-api.test.ts git-status-model.test.ts ai-commit-context.test.ts working-tree-diff-refresh.test.ts + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + node $runner --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-workspace-status.json -- git-workspace-status-panel.test.tsx + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Test terminal discovery, default shell, profiles, and tab layout shell: pwsh run: ./.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 -Scope Frontend -FrontendTestPath src/features/terminal/stores/shells.store.test.ts,src/features/terminal/utils/terminal-profiles.test.ts,src/features/terminal/utils/terminal-keyboard.test.ts,src/features/settings/lib/default-shell-options.test.ts,src/features/terminal/components/terminal-tab-strip.test.tsx diff --git a/docs/development/platform-parity-matrix.csv b/docs/development/platform-parity-matrix.csv index 1c6c29e25..facdfba4c 100644 --- a/docs/development/platform-parity-matrix.csv +++ b/docs/development/platform-parity-matrix.csv @@ -6,11 +6,14 @@ agent-acp-conversation,AI,Agent 对话,可选 ACP Agent 对话:API Key 网关 agent-file-references,AI,Agent 对话,从 Finder 或项目树拖入文件到 Agent 输入框,文件选择器、可移除标签、多文件去重和数量限制、文件引用随消息发送及失败保留草稿,已实现,待验证,部分实现,待验证,Agent,macOS:从 Finder、项目树拖入临时文件(多文件、中文/空格名)到输入区与上下文栏,检查高亮和可移除标签、去重、数量限制、会话切换和发送失败保留草稿;用隔离项目验证文字+文件与纯文件发送、历史加载后引用正确送到原会话、Agent 读取与权限流程。图片按文件引用处理,无多模态上传。Windows 原生拖放和 Claude 端到端待验证。,,macos/Sources/Lithe/Views/Agent/AgentComposerView.swift; macos/Sources/Lithe/Views/Agent/AgentFileReferenceList.swift; macos/Sources/LitheAgentConversationModule/Application/AgentFileReference.swift; macos/Tests/LitheTests/AgentFileReferenceTests.swift; macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift; rust/lithe-agent-host/src/tests.rs,rust/lithe-agent-host/src/prompt.rs; shared/fixtures/agent/acp-events-v1.json agent-management,AI,Agent 对话,Agent 管理:面板内设置、Node.js/npm 检测、预检清单、ACP 适配器与 Agent CLI 一键安装或升级、一键获取本机 CLI 配置、实时下载数据量/速度/耗时与等待提示、按 CLI 安装来源更新、验证实际升级结果并区分成功警告与失败,已实现,待验证,部分实现,待验证,Agent,在 macOS 打开 Agent 面板右上角的设置:确认显示 Node.js 与 npm 版本,Node 缺失或版本过低时只提示;一键安装、取消安装、更新与卸载 Codex 适配器;CLI 过旧时确认显示 npm/Homebrew/原生来源并通过原安装器升级,npm 环境不匹配或未知来源时只显示手动指引,升级后确认实际 PATH 中的版本达到要求;模拟安装器先下载失败再重试成功但返回非零,确认版本确实提升时显示成功与折叠警告日志,版本未变/仍旧/丢失、取消和超时继续按原语义处理;确认下载数据量、速度、耗时实时更新且未知总量不显示百分比,取消/失败/完成后进度清除;一键获取本机 Codex 配置后在 Agent 面板对话,且提交信息所用服务商不变;Windows 待接入设置界面。,,macos/Sources/Lithe/Views/Agent/AgentPanelSettingsView.swift; macos/Sources/Lithe/Application/Features/AgentManagementFeatureModel.swift; rust/lithe-agent-host/src/install.rs; rust/lithe-core/src/agent/mod.rs; rust/lithe-agent-host/src/npm-progress.mjs; rust/lithe-agent-host/src/cli_update.rs; macos/Tests/LitheTests/AgentManagementFeatureModelTests.swift,rust/lithe-core/src/agent/mod.rs agent-provider-configuration,AI,Agent 对话,Agent 供应商管理:读取本机配置、自定义 Codex/Claude 配置文本、添加与编辑、安全保存密钥、启用与重连、确认删除及取消关联,已实现,待验证,部分实现,待验证,Agent,macOS:验证本机 Codex/Claude 配置读取、刷新与取消关联;添加自定义 TOML/auth JSON 或 Claude settings JSON,检查格式化、输入直引号不被自动替换、弯引号 TOML 错误在保存按钮上方始终可见、非法配置和缺失密钥、取消不保存、编辑保持 ID、删除确认及安全存储失败不丢失配置。在一个窗口保存并等待连接关闭期间,由另一窗口删除或修改同一供应商,确认旧保存失败且不恢复/覆盖供应商、密钥或 Agent 关联。检查窄宽面板、深浅主题、中英文本。空闲切换后确认请求到所选地址和模型;未发送空会话切换或意外退出后重新创建,不向旧 ID 加载,首条消息与文件只发送一次;已有消息、上游列出或成功加载的会话继续恢复,历史加载失败保留记录;正在响应、加载、权限待处理或配置确认时拒绝切换。提交服务商选择不被添加/启用改变。CLI 文件与 bundle 不被写入。Windows 编辑器及 Claude 真实请求待验证。,,macos/Sources/Lithe/Views/Agent/AgentProviderConfigurationView.swift; macos/Sources/Lithe/Platform/MacOS/UI/MacConfigurationTextEditor.swift; macos/Sources/Lithe/Application/Features/AgentProviderConfiguration.swift; macos/Sources/Lithe/Models/AppModel/AppModel+AgentProviders.swift; macos/Tests/LitheTests/AgentProviderConfigurationTests.swift; macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift; macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift,rust/lithe-core/src/ai/agent_configuration.rs; shared/fixtures/agent/provider-configuration-v1.json +agent-codex-subscription,AI,Agent 对话,Codex 供应商选择官方订阅:复用本机账号、显式浏览器登录与取消、API Key 模式切换及旧配置兼容,已实现,待验证,部分实现,待验证,Agent,macOS:已有 Codex ChatGPT 登录直接对话;未登录只显示登录按钮,点击才打开浏览器,取消与超时清理进程;API Key 与订阅空闲切换后验证实际计费来源,不携带旧 key/URL/模型、不改提交服务商;忙碌时拒绝切换,账号变化后断开;老配置仍走 API Key,Claude 不显示订阅选项。真实账号与 macOS UI 待验证;Windows 仅共享 host 已实现,UI 待接入。,,macos/Sources/Lithe/Views/Agent/AgentProviderConfigurationView.swift; macos/Sources/Lithe/Models/AppModel/AppModel+AgentProviders.swift; macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift; macos/Tests/LitheTests/AgentProviderConfigurationTests.swift; rust/lithe-agent-host/src/subscription.rs,rust/lithe-agent-host/src/lib.rs; shared/fixtures/agent/acp-events-v1.json +agent-subscription-quota,AI,Agent 对话,Codex 订阅额度:上下文右侧紧凑显示、可见时定时刷新、悬停多窗口与重置时间、未知与过期状态及账号隔离,已实现,待验证,部分实现,待验证,Agent,macOS:订阅会话上下文右侧显示额度,悬停查看窗口时长/已用百分比/重置和更新时间;真实账号验证可见且活跃时每分钟刷新、隐藏不轮询、单连接不并发探测,查询不创建会话或模型请求;周窗口作为 primary 仍显示 7d,缺失不报 0%;网络失败保留并灰显旧值,账号不匹配清除;API Key 模式不查询。窄宽面板、深浅主题、关闭面板/项目和取消时确认探测进程清理。Windows UI 与真实账号验证待完成。,,macos/Sources/Lithe/Views/Agent/AgentSubscriptionQuotaView.swift; macos/Sources/LitheAgentConversationModule/Application/AgentSubscriptionQuota.swift; macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift; rust/lithe-agent-host/src/subscription/tests.rs,rust/lithe-agent-host/src/subscription.rs; shared/fixtures/agent/acp-events-v1.json workspace-open-switch,工作区,工作区生命周期,打开工作区与切换项目,已实现,待验证,已实现,待验证,Workspace,打开多个项目并在项目之间切换,确认当前项目、文件树和编辑器状态正确。,,macos/Sources/Lithe/Views/Workspace; macos/Sources/Lithe/Services/Workspace,windows/tauri/src/features/workspace 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-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-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 @@ -19,6 +22,7 @@ search-project,搜索,搜索与导航,项目范围文本搜索,已实现,待验 search-global,搜索,搜索与导航,全局命令与设置搜索,已实现,待验证,已实现,待验证,Search,使用快捷键搜索命令、设置和工作区内容,确认结果来源和跳转行为。,,macos/Sources/Lithe/Views/Search,windows/tauri/src/features/global-search; windows/tauri/src/features/command-palette search-replace,搜索,搜索与导航,项目替换与取消,已实现,待验证,已实现,待验证,Search,执行替换预览、确认替换和取消操作,确认未保存文件与异常文件不会被静默覆盖。,,macos/Sources/Lithe/Views/Search; macos/Sources/Lithe/Services/Language,windows/tauri/src/features/file-search 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 @@ -93,3 +97,6 @@ editor-file-encoding-reopen,编辑器,文本编辑,按指定编码重新打开 editor-file-encoding-save,编辑器,文本编辑,按指定编码保存文本文件,已实现,待验证,已实现,待验证,Editor,在 macOS 和 Windows 实机验证 UTF-8、UTF-8 BOM 与 GBK/GB18030 的自动识别,并分别验证 Shift JIS、Windows-1252 的指定编码重新打开、编码转换保存、脏文件选择和外部修改保护。,,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 php-optional-plugin,语言支持,PHP,PHP 按需安装与插件生命周期,已实现,待验证,已实现,待验证,PHP Support,在干净安装上确认没有 PHP 插件运行时;显式安装并启用后验证补全、诊断,安装中取消、运行中禁用、关闭工作区和卸载后确认无插件进程,用户工具保留。 Windows 从独立 .lithe-extension 文件导入,默认禁用;启用后重启确认能恢复,卸载后重启确认不恢复。,,Plugins/mac/Official/PhpSupport; scripts/official-plugin-distribution.mjs; macos/Sources/Lithe/Platform/MacOS/Plugins,Plugins/win/Official/PhpSupport; windows/tauri/src/extensions/registry/extension-store-lifecycle.ts; windows/tauri/src-tauri/src/language_tools.rs; windows/tauri/src/extensions/packages/local-extension-package.ts; scripts/verify-windows-plugin-isolation.mjs php-run-test,语言支持,PHP,PHP 插件运行与 PHPUnit 测试,已实现,待验证,部分实现,待验证,PHP Support,macOS 运行 PHP 文件和单条/整套 PHPUnit;Windows 运行字符串/数组 Composer script 和整套 PHPUnit。禁用后菜单消失、运行进程退出,特殊文件名保持原样。Windows 暂不支持单方法发现。,,Plugins/mac/Official/PhpSupport/Sources/LithePhpSupportModule/Capabilities/PhpExecutionCapability.swift; macos/Tests/LitheTests/RealPhpIntegrationTests.swift,Plugins/win/Official/PhpSupport/plugin.ts; windows/tauri/src/extensions/run; windows/tauri/src/extensions/ui/services/ui-extension-worker-runtime.test.ts +git-workspace-staged-commit,版本控制,Git,按文件所属仓库批量提交及推送,保留每仓库结果,已实现,待验证,已实现,待验证,Git,勾选两个独立仓库文件,一次提交并推送,验证各自 HEAD 和远程;停止一个操作后其余独立仓库继续,已成功仓库不会回滚。,两端直接消费共享 Rust Core 的计划、依赖排序、执行结果与重试。平台负责真实暂存勾选、确认与结果界面、认证取消和项目生命周期。原生界面实测待平台验收。,macos/Sources/LitheGitModule/Application/GitFeatureModel+WorkspaceCommit.swift; macos/Sources/Lithe/Views/Git/CommitAreaView.swift; macos/Tests/LitheGitModuleTests/GitModuleTests.swift; rust/lithe-core/src/git/commit_state.rs; rust/lithe-core/src/tests/git_workspace_commit.rs; rust/lithe-core/src/git/workspace_commit.rs; rust/lithe-core/src/git/workspace_commit/tests.rs; shared/fixtures/git/workspace-commit-workflow-v1.json,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; windows/tauri/src/features/git/runtime/git-workspace-commit-host.tsx; windows/tauri/src/features/git/runtime/git-workspace-commit-host.test.tsx +git-submodule-commit-plan,版本控制,Git,子模块先提交与推送、自动更新父引用、计划变化再次确认,已实现,待验证,已实现,待验证,Git,只勾选孙仓库文件,确认计划自动列出父祖仓库的引用更新且可关闭;确认期间改变暂存内容或分支,必须显示新计划再次确认。父仓库未暂存文件不能被带入;只勾选父引用时先推送子仓库现有提交,Git 发布检查应拒绝未发布的子引用。子仓库元数据被外部删除后必须拒绝读取和写入,不能向上回退父仓库。,两端直接消费共享 Rust Core 的计划、依赖排序、执行结果与重试。平台负责真实暂存勾选、确认与结果界面、认证取消和项目生命周期。原生界面实测待平台验收。,macos/Sources/LitheGitModule/Application/GitFeatureModel+WorkspaceCommit.swift; macos/Sources/Lithe/Views/Git/CommitAreaView.swift; macos/Tests/LitheGitModuleTests/GitModuleTests.swift; rust/lithe-core/src/git/commit_state.rs; rust/lithe-core/src/tests/git_workspace_commit.rs; rust/lithe-core/src/git/workspace_commit.rs; rust/lithe-core/src/git/workspace_commit/tests.rs; shared/fixtures/git/workspace-commit-workflow-v1.json,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; windows/tauri/src/features/git/runtime/git-workspace-commit-host.tsx; windows/tauri/src/features/git/runtime/git-workspace-commit-host.test.tsx +git-workspace-commit-retry,版本控制,Git,失败后只重试未完成的提交或推送步骤,已实现,待验证,已实现,待验证,Git,让子仓库推送失败,确认父仓库被阻塞、独立仓库成功;重试计划应显示子仓库只推送,验证子仓库提交数不增加,随后才提交和推送父仓库。,两端直接消费共享 Rust Core 的计划、依赖排序、执行结果与重试。平台负责真实暂存勾选、确认与结果界面、认证取消和项目生命周期。原生界面实测待平台验收。,macos/Sources/LitheGitModule/Application/GitFeatureModel+WorkspaceCommit.swift; macos/Sources/Lithe/Views/Git/CommitAreaView.swift; macos/Tests/LitheGitModuleTests/GitModuleTests.swift; rust/lithe-core/src/git/commit_state.rs; rust/lithe-core/src/tests/git_workspace_commit.rs; rust/lithe-core/src/git/workspace_commit.rs; rust/lithe-core/src/git/workspace_commit/tests.rs; shared/fixtures/git/workspace-commit-workflow-v1.json,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; windows/tauri/src/features/git/runtime/git-workspace-commit-host.tsx; windows/tauri/src/features/git/runtime/git-workspace-commit-host.test.tsx diff --git a/docs/development/platform-parity-matrix.md b/docs/development/platform-parity-matrix.md index e55e4495b..2c9bb9b60 100644 --- a/docs/development/platform-parity-matrix.md +++ b/docs/development/platform-parity-matrix.md @@ -2,11 +2,11 @@ > 本页由 `shared/platform-feature-matrix.json` 自动生成。不要直接编辑本文件;新增或变更功能时更新源数据,再运行 `node scripts/generate-platform-feature-matrix.mjs`。 -- 最后复核:2026-09-25 +- 最后复核:2026-09-27 - 盘点状态:initial-static-inventory(根据 macOS Views/Application/Services、Windows features/extensions 和共享契约的代码入口进行初版盘点;未替代真实运行验收。) -- 功能项:94 -- macOS:实现:✅ 81 已实现,🟡 3 部分实现,❌ 7 未实现,🧩 3 平台专属;验证:✔️ 0 已验证,🔍 84 待验证,— 10 不适用 -- Windows:实现:✅ 82 已实现,🟡 8 部分实现,❌ 4 未实现,🧩 0 平台专属;验证:✔️ 0 已验证,🔍 90 待验证,— 4 不适用 +- 功能项:101 +- macOS:实现:✅ 88 已实现,🟡 3 部分实现,❌ 7 未实现,🧩 3 平台专属;验证:✔️ 0 已验证,🔍 91 待验证,— 10 不适用 +- Windows:实现:✅ 87 已实现,🟡 10 部分实现,❌ 4 未实现,🧩 0 平台专属;验证:✔️ 0 已验证,🔍 97 待验证,— 4 不适用 ## 实现状态定义 @@ -30,7 +30,7 @@ > 每一行对应一个可以单独验收的用户能力;区域和功能组只用于导航,不作为状态统计单位。单元格第一行是实现状态,第二行是验证状态。
-AI · 11 个能力点 +AI · 13 个能力点 | 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | | --- | --- | --- | --- | --- | --- | --- | @@ -41,6 +41,8 @@ | Agent 对话 | **从 Finder 或项目树拖入文件到 Agent 输入框,文件选择器、可移除标签、多文件去重和数量限制、文件引用随消息发送及失败保留草稿**
agent-file-references | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Agent/AgentComposerView.swift`、`macos/Sources/Lithe/Views/Agent/AgentFileReferenceList.swift`、`macos/Sources/LitheAgentConversationModule/Application/AgentFileReference.swift`、`macos/Tests/LitheTests/AgentFileReferenceTests.swift`、`macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift`、`rust/lithe-agent-host/src/tests.rs` | 🟡 部分实现
🔍 待验证
`rust/lithe-agent-host/src/prompt.rs`、`shared/fixtures/agent/acp-events-v1.json` | Agent | macOS:从 Finder、项目树拖入临时文件(多文件、中文/空格名)到输入区与上下文栏,检查高亮和可移除标签、去重、数量限制、会话切换和发送失败保留草稿;用隔离项目验证文字+文件与纯文件发送、历史加载后引用正确送到原会话、Agent 读取与权限流程。图片按文件引用处理,无多模态上传。Windows 原生拖放和 Claude 端到端待验证。 | | | Agent 对话 | **Agent 管理:面板内设置、Node.js/npm 检测、预检清单、ACP 适配器与 Agent CLI 一键安装或升级、一键获取本机 CLI 配置、实时下载数据量/速度/耗时与等待提示、按 CLI 安装来源更新、验证实际升级结果并区分成功警告与失败**
agent-management | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Agent/AgentPanelSettingsView.swift`、`macos/Sources/Lithe/Application/Features/AgentManagementFeatureModel.swift`、`rust/lithe-agent-host/src/install.rs`、`rust/lithe-core/src/agent/mod.rs`、`rust/lithe-agent-host/src/npm-progress.mjs`、`rust/lithe-agent-host/src/cli_update.rs`、`macos/Tests/LitheTests/AgentManagementFeatureModelTests.swift` | 🟡 部分实现
🔍 待验证
`rust/lithe-core/src/agent/mod.rs` | Agent | 在 macOS 打开 Agent 面板右上角的设置:确认显示 Node.js 与 npm 版本,Node 缺失或版本过低时只提示;一键安装、取消安装、更新与卸载 Codex 适配器;CLI 过旧时确认显示 npm/Homebrew/原生来源并通过原安装器升级,npm 环境不匹配或未知来源时只显示手动指引,升级后确认实际 PATH 中的版本达到要求;模拟安装器先下载失败再重试成功但返回非零,确认版本确实提升时显示成功与折叠警告日志,版本未变/仍旧/丢失、取消和超时继续按原语义处理;确认下载数据量、速度、耗时实时更新且未知总量不显示百分比,取消/失败/完成后进度清除;一键获取本机 Codex 配置后在 Agent 面板对话,且提交信息所用服务商不变;Windows 待接入设置界面。 | | | Agent 对话 | **Agent 供应商管理:读取本机配置、自定义 Codex/Claude 配置文本、添加与编辑、安全保存密钥、启用与重连、确认删除及取消关联**
agent-provider-configuration | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Agent/AgentProviderConfigurationView.swift`、`macos/Sources/Lithe/Platform/MacOS/UI/MacConfigurationTextEditor.swift`、`macos/Sources/Lithe/Application/Features/AgentProviderConfiguration.swift`、`macos/Sources/Lithe/Models/AppModel/AppModel+AgentProviders.swift`、`macos/Tests/LitheTests/AgentProviderConfigurationTests.swift`、`macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift`、`macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift` | 🟡 部分实现
🔍 待验证
`rust/lithe-core/src/ai/agent_configuration.rs`、`shared/fixtures/agent/provider-configuration-v1.json` | Agent | macOS:验证本机 Codex/Claude 配置读取、刷新与取消关联;添加自定义 TOML/auth JSON 或 Claude settings JSON,检查格式化、输入直引号不被自动替换、弯引号 TOML 错误在保存按钮上方始终可见、非法配置和缺失密钥、取消不保存、编辑保持 ID、删除确认及安全存储失败不丢失配置。在一个窗口保存并等待连接关闭期间,由另一窗口删除或修改同一供应商,确认旧保存失败且不恢复/覆盖供应商、密钥或 Agent 关联。检查窄宽面板、深浅主题、中英文本。空闲切换后确认请求到所选地址和模型;未发送空会话切换或意外退出后重新创建,不向旧 ID 加载,首条消息与文件只发送一次;已有消息、上游列出或成功加载的会话继续恢复,历史加载失败保留记录;正在响应、加载、权限待处理或配置确认时拒绝切换。提交服务商选择不被添加/启用改变。CLI 文件与 bundle 不被写入。Windows 编辑器及 Claude 真实请求待验证。 | | +| Agent 对话 | **Codex 供应商选择官方订阅:复用本机账号、显式浏览器登录与取消、API Key 模式切换及旧配置兼容**
agent-codex-subscription | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Agent/AgentProviderConfigurationView.swift`、`macos/Sources/Lithe/Models/AppModel/AppModel+AgentProviders.swift`、`macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift`、`macos/Tests/LitheTests/AgentProviderConfigurationTests.swift`、`rust/lithe-agent-host/src/subscription.rs` | 🟡 部分实现
🔍 待验证
`rust/lithe-agent-host/src/lib.rs`、`shared/fixtures/agent/acp-events-v1.json` | Agent | macOS:已有 Codex ChatGPT 登录直接对话;未登录只显示登录按钮,点击才打开浏览器,取消与超时清理进程;API Key 与订阅空闲切换后验证实际计费来源,不携带旧 key/URL/模型、不改提交服务商;忙碌时拒绝切换,账号变化后断开;老配置仍走 API Key,Claude 不显示订阅选项。真实账号与 macOS UI 待验证;Windows 仅共享 host 已实现,UI 待接入。 | | +| Agent 对话 | **Codex 订阅额度:上下文右侧紧凑显示、可见时定时刷新、悬停多窗口与重置时间、未知与过期状态及账号隔离**
agent-subscription-quota | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Agent/AgentSubscriptionQuotaView.swift`、`macos/Sources/LitheAgentConversationModule/Application/AgentSubscriptionQuota.swift`、`macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift`、`rust/lithe-agent-host/src/subscription/tests.rs` | 🟡 部分实现
🔍 待验证
`rust/lithe-agent-host/src/subscription.rs`、`shared/fixtures/agent/acp-events-v1.json` | Agent | macOS:订阅会话上下文右侧显示额度,悬停查看窗口时长/已用百分比/重置和更新时间;真实账号验证可见且活跃时每分钟刷新、隐藏不轮询、单连接不并发探测,查询不创建会话或模型请求;周窗口作为 primary 仍显示 7d,缺失不报 0%;网络失败保留并灰显旧值,账号不匹配清除;API Key 模式不查询。窄宽面板、深浅主题、关闭面板/项目和取消时确认探测进程清理。Windows UI 与真实账号验证待完成。 | | | AI 提交信息 | **Provider 配置与提交信息生成**
ai-commit-generation | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Application/Features/CommitWorkflowCoordinator.swift`、`macos/Sources/Lithe/Platform/MacOS/AI` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git/services/ai-commit-service.ts`、`shared/contracts/ai-commit.md` | Git / AI | 用相同 diff、规则和 Provider 配置比较请求计划、取消、错误和生成文本。 | | | AI 对话 | **对话会话与流式响应**
ai-chat-session | ❌ 未实现
— 不适用
`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 | Windows 验证新建会话、流式输出、取消和失败恢复;macOS 需要先定义产品范围。 | | | AI 对话 | **对话历史、置顶与归档**
ai-chat-history | ❌ 未实现
— 不适用
`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 | Windows 验证历史加载、重命名、置顶、归档、删除和重启后持久化。 | | @@ -64,11 +66,12 @@
-编辑器 · 12 个能力点 +编辑器 · 13 个能力点 | 功能组 | 能力点 | 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-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 和普通文本,确认语言识别、语法高亮和模型切换;开启缩略图并滚动,确认滚动条轨道不透出编辑器代码。 | | | 文本编辑 | **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 | 打开足够多文件触发多行标签,确认滚动、切换、关闭和活动文件保持。 | | @@ -96,17 +99,21 @@
-版本控制 · 7 个能力点 +版本控制 · 11 个能力点 | 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | | --- | --- | --- | --- | --- | --- | --- | | 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 | **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 弹窗可选择远程分支与策略。 | | +| Git | **按文件所属仓库批量提交及推送,保留每仓库结果**
git-workspace-staged-commit | ✅ 已实现
🔍 待验证
`macos/Sources/LitheGitModule/Application/GitFeatureModel+WorkspaceCommit.swift`、`macos/Sources/Lithe/Views/Git/CommitAreaView.swift`、`macos/Tests/LitheGitModuleTests/GitModuleTests.swift`、`rust/lithe-core/src/git/commit_state.rs`、`rust/lithe-core/src/tests/git_workspace_commit.rs`、`rust/lithe-core/src/git/workspace_commit.rs`、`rust/lithe-core/src/git/workspace_commit/tests.rs`、`shared/fixtures/git/workspace-commit-workflow-v1.json` | ✅ 已实现
🔍 待验证
`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`、`windows/tauri/src/features/git/runtime/git-workspace-commit-host.tsx`、`windows/tauri/src/features/git/runtime/git-workspace-commit-host.test.tsx` | Git | 勾选两个独立仓库文件,一次提交并推送,验证各自 HEAD 和远程;停止一个操作后其余独立仓库继续,已成功仓库不会回滚。 | 两端直接消费共享 Rust Core 的计划、依赖排序、执行结果与重试。平台负责真实暂存勾选、确认与结果界面、认证取消和项目生命周期。原生界面实测待平台验收。 | +| Git | **子模块先提交与推送、自动更新父引用、计划变化再次确认**
git-submodule-commit-plan | ✅ 已实现
🔍 待验证
`macos/Sources/LitheGitModule/Application/GitFeatureModel+WorkspaceCommit.swift`、`macos/Sources/Lithe/Views/Git/CommitAreaView.swift`、`macos/Tests/LitheGitModuleTests/GitModuleTests.swift`、`rust/lithe-core/src/git/commit_state.rs`、`rust/lithe-core/src/tests/git_workspace_commit.rs`、`rust/lithe-core/src/git/workspace_commit.rs`、`rust/lithe-core/src/git/workspace_commit/tests.rs`、`shared/fixtures/git/workspace-commit-workflow-v1.json` | ✅ 已实现
🔍 待验证
`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`、`windows/tauri/src/features/git/runtime/git-workspace-commit-host.tsx`、`windows/tauri/src/features/git/runtime/git-workspace-commit-host.test.tsx` | Git | 只勾选孙仓库文件,确认计划自动列出父祖仓库的引用更新且可关闭;确认期间改变暂存内容或分支,必须显示新计划再次确认。父仓库未暂存文件不能被带入;只勾选父引用时先推送子仓库现有提交,Git 发布检查应拒绝未发布的子引用。子仓库元数据被外部删除后必须拒绝读取和写入,不能向上回退父仓库。 | 两端直接消费共享 Rust Core 的计划、依赖排序、执行结果与重试。平台负责真实暂存勾选、确认与结果界面、认证取消和项目生命周期。原生界面实测待平台验收。 | +| Git | **失败后只重试未完成的提交或推送步骤**
git-workspace-commit-retry | ✅ 已实现
🔍 待验证
`macos/Sources/LitheGitModule/Application/GitFeatureModel+WorkspaceCommit.swift`、`macos/Sources/Lithe/Views/Git/CommitAreaView.swift`、`macos/Tests/LitheGitModuleTests/GitModuleTests.swift`、`rust/lithe-core/src/git/commit_state.rs`、`rust/lithe-core/src/tests/git_workspace_commit.rs`、`rust/lithe-core/src/git/workspace_commit.rs`、`rust/lithe-core/src/git/workspace_commit/tests.rs`、`shared/fixtures/git/workspace-commit-workflow-v1.json` | ✅ 已实现
🔍 待验证
`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`、`windows/tauri/src/features/git/runtime/git-workspace-commit-host.tsx`、`windows/tauri/src/features/git/runtime/git-workspace-commit-host.test.tsx` | Git | 让子仓库推送失败,确认父仓库被阻塞、独立仓库成功;重试计划应显示子仓库只推送,验证子仓库提交数不增加,随后才提交和推送父仓库。 | 两端直接消费共享 Rust Core 的计划、依赖排序、执行结果与重试。平台负责真实暂存勾选、确认与结果界面、认证取消和项目生命周期。原生界面实测待平台验收。 |
diff --git a/macos/EditorFrontend/main.ts b/macos/EditorFrontend/main.ts index 71c8cf2a6..1b0dcbafa 100644 --- a/macos/EditorFrontend/main.ts +++ b/macos/EditorFrontend/main.ts @@ -9,9 +9,13 @@ import failedIcon from "../Resources/IDEAIcons/testState/red2.svg" with { type: 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"; declare global { interface Window { webkit: any; MonacoEnvironment: any; lithe: any; } } +const mouseInput = installWebKitMouseInput(document); +window.addEventListener("beforeunload", () => mouseInput.dispose(), { once: true }); + const workbench = mountWorkbench({ request: payload => window.webkit.messageHandlers.litheEditor.postMessage(payload), palette, diff --git a/macos/EditorFrontend/mouse-input.ts b/macos/EditorFrontend/mouse-input.ts new file mode 100644 index 000000000..85fe2b471 --- /dev/null +++ b/macos/EditorFrontend/mouse-input.ts @@ -0,0 +1,15 @@ +/** Correct WebKit's released-button mousedown before Monaco starts pointer tracking. */ +export function installWebKitMouseInput(document: Document): { dispose(): void } { + const mouseDown = (event: MouseEvent) => { + if (event.buttons !== 0 || (event.button !== 0 && event.button !== 1) || + !(event.target instanceof Element) || !event.target.closest(".monaco-editor")) return; + + // Some IMEs reorder tap-to-click's mouseup before mousedown (WebKit #219670). + // Monaco 0.55.1 compares later pointermove.buttons with this initial mask; + // starting at zero makes unpressed hover movements extend the selection. + // Keep the original event, click count, modifiers and upstream selection logic. + Object.defineProperty(event, "buttons", { value: event.button === 0 ? 1 : 4 }); + }; + document.addEventListener("mousedown", mouseDown, true); + return { dispose: () => document.removeEventListener("mousedown", mouseDown, true) }; +} diff --git a/macos/Experiments/Monaco/mouse-input.integration.ts b/macos/Experiments/Monaco/mouse-input.integration.ts new file mode 100644 index 000000000..fc068babe --- /dev/null +++ b/macos/Experiments/Monaco/mouse-input.integration.ts @@ -0,0 +1,151 @@ +import { editor as monacoEditor, Range } from "monaco-editor/esm/vs/editor/editor.api.js"; +import { installWebKitMouseInput } from "../../EditorFrontend/mouse-input"; + +const source = "alpha beta gamma delta\nsecond line with words\nthird line for selection"; +const assert = (condition: unknown, message: string) => { if (!condition) throw new Error(message); }; +type Emit = (type: string, line: number, column: number, buttons: number, options?: MouseEventInit) => MouseEvent; + +function withEditor(run: (view: monacoEditor.IStandaloneCodeEditor, emit: Emit) => void) { + const container = document.createElement("div"); + container.style.cssText = "position:fixed;inset:0;width:800px;height:400px;z-index:10000"; + document.body.append(container); + const model = monacoEditor.createModel(source, "plaintext"); + const view = monacoEditor.create(container, { + model, fontSize: 16, minimap: { enabled: false }, scrollBeyondLastLine: false, + }); + const node = view.getDomNode()!; + try { + view.render(true); + const emit: Emit = (type, lineNumber, column, buttons, options = {}) => { + const position = view.getScrolledVisiblePosition({ lineNumber, column })!; + const bounds = node.getBoundingClientRect(); + const clientX = bounds.left + position.left + 1; + const clientY = bounds.top + position.top + position.height / 2; + const target = document.elementFromPoint(clientX, clientY); + assert(target && node.contains(target), "mouse fixture missed its editor"); + const init: PointerEventInit = { + bubbles: true, cancelable: true, view: window, clientX, clientY, + button: 0, buttons, detail: 1, pointerId: 1, pointerType: "mouse", isPrimary: true, ...options, + }; + const event = type.startsWith("pointer") ? new PointerEvent(type, init) : new MouseEvent(type, init); + target!.dispatchEvent(event); + return event; + }; + run(view, emit); + } finally { + // Synthetic input cannot acquire native capture. Release both possible + // monitoring targets before disposing, including after failed assertions. + node.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerId: 1, pointerType: "mouse" })); + view.dispose(); model.dispose(); container.remove(); + } +} + +function releasedTap(emit: Emit, line: number, column: number, detail = 1) { + // WebKit #219670: an IME can dispatch the release before the press. Unlike + // an ordinary click, the late mousedown reports no currently pressed buttons. + emit("pointerup", line, column, 0); + emit("mouseup", line, column, 0); + emit("pointerdown", line, column, 0); + return emit("mousedown", line, column, 0, { detail }); +} + +function click(emit: Emit, line: number, column: number, options: MouseEventInit = {}) { + emit("pointerdown", line, column, 1, options); + emit("mousedown", line, column, 1, options); + emit("pointerup", line, column, 0, options); + emit("mouseup", line, column, 0, options); +} + +function expectCaret(view: monacoEditor.IStandaloneCodeEditor, line: number, column: number) { + assert(view.getSelection()!.equalsRange(new Range(line, column, line, column)), "unpressed movement extended the caret"); +} + +export const mouseInputCases: { name: string; run(): void }[] = [ + { + name: "IME reordered taps remain independent clicks during unpressed movement", + run: () => withEditor((view, emit) => { + releasedTap(emit, 1, 3); + expectCaret(view, 1, 3); + emit("pointermove", 3, 12, 0); + expectCaret(view, 1, 3); + releasedTap(emit, 2, 5, 2); + expectCaret(view, 2, 5); + emit("pointermove", 1, 18, 0); + expectCaret(view, 2, 5); + assert(view.getValue() === source, "clicks modified text"); + }), + }, + { + name: "IME reordered click on selected text cannot drag it on hover", + run: () => withEditor((view, emit) => { + view.setSelection(new Range(1, 1, 1, 6)); + releasedTap(emit, 1, 3); + emit("pointermove", 3, 12, 0); + emit("pointerup", 3, 12, 0); + assert(view.getValue() === source, "unpressed movement relocated selected text"); + }), + }, + { + name: "pressed drag still selects and stops when released", + run: () => withEditor((view, emit) => { + emit("pointerdown", 1, 3, 1); + emit("mousedown", 1, 3, 1); + emit("pointermove", 3, 12, 1); + assert(view.getSelection()!.equalsRange(new Range(1, 3, 3, 12)), "normal drag selection changed"); + emit("pointerup", 3, 12, 0); + emit("mouseup", 3, 12, 0); + emit("pointermove", 1, 18, 0); + assert(view.getSelection()!.equalsRange(new Range(1, 3, 3, 12)), "released drag kept selecting"); + }), + }, + { + name: "ordinary and reordered double clicks retain word selection", + run: () => withEditor((view, emit) => { + click(emit, 1, 8); + click(emit, 1, 8, { detail: 2 }); + assert(view.getSelection()!.equalsRange(new Range(1, 7, 1, 11)), "ordinary double click lost word selection"); + releasedTap(emit, 2, 10); + releasedTap(emit, 2, 10, 2); + assert(view.getSelection()!.equalsRange(new Range(2, 8, 2, 12)), "reordered double click lost word selection"); + emit("pointermove", 3, 12, 0); + assert(view.getSelection()!.equalsRange(new Range(2, 8, 2, 12)), "double click kept selecting after release"); + }), + }, + { + name: "shift click continues extending an existing selection", + run: () => withEditor((view, emit) => { + click(emit, 1, 3); + click(emit, 2, 10, { shiftKey: true }); + assert(view.getSelection()!.equalsRange(new Range(1, 3, 2, 10)), "shift click lost its anchor"); + }), + }, + { + name: "WebKit normalization preserves event identity and releases its listener", + run: () => { + // A separate document isolates lifecycle checks from the production host's listener. + const isolated = document.implementation.createHTMLDocument(); + const node = isolated.createElement("div"); + isolated.body.append(node); + const input = installWebKitMouseInput(isolated); + const event = (buttons: number, button = 0) => new MouseEvent("mousedown", { + bubbles: true, buttons, button, detail: 2, shiftKey: true, metaKey: true, clientX: 17, clientY: 23, + }); + try { + const outside = event(0); node.dispatchEvent(outside); + assert(outside.buttons === 0, "non-editor control was changed"); + node.className = "monaco-editor"; + const left = event(0); node.dispatchEvent(left); + assert(left.buttons === 1 && left.detail === 2 && left.shiftKey && left.metaKey && + left.clientX === 17 && left.clientY === 23, "normalization replaced click semantics"); + const middle = event(0, 1); node.dispatchEvent(middle); + assert(middle.buttons === 4, "middle button used the wrong mask"); + const right = event(0, 2); node.dispatchEvent(right); + assert(right.buttons === 0, "context menu press was changed"); + const held = event(5); node.dispatchEvent(held); + assert(held.buttons === 5, "existing button combination was changed"); + } finally { input.dispose(); } + const after = event(0); node.dispatchEvent(after); + assert(after.buttons === 0, "disposed listener still normalized input"); + }, + }, +]; diff --git a/macos/Experiments/Monaco/workbench.integration.ts b/macos/Experiments/Monaco/workbench.integration.ts index b8d344a39..5eaec4160 100644 --- a/macos/Experiments/Monaco/workbench.integration.ts +++ b/macos/Experiments/Monaco/workbench.integration.ts @@ -12,6 +12,7 @@ import { CancellationTokenSource, CancellationToken } from "monaco-editor/esm/vs 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"; // Real WebKit integration, using the exact workbench bundle and the existing // bounded native probe host. No DOM-based imitation of Monaco input. @@ -31,6 +32,7 @@ async function verify() { await operation(); cases.push({ name, durationMs: performance.now() - started }); } + for (const test of mouseInputCases) 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"); diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 26547b07d..329b2bc59 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -1230,3 +1230,64 @@ "Context usage" = "Context usage"; "Context: %@" = "Context: %@"; "%@ · %@ / %@ context tokens" = "%@ · %@ / %@ context tokens"; + +/* Workspace repository commits */ +"Review remaining steps" = "Review remaining steps"; +"Review repository commits" = "Review repository commits"; +"Each repository has its own commit. Completed steps are kept if another repository fails." = "Each repository has its own commit. Completed steps are kept if another repository fails."; +"Review and Retry Unfinished Steps…" = "Review and Retry Unfinished Steps…"; +"Dismiss Results" = "Dismiss Results"; +"Update parent repository references" = "Update parent repository references"; +"Each submodule is pushed before its parent." = "Each submodule is pushed before its parent."; +"Uncommitted submodule changes" = "Uncommitted submodule changes"; +"Commit changed files in the submodule first" = "Commit changed files in the submodule first"; +"Amend applies to repositories with selected files." = "Amend applies to repositories with selected files."; +"Commit message: %@" = "Commit message: %@"; +"Push only" = "Push only"; +"Commit and push" = "Commit and push"; +"Committed; push pending" = "Committed; push pending"; +"Committed and pushed" = "Committed and pushed"; +"Waiting for submodule" = "Waiting for submodule"; +"Update %@/%@ after %@" = "Update %@/%@ after %@"; +"Repository changed; review and retry" = "Repository changed; review and retry"; +"Committed" = "Committed"; +"Collapse repository" = "Collapse repository"; +"Expand repository" = "Expand repository"; +"Unstage all files in repository" = "Unstage all files in repository"; +"Stage all files in repository" = "Stage all files in repository"; +"Pending" = "Pending"; +"Not included in the updated plan" = "Not included in the updated plan"; +"Committed; push failed" = "Committed; push failed"; +"HEAD advanced; review before continuing." = "HEAD advanced; review before continuing."; +"Could not verify the commit outcome. Review before retrying." = "Could not verify the commit outcome. Review before retrying."; +"Repository needs attention" = "Repository needs attention"; + +"Codex subscription" = "Codex subscription"; + +"Use your local ChatGPT account. No API key or URL is needed." = "Use your local ChatGPT account. No API key or URL is needed."; + +"Sign in to Codex" = "Sign in to Codex"; + +"Sign in with ChatGPT" = "Sign in with ChatGPT"; + +"Waiting for ChatGPT sign-in…" = "Waiting for ChatGPT sign-in…"; + +"Complete sign-in in your browser. Your credentials are managed by Codex." = "Complete sign-in in your browser. Your credentials are managed by Codex."; + +"%@ · %@ used" = "%@ · %@ used"; + +"Quota —" = "Quota —"; + +"Codex subscription quota" = "Codex subscription quota"; + +"Resets: %@" = "Resets: %@"; + +"Updated: %@" = "Updated: %@"; + +"The local Codex account changed. Reconnect to refresh quota." = "The local Codex account changed. Reconnect to refresh quota."; + +"Quota is temporarily unavailable. The last value may be out of date." = "Quota is temporarily unavailable. The last value may be out of date."; + +"Waiting for subscription quota…" = "Waiting for subscription quota…"; + +"ChatGPT sign-in was cancelled." = "ChatGPT sign-in was cancelled."; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 6c80e14da..0ab01e710 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -2458,3 +2458,64 @@ "Context usage" = "上下文用量"; "Context: %@" = "上下文: %@"; "%@ · %@ / %@ context tokens" = "%@ · %@ / %@ 上下文 token"; + +/* Workspace repository commits */ +"Review remaining steps" = "确认剩余步骤"; +"Review repository commits" = "确认各仓库提交"; +"Each repository has its own commit. Completed steps are kept if another repository fails." = "每个仓库会分别提交。某个仓库失败时,已完成的步骤仍会保留。"; +"Review and Retry Unfinished Steps…" = "查看并重试未完成的步骤…"; +"Dismiss Results" = "清除结果"; +"Update parent repository references" = "更新父仓库引用"; +"Each submodule is pushed before its parent." = "先推送子模块,再推送父仓库。"; +"Uncommitted submodule changes" = "子模块有未提交的修改"; +"Commit changed files in the submodule first" = "请先在子模块中提交修改的文件"; +"Amend applies to repositories with selected files." = "修订提交仅作用于有已选文件的仓库。"; +"Commit message: %@" = "提交说明:%@"; +"Push only" = "仅推送"; +"Commit and push" = "提交并推送"; +"Committed; push pending" = "已提交,等待推送"; +"Committed and pushed" = "已提交并推送"; +"Waiting for submodule" = "等待子模块完成"; +"Update %@/%@ after %@" = "在 %@/%@ 更新引用,等待 %@ 完成"; +"Repository changed; review and retry" = "仓库状态已改变,请检查并重试"; +"Committed" = "已提交"; +"Collapse repository" = "折叠仓库"; +"Expand repository" = "展开仓库"; +"Unstage all files in repository" = "取消暂存仓库的所有文件"; +"Stage all files in repository" = "暂存仓库的所有文件"; +"Pending" = "等待执行"; +"Not included in the updated plan" = "未包含在更新后的计划中"; +"Committed; push failed" = "已提交,推送失败"; +"HEAD advanced; review before continuing." = "提交记录已推进,请检查后再继续。"; +"Could not verify the commit outcome. Review before retrying." = "无法确认提交结果,请检查后再重试。"; +"Repository needs attention" = "仓库需要处理"; + +"Codex subscription" = "Codex 官方订阅(本机账号)"; + +"Use your local ChatGPT account. No API key or URL is needed." = "使用本机已登录的 ChatGPT 账号,无需 API Key 或 URL。"; + +"Sign in to Codex" = "登录 Codex"; + +"Sign in with ChatGPT" = "登录 ChatGPT"; + +"Waiting for ChatGPT sign-in…" = "正在等待 ChatGPT 登录…"; + +"Complete sign-in in your browser. Your credentials are managed by Codex." = "请在浏览器中完成登录,凭据由 Codex 管理。"; + +"%@ · %@ used" = "%@ · 已用 %@"; + +"Quota —" = "额度 —"; + +"Codex subscription quota" = "Codex 订阅额度"; + +"Resets: %@" = "重置时间:%@"; + +"Updated: %@" = "更新时间:%@"; + +"The local Codex account changed. Reconnect to refresh quota." = "本机 Codex 账号已变更,请重新连接以刷新额度。"; + +"Quota is temporarily unavailable. The last value may be out of date." = "暂时无法更新额度,上次读数可能已过期。"; + +"Waiting for subscription quota…" = "正在等待订阅额度…"; + +"ChatGPT sign-in was cancelled." = "已取消 ChatGPT 登录。"; diff --git a/macos/Sources/Lithe/Application/Composition/CommitWorkflowComposition.swift b/macos/Sources/Lithe/Application/Composition/CommitWorkflowComposition.swift index 8f76ee7b5..fa86891a4 100644 --- a/macos/Sources/Lithe/Application/Composition/CommitWorkflowComposition.swift +++ b/macos/Sources/Lithe/Application/Composition/CommitWorkflowComposition.swift @@ -3,8 +3,12 @@ import LitheGitModule import LitheModuleAPI extension GitFeatureModel: CommitWorkflowGit { + var pendingCommitDraft: (message: String, amend: Bool)? { + pendingSubmoduleCommitPlan.map { ($0.message, $0.amend) } + } + var stagedChangeIDs: Set { - Set(activeRepositoryChanges.filter(\.isStaged).map(\.id)) + Set(gitChanges.filter(\.isStaged).map(\.id)) } } diff --git a/macos/Sources/Lithe/Application/Features/CommitWorkflowCoordinator.swift b/macos/Sources/Lithe/Application/Features/CommitWorkflowCoordinator.swift index a4fa2ad48..425d0ae06 100644 --- a/macos/Sources/Lithe/Application/Features/CommitWorkflowCoordinator.swift +++ b/macos/Sources/Lithe/Application/Features/CommitWorkflowCoordinator.swift @@ -4,10 +4,16 @@ import LitheCoreContracts /// The Git operations needed by the application-owned commit workflow. @MainActor protocol CommitWorkflowGit: AnyObject { + /// Staged changes from every repository discovered in the workspace. var stagedChangeIDs: Set { get } + var pendingCommitDraft: (message: String, amend: Bool)? { get } func stagedCommitMessageInput() async -> CommitMessageInput? + /// Commits each repository's staged changes independently while sharing the + /// commit message. The Git feature owns repository grouping and failure + /// reporting so the coordinator remains unaware of Git's storage model. func commitStagedChanges(message: String, amend: Bool) async -> Bool func commitAndPushStagedChanges(message: String, amend: Bool) async -> Bool + func confirmPendingSubmoduleCommit() async -> Bool } /// Coordinates submission and AI generation without owning Git or AI services. @@ -35,6 +41,14 @@ final class CommitWorkflowCoordinator { } func commit(push: Bool = false) async { + await submitCommit(push: push, confirmSubmodulePlan: false) + } + + func confirmPendingSubmoduleCommit() async { + await submitCommit(push: false, confirmSubmodulePlan: true) + } + + private func submitCommit(push: Bool, confirmSubmodulePlan: Bool) async { guard !isSubmitting else { return } isSubmitting = true defer { isSubmitting = false } @@ -43,14 +57,19 @@ final class CommitWorkflowCoordinator { let amend = draft.amend guard let git = await activateGit(), generation == workspaceGeneration(), !Task.isCancelled else { return } - let succeeded = if push { - await git.commitAndPushStagedChanges(message: message, amend: amend) + let submittedMessage = confirmSubmodulePlan ? git.pendingCommitDraft?.message ?? message : message + let submittedAmend = confirmSubmodulePlan ? git.pendingCommitDraft?.amend ?? amend : amend + let succeeded: Bool + if confirmSubmodulePlan { + succeeded = await git.confirmPendingSubmoduleCommit() + } else if push { + succeeded = await git.commitAndPushStagedChanges(message: message, amend: amend) } else { - await git.commitStagedChanges(message: message, amend: amend) + succeeded = await git.commitStagedChanges(message: message, amend: amend) } guard succeeded, generation == workspaceGeneration() else { return } // Preserve edits made while Git was running, even after a successful commit. - if draft.message == message && draft.amend == amend { + if draft.message.trimmingCharacters(in: .whitespacesAndNewlines) == submittedMessage.trimmingCharacters(in: .whitespacesAndNewlines) && draft.amend == submittedAmend { draft.clearAfterCommit() } } diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index d2c19e924..f82ab67af 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -1330,6 +1330,8 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { let staged: Bool let worktree: Bool let untracked: Bool + let submodule: GitSubmoduleStatus? + let canToggleStaging: Bool? } struct GitStatusPayload: Decodable, Sendable { @@ -1352,7 +1354,9 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { path: change.path, originalPath: change.originalPath, indexStatus: status.first ?? " ", - workTreeStatus: status.dropFirst().first ?? " " + workTreeStatus: status.dropFirst().first ?? " ", + submodule: change.submodule, + canToggleStaging: change.canToggleStaging ?? true ) } ) @@ -2093,7 +2097,9 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { } private struct GitStatusRequest: Encodable { + let repositoryRoots: [String] let root: String + let includeIndexOnlyChanges = true } private struct WorkspaceRepositoriesRequest: Encodable { @@ -2835,10 +2841,10 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { ) } - func gitStatus(at rootURL: URL) -> GitStatusPayload? { + func gitStatus(at rootURL: URL, repositoryRoots: [URL] = []) -> GitStatusPayload? { execute( command: "git.status", - payload: GitStatusRequest(root: rootURL.standardizedFileURL.path) + payload: GitStatusRequest(repositoryRoots: repositoryRoots.map { $0.standardizedFileURL.path }, root: rootURL.standardizedFileURL.path) ) } @@ -2860,7 +2866,7 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { func gitWorktrees(at rootURL: URL) -> GitWorktreesPayload? { execute( command: "git.worktrees", - payload: GitStatusRequest(root: rootURL.standardizedFileURL.path) + payload: GitStatusRequest(repositoryRoots: [], root: rootURL.standardizedFileURL.path) ) } diff --git a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift index 0555655e3..b460a373c 100644 --- a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift @@ -140,6 +140,19 @@ struct RustGitOperations: GitOperations, Sendable { write(at: change.repositoryRoot, operation: "discardAll", paths: change.pathspecs) } + func prepareWorkspaceCommit(_ request: GitWorkspaceCommitRequest) -> Result { + let result: Result = core.executeResult( + command: "git.workspaceCommitPrepare", payload: request) + return result.mapError { GitWorkspaceCommitFailure($0.userMessage) } + } + + func stepWorkspaceCommit(_ session: GitWorkspaceCommitSession) -> Result { + struct Request: Encodable { let session: GitWorkspaceCommitSession } + let result: Result = core.executeResult( + command: "git.workspaceCommitStep", payload: Request(session: session)) + return result.mapError { GitWorkspaceCommitFailure($0.userMessage) } + } + func commit(at rootURL: URL, message: String, amend: Bool) -> GitProcessResult? { write(at: rootURL, operation: "commit", message: message, amend: amend) } @@ -455,8 +468,10 @@ struct RustGitOperations: GitOperations, Sendable { write(at: rootURL, operation: "deleteTag", name: name) } - func snapshot(at rootURL: URL) -> GitSnapshot? { - core.gitStatus(at: rootURL)?.makeSnapshot(at: rootURL) + func snapshot(at rootURL: URL) -> GitSnapshot? { snapshot(at: rootURL, repositoryRoots: []) } + + func snapshot(at rootURL: URL, repositoryRoots: [URL]) -> GitSnapshot? { + core.gitStatus(at: rootURL, repositoryRoots: repositoryRoots)?.makeSnapshot(at: rootURL) } func repositories(in workspaceURL: URL) -> [URL] { diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+AgentConversation.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+AgentConversation.swift index 1995b9f3b..b41822378 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+AgentConversation.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+AgentConversation.swift @@ -122,13 +122,13 @@ extension AppModel { var configuredAgentOptions: [AgentOption] { settings.agentConfigurations .filter { id, configuration in - configuration.providerID != nil + configuration.isConfigured && (id != AgentConfiguration.customAgentID || !settings.agentCommand.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } .map { id, configuration -> AgentOption in let provider = settings.agentProvider(for: id) - let model = provider?.model.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let model = configuration.authentication == .codexSubscription ? String(localized: "Codex subscription") : (provider?.model.trimmingCharacters(in: .whitespacesAndNewlines) ?? "") return AgentOption(id: id, name: configuration.name, modelName: model.isEmpty ? provider?.name : model) } .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } @@ -164,6 +164,13 @@ extension AppModel { func agentLaunchConfiguration(agentID: String) throws -> AgentLaunchConfiguration { guard let workspaceURL else { throw AgentConversationError.notConnected } + if settings.agentConfigurations[agentID]?.authentication == .codexSubscription { + guard agentID == "codex-acp" else { throw AgentConversationError.missingProvider } + return AgentLaunchConfiguration(agentID: agentID, command: "", arguments: [], + workspaceURL: workspaceURL, dataDirectory: agentDataDirectory, + providerProtocol: "", providerEndpoint: "", apiKey: "", providerName: "", model: "", + allowsInsecureHTTP: false, authentication: .codexSubscription) + } // A saved import is not the current CLI default. Read through the same // configuration ports used for credentials, once at connection startup. settings.refreshAgentModels(from: services.aiConfigurationSources.compactMap { $0.loadModel() }) diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+AgentProviders.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+AgentProviders.swift index d7ff40a2c..67815237d 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+AgentProviders.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+AgentProviders.swift @@ -48,6 +48,16 @@ extension AppModel { connectAgentConversation() } + func useCodexSubscription() async throws { + try beginAgentProviderChange() + defer { finishAgentProviderChange() } + try await stopAgentsForProviderChange(["codex-acp"]) + settings.agentConfigurations["codex-acp"] = AgentConfiguration(name: "Codex", providerID: nil, + authentication: .codexSubscription) + agentConversationFeatureIfActive?.setAgents(configuredAgentOptions) + agentConversationFeatureIfActive?.selectAgent("codex-acp") + } + func useLocalAgentProvider(source: AIConfigurationSourceKind, agentID: String, name: String) async throws { try beginAgentProviderChange() defer { finishAgentProviderChange() } @@ -68,7 +78,7 @@ extension AppModel { guard let feature = agentConversationFeatureIfActive else { return } let connections = agentIDs.sorted().map { feature.connection(for: $0) } guard connections.allSatisfy({ connection in - !connection.isCreatingSession && connection.connectionState != .connecting && + !connection.isCreatingSession && connection.connectionState != .connecting && connection.connectionState != .authenticating && !connection.conversations.values.contains { $0.isResponding || $0.isLoading || $0.pendingConfigToken != nil || $0.permission != nil } diff --git a/macos/Sources/Lithe/Models/Settings/AppSettings.swift b/macos/Sources/Lithe/Models/Settings/AppSettings.swift index 594d3b5f8..2ded17cbf 100644 --- a/macos/Sources/Lithe/Models/Settings/AppSettings.swift +++ b/macos/Sources/Lithe/Models/Settings/AppSettings.swift @@ -825,4 +825,23 @@ struct AgentConfiguration: Codable, Equatable { /// Display name recorded when the agent was set up, for the Agent panel. var name: String var providerID: UUID? + var authentication: AgentAuthentication + var isConfigured: Bool { authentication == .codexSubscription || providerID != nil } + + init(name: String, providerID: UUID?, authentication: AgentAuthentication = .apiKey) { + self.name = name + self.providerID = providerID + self.authentication = authentication + } + + private enum CodingKeys: String, CodingKey { case name, providerID, authentication } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + name = try values.decode(String.self, forKey: .name) + providerID = try values.decodeIfPresent(UUID.self, forKey: .providerID) + authentication = try values.decodeIfPresent(AgentAuthentication.self, forKey: .authentication) ?? .apiKey + if authentication == .codexSubscription { providerID = nil } + } + } diff --git a/macos/Sources/Lithe/Platform/MacOS/Agent/MacACPAgentTransport.swift b/macos/Sources/Lithe/Platform/MacOS/Agent/MacACPAgentTransport.swift index e2bdb0089..59cd31133 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Agent/MacACPAgentTransport.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Agent/MacACPAgentTransport.swift @@ -12,7 +12,10 @@ final class MacACPAgentTransport: AgentConversationTransport { "args": configuration.arguments, "cwd": configuration.workspaceURL.path, "dataDirectory": configuration.dataDirectory.path, - "provider": [ + "authentication": configuration.authentication.rawValue + ] + if configuration.authentication == .apiKey { + configurationJSON["provider"] = [ "protocol": configuration.providerProtocol, "baseUrl": configuration.providerEndpoint, "apiKey": configuration.apiKey, @@ -20,7 +23,7 @@ final class MacACPAgentTransport: AgentConversationTransport { "model": configuration.model, "allowInsecureHttp": configuration.allowsInsecureHTTP ] - ] + } if let agentID = configuration.agentID { configurationJSON["agentId"] = agentID } else { diff --git a/macos/Sources/Lithe/Views/Agent/AgentComposerView.swift b/macos/Sources/Lithe/Views/Agent/AgentComposerView.swift index d3fc09dbe..c474e66ad 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentComposerView.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentComposerView.swift @@ -18,6 +18,10 @@ struct AgentComposerView: View { var isConfiguring = false var isCancelling = false var contextUsage: AgentContextUsage? + var showsSubscriptionQuota = false + var subscriptionQuota: AgentSubscriptionQuota? + var subscriptionAccount: String? + var quotaFailure: String? var onSetConfig: (String, String) -> Void = { _, _ in } @State private var draft = "" @State private var files: [AgentFileReference] = [] @@ -86,6 +90,9 @@ struct AgentComposerView: View { .buttonStyle(.plain) .help("Drag files here or click to choose files") Spacer(minLength: 0) + if showsSubscriptionQuota { + AgentSubscriptionQuotaView(quota: subscriptionQuota, account: subscriptionAccount, failure: quotaFailure) + } } .font(.system(size: 11)) .foregroundStyle(AgentPanelStyle.secondary) diff --git a/macos/Sources/Lithe/Views/Agent/AgentConversationView.swift b/macos/Sources/Lithe/Views/Agent/AgentConversationView.swift index 142168d74..d7b637f4b 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentConversationView.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentConversationView.swift @@ -123,6 +123,11 @@ private struct AgentConnectionView: View { @State private var searchText = "" @State private var showsTabs = false @State private var showsHistory = false + @Environment(\.scenePhase) private var scenePhase + + private var shouldPollQuota: Bool { + feature.usesSubscription && feature.connectionState == .ready && scenePhase == .active && !showsHistory + } private var selectedAgent: AgentOption? { agents.first { $0.id == selectedAgentID } } @@ -139,6 +144,14 @@ private struct AgentConnectionView: View { onReconnect: onConnect) } } + .task(id: shouldPollQuota) { + guard shouldPollQuota else { return } + while !Task.isCancelled { + feature.refreshQuota() + do { try await Task.sleep(for: .seconds(60)) } + catch { return } + } + } .onAppear { feature.prepareConversation() } .onChange(of: feature.connectionState) { state in if state == .ready { feature.prepareConversation() } @@ -208,6 +221,10 @@ private struct AgentConnectionView: View { isConfiguring: feature.selectedConversation?.pendingConfigToken != nil, isCancelling: feature.selectedConversation?.isCancelling == true, contextUsage: feature.selectedConversation?.contextUsage, + showsSubscriptionQuota: feature.usesSubscription, + subscriptionQuota: feature.subscriptionQuota, + subscriptionAccount: feature.subscriptionEmail, + quotaFailure: feature.quotaFailure, onSetConfig: { feature.setConfigOption($0, value: $1) } ) } @@ -247,6 +264,15 @@ private struct AgentConnectionView: View { .onAppear { if feature.connectionState == .idle { onConnect() } } + case .authenticationRequired: + AgentEmptyStateView(systemImage: "person.crop.circle", title: "Sign in to Codex", + message: String(localized: "Use your local ChatGPT account. No API key or URL is needed."), + actionTitle: "Sign in with ChatGPT", action: { feature.authenticate() }, + secondaryActionTitle: "Agent Settings", secondaryAction: onOpenSettings) + case .authenticating: + AgentEmptyStateView(systemImage: "person.crop.circle", title: "Waiting for ChatGPT sign-in…", + message: String(localized: "Complete sign-in in your browser. Your credentials are managed by Codex."), + actionTitle: "Cancel", action: { Task { await feature.cancelAuthentication() } }, isBusy: true) case .failed(let message): if feature.selectedConversation?.messages.isEmpty == false { AgentTranscriptView( diff --git a/macos/Sources/Lithe/Views/Agent/AgentPanelSettingsView.swift b/macos/Sources/Lithe/Views/Agent/AgentPanelSettingsView.swift index 33f2d1969..77791f9f5 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentPanelSettingsView.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentPanelSettingsView.swift @@ -249,7 +249,7 @@ private struct AgentsManagementPage: View { let checks = AgentSetupDiagnostics.preflight( for: agent, environment: environment, - hasProvider: settings.agentConfigurations[agent.id]?.providerID != nil + hasProvider: settings.agentConfigurations[agent.id]?.isConfigured == true ) switch AgentSetupDiagnostics.summary(of: checks) { case .pass: return LitheTheme.success @@ -271,7 +271,7 @@ private struct AgentDetailView: View { @State private var expanded: Set = [] private var isBusy: Bool { feature.busyAgentID == agent.id } - private var hasProvider: Bool { settings.agentConfigurations[agent.id]?.providerID != nil } + private var hasProvider: Bool { settings.agentConfigurations[agent.id]?.isConfigured == true } private var checks: [AgentPreflightCheck] { AgentSetupDiagnostics.preflight(for: agent, environment: environment, hasProvider: hasProvider) } diff --git a/macos/Sources/Lithe/Views/Agent/AgentProviderConfigurationView.swift b/macos/Sources/Lithe/Views/Agent/AgentProviderConfigurationView.swift index 14a8d656a..063135c34 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentProviderConfigurationView.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentProviderConfigurationView.swift @@ -13,6 +13,7 @@ struct AgentProviderConfigurationView: View { @State private var errorMessage: String? @State private var actionTask: Task? + private var usesSubscription: Bool { settings.agentConfigurations[agentID]?.authentication == .codexSubscription } private var selected: AIProviderProfile? { settings.agentProvider(for: agentID) } private var sources: [AIConfigurationSourceKind] { source.map { [$0] } ?? AIConfigurationSourceKind.allCases } private var providers: [AIProviderProfile] { @@ -41,6 +42,7 @@ struct AgentProviderConfigurationView: View { } label: { Label("Add", systemImage: "plus") } } } + if agentID == "codex-acp" { subscriptionRow } ForEach(sources) { kind in localRow(kind) } @@ -54,7 +56,7 @@ struct AgentProviderConfigurationView: View { Text(String(format: String(localized: "Current provider: %@"), selected.name)) .font(.system(size: 11)).foregroundStyle(LitheTheme.secondaryText) } - if self.selected != nil { + if self.selected != nil || usesSubscription { Button("Unlink") { perform { try await model.useAgentProvider(nil, agentID: agentID, name: name) } } @@ -83,6 +85,27 @@ struct AgentProviderConfigurationView: View { .onDisappear { actionTask?.cancel() } } + private var subscriptionRow: some View { + HStack(spacing: 8) { + Image(systemName: usesSubscription ? "checkmark.circle.fill" : "person.crop.circle") + .foregroundStyle(usesSubscription ? LitheTheme.accent : LitheTheme.secondaryText) + VStack(alignment: .leading, spacing: 3) { + Text("Codex subscription").font(.system(size: 12, weight: .medium)) + Text("Use your local ChatGPT account. No API key or URL is needed.") + .font(.system(size: 11)).foregroundStyle(LitheTheme.secondaryText) + } + Spacer(minLength: 8) + Button(usesSubscription ? "Enabled" : "Enable") { + perform { try await model.useCodexSubscription() } + } + .buttonStyle(LitheSecondaryButtonStyle(horizontalPadding: 10, height: 24, fontSize: 11.5)) + .disabled(usesSubscription) + } + .padding(10) + .background(LitheTheme.inputBackground, in: RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(usesSubscription ? LitheTheme.accent : LitheTheme.panelBorder, lineWidth: 1)) + } + private func localRow(_ kind: AIConfigurationSourceKind) -> some View { let active = selected?.credentialSource.configurationSource == kind return VStack(alignment: .leading, spacing: 6) { diff --git a/macos/Sources/Lithe/Views/Agent/AgentSubscriptionQuotaView.swift b/macos/Sources/Lithe/Views/Agent/AgentSubscriptionQuotaView.swift new file mode 100644 index 000000000..434c11865 --- /dev/null +++ b/macos/Sources/Lithe/Views/Agent/AgentSubscriptionQuotaView.swift @@ -0,0 +1,78 @@ +import SwiftUI +import LitheAgentConversationModule + +enum AgentSubscriptionQuotaPresentation { + static func duration(_ seconds: UInt64) -> String { + if seconds.isMultiple(of: 86_400) { return "\(seconds / 86_400)d" } + if seconds.isMultiple(of: 3_600) { return "\(seconds / 3_600)h" } + return "\(seconds / 60)m" + } + + static func usage(_ window: AgentSubscriptionQuota.Window, locale: Locale) -> String { + let percent = window.usedPercent.map { + ($0 / 100).formatted(.percent.precision(.fractionLength(0)).locale(locale)) + } ?? "—" + return String(format: String(localized: "%@ · %@ used"), duration(window.limitSeconds), percent) + } +} + +/// Only subscription connections mount this view; it owns no network or credentials. +struct AgentSubscriptionQuotaView: View { + let quota: AgentSubscriptionQuota? + let account: String? + let failure: String? + @Environment(\.locale) private var locale + + var body: some View { + TimelineView(.periodic(from: .now, by: 60)) { timeline in + let stale = failure != nil || quota?.isStale(at: timeline.date) == true + HStack(spacing: 4) { + Image(systemName: stale ? "clock" : "gauge.medium") + Text(quota?.mostUsedWindow.map { AgentSubscriptionQuotaPresentation.usage($0, locale: locale) } + ?? String(localized: "Quota —")) + .monospacedDigit().lineLimit(1) + } + .font(.system(size: 11)) + .foregroundStyle(stale ? AgentPanelStyle.muted : color) + .fixedSize() + .padding(.vertical, 4) + .workbenchHoverHelp(Text(verbatim: details(stale: stale)), placement: .above) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Codex subscription quota") + .accessibilityValue(details(stale: stale)) + .accessibilityIdentifier("agent-subscription-quota") + } + } + + private var color: Color { + let used = quota?.mostUsedWindow?.usedPercent ?? 0 + return used >= 100 ? LitheTheme.error : (used >= 90 ? LitheTheme.warning : AgentPanelStyle.secondary) + } + + private func details(stale: Bool) -> String { + var lines = [String(localized: "Codex subscription quota")] + if let account { lines.append(account) } + for window in quota?.windows ?? [] { + let prefix = window.name == "codex" ? "" : "\(window.name) · " + lines.append(prefix + AgentSubscriptionQuotaPresentation.usage(window, locale: locale)) + if let reset = window.resetsAt { + let date = Date(timeIntervalSince1970: Double(reset)) + .formatted(.dateTime.month().day().hour().minute().locale(locale)) + lines.append(String(format: String(localized: "Resets: %@"), date)) + } + } + if let quota { + let date = Date(timeIntervalSince1970: Double(quota.fetchedAt)) + .formatted(.dateTime.hour().minute().locale(locale)) + lines.append(String(format: String(localized: "Updated: %@"), date)) + } + if failure == "accountChanged" || failure == "unauthorized" { + lines.append(String(localized: "The local Codex account changed. Reconnect to refresh quota.")) + } else if stale { + lines.append(String(localized: "Quota is temporarily unavailable. The last value may be out of date.")) + } else if quota == nil { + lines.append(String(localized: "Waiting for subscription quota…")) + } + return lines.joined(separator: "\n") + } +} diff --git a/macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift b/macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift index 70233a03d..c710492da 100644 --- a/macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift +++ b/macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift @@ -21,6 +21,7 @@ struct ChangesSidebarView: View { @State private var selectedTab = CommitTab.commit @State private var trackedExpanded = true @State private var untrackedExpanded = true + @State private var repositoryExpanded: [String: Bool] = [:] @State private var stashMessage = "WIP" @State private var includeUntracked = true @State private var selectedStash: GitStash? @@ -516,33 +517,148 @@ struct ChangesSidebarView: View { .font(LitheTheme.uiFont) .foregroundStyle(LitheTheme.secondaryText) .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if feature.availableRepositoryRoots.count > 1 { + multiRepositoryChangeList } else { - GeometryReader { geometry in - ScrollView(.vertical) { - LazyVStack(alignment: .leading, spacing: 0) { - changeSection( - "Changes", - changes: trackedChanges, - expanded: $trackedExpanded, - showsParentPaths: geometry.size.width >= 300, - joinsNextHeader: !trackedExpanded && !addedChanges.isEmpty - ) - changeSection( - "Unversioned Files", - changes: addedChanges, - expanded: $untrackedExpanded, - showsParentPaths: geometry.size.width >= 300, - joinsPreviousHeader: !trackedExpanded && !trackedChanges.isEmpty - ) - } - .padding(.horizontal, 8) - .padding(.vertical, 8) - .frame(maxWidth: .infinity, alignment: .topLeading) + singleRepositoryChangeList + } + } + .frame(maxHeight: .infinity) + } + + private var singleRepositoryChangeList: some View { + GeometryReader { geometry in + ScrollView(.vertical) { + LazyVStack(alignment: .leading, spacing: 0) { + changeSection( + "Changes", + changes: trackedChanges, + expanded: $trackedExpanded, + showsParentPaths: geometry.size.width >= 300, + joinsNextHeader: !trackedExpanded && !addedChanges.isEmpty + ) + changeSection( + "Unversioned Files", + changes: addedChanges, + expanded: $untrackedExpanded, + showsParentPaths: geometry.size.width >= 300, + joinsPreviousHeader: !trackedExpanded && !trackedChanges.isEmpty + ) + } + .padding(.horizontal, 8) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + } + } + + private var multiRepositoryChangeList: some View { + GeometryReader { geometry in + ScrollView(.vertical) { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(changeSections.repositories) { repository in + repositoryChangeSection( + repository, + showsParentPaths: geometry.size.width >= 300 + ) } } + .padding(.horizontal, 8) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .topLeading) } } - .frame(maxHeight: .infinity) + } + + private func repositoryChangeSection( + _ repository: GitChangeSectionsCache.RepositorySection, + showsParentPaths: Bool + ) -> some View { + let repositoryID = repository.id + let isExpanded = repositoryExpanded[repositoryID] ?? true + + return VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 7) { + Button { + repositoryExpanded[repositoryID] = !isExpanded + } label: { + Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + .font(.system(size: 8, weight: .bold)) + .frame(width: 10, height: 26) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help(LocalizedStringKey(isExpanded ? "Collapse repository" : "Expand repository")) + + Button { + setStaging(repository.changes, !allChangesStaged(repository.changes)) + } label: { + Image(systemName: stagingSymbol(for: repository.changes)) + .font(.system(size: 16)) + .foregroundStyle( + repository.changes.contains(where: isEffectivelyStaged) + ? LitheTheme.accent + : LitheTheme.secondaryText + ) + .frame(width: 18, height: 26) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(feature.isCommitting || !repository.changes.contains(where: \.canToggleStaging)) + .help(LocalizedStringKey( + allChangesStaged(repository.changes) + ? "Unstage all files in repository" + : "Stage all files in repository" + )) + + Button { + repositoryExpanded[repositoryID] = !isExpanded + } label: { + HStack(spacing: 6) { + Image(systemName: "folder.fill") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(GitRepositoryColor.color( + for: repository.root, + in: feature.availableRepositoryRoots + )) + Text(repositoryDisplayName(repository.root)) + .font(.system(size: 12.5, weight: .semibold)) + .foregroundStyle(LitheTheme.primaryText) + .lineLimit(1) + Text("\(repository.changes.count)") + .font(.system(size: 11)) + .foregroundStyle(LitheTheme.secondaryText) + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, minHeight: 26, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help(repository.root.path) + } + .padding(.horizontal, 7) + .frame(maxWidth: .infinity) + .background(LitheTheme.subtleSelection.opacity(0.45)) + + if isExpanded { + ForEach(Array(repository.changes.enumerated()), id: \.element.id) { index, change in + changeRow( + change, + showsParentPath: showsParentPaths, + includesRepositoryRootInParentPath: false, + leadingInset: 12, + joinsPrevious: index > 0 && selection.ids.contains(repository.changes[index - 1].id), + joinsNext: index + 1 < repository.changes.count + && selection.ids.contains(repository.changes[index + 1].id) + ) + } + } + } + .overlay(alignment: .bottom) { + Rectangle() + .fill(LitheTheme.divider.opacity(0.72)) + .frame(height: 1) + } } @ViewBuilder @@ -551,6 +667,7 @@ struct ChangesSidebarView: View { changes: [GitChange], expanded: Binding, showsParentPaths: Bool, + leadingInset: CGFloat = 0, joinsPreviousHeader: Bool = false, joinsNextHeader: Bool = false ) -> some View { @@ -577,6 +694,7 @@ struct ChangesSidebarView: View { .contentShape(Rectangle()) } .buttonStyle(.plain) + .disabled(feature.isCommitting || !changes.contains(where: \.canToggleStaging)) .help(LocalizedStringKey(allChangesStaged(changes) ? "Unstage all files" : "Stage all files")) Button { @@ -597,6 +715,7 @@ struct ChangesSidebarView: View { .buttonStyle(.plain) } .padding(.horizontal, 7) + .padding(.leading, leadingInset) .frame(maxWidth: .infinity) .frame(height: changeRowHeight) .background { @@ -617,6 +736,7 @@ struct ChangesSidebarView: View { changeRow( change, showsParentPath: showsParentPaths, + leadingInset: leadingInset, joinsPrevious: index > 0 && selection.ids.contains(changes[index - 1].id), joinsNext: index + 1 < changes.count && selection.ids.contains(changes[index + 1].id) ) @@ -628,6 +748,8 @@ struct ChangesSidebarView: View { private func changeRow( _ change: GitChange, showsParentPath: Bool, + includesRepositoryRootInParentPath: Bool = true, + leadingInset: CGFloat = 0, joinsPrevious: Bool, joinsNext: Bool ) -> some View { @@ -643,7 +765,10 @@ struct ChangesSidebarView: View { .contentShape(Rectangle()) } .buttonStyle(LitheTreeRowButtonStyle()) - .help(LocalizedStringKey(isEffectivelyStaged(change) ? "Unstage file" : "Stage file")) + .disabled(feature.isCommitting || !change.canToggleStaging) + .help(LocalizedStringKey(change.canToggleStaging + ? (isEffectivelyStaged(change) ? "Unstage file" : "Stage file") + : "Commit changed files in the submodule first")) Button { selectRow(change) @@ -662,7 +787,14 @@ struct ChangesSidebarView: View { .strikethrough(change.kind == .deleted, color: statusColor(change)) .lineLimit(1) .layoutPriority(1) - let parent = parentPathText(change) + if !change.canToggleStaging { + Text("Uncommitted submodule changes") + .font(.caption).foregroundStyle(LitheTheme.secondaryText) + } + let parent = parentPathText( + change, + includesRepositoryRoot: includesRepositoryRootInParentPath + ) if showsParentPath, !parent.isEmpty { Text(parent) .font(.system(size: 10.5)) @@ -677,7 +809,7 @@ struct ChangesSidebarView: View { } .buttonStyle(LitheTreeRowButtonStyle()) } - .padding(.leading, 30) + .padding(.leading, 30 + leadingInset) .padding(.trailing, 6) .frame(maxWidth: .infinity) .frame(height: changeRowHeight) @@ -702,12 +834,24 @@ struct ChangesSidebarView: View { } } + private func repositoryDisplayName(_ root: URL) -> String { + let name = root.lastPathComponent + return name.isEmpty ? root.path : name + } + private var selectedChanges: [GitChange] { selection.actionTargets(in: displayedChanges) } private var visibleChangeIDs: [String] { - ((trackedExpanded ? trackedChanges : []) + (untrackedExpanded ? addedChanges : [])).map(\.id) + guard feature.availableRepositoryRoots.count > 1 else { + return ((trackedExpanded ? trackedChanges : []) + (untrackedExpanded ? addedChanges : [])).map(\.id) + } + + return changeSections.repositories.flatMap { repository in + guard repositoryExpanded[repository.id] ?? true else { return [String]() } + return repository.changes.map(\.id) + } } private func selectRow(_ change: GitChange) { @@ -811,7 +955,8 @@ struct ChangesSidebarView: View { } private func allChangesStaged(_ changes: [GitChange]) -> Bool { - changes.allSatisfy(isEffectivelyStaged) + let selectable = changes.filter(\.canToggleStaging) + return !selectable.isEmpty && selectable.allSatisfy(isEffectivelyStaged) } private func stagingSymbol(for changes: [GitChange]) -> String { @@ -854,9 +999,12 @@ struct ChangesSidebarView: View { return "\(oldName) → \(change.url.lastPathComponent)" } - private func parentPathText(_ change: GitChange) -> String { + private func parentPathText( + _ change: GitChange, + includesRepositoryRoot: Bool = true + ) -> String { let parent = (change.path as NSString).deletingLastPathComponent - let prefix = feature.availableRepositoryRoots.count > 1 + let prefix = includesRepositoryRoot && feature.availableRepositoryRoots.count > 1 ? change.repositoryRoot.path + "/" : "" guard let originalPath = change.originalPath else { return prefix + parent } let originalParent = (originalPath as NSString).deletingLastPathComponent diff --git a/macos/Sources/Lithe/Views/Git/CommitAreaView.swift b/macos/Sources/Lithe/Views/Git/CommitAreaView.swift index 4f77c4f31..be51b9e89 100644 --- a/macos/Sources/Lithe/Views/Git/CommitAreaView.swift +++ b/macos/Sources/Lithe/Views/Git/CommitAreaView.swift @@ -63,6 +63,25 @@ struct CommitAreaView: View { .allowsHitTesting(false) } + if !feature.workspaceCommitResults.isEmpty { + ScrollView { + VStack(alignment: .leading, spacing: 3) { + ForEach(feature.workspaceCommitResults) { result in + (Text("\(result.root.lastPathComponent): ") + Text(LocalizedStringKey(result.detail)) + + Text(verbatim: result.diagnostic.isEmpty ? "" : ": \(result.diagnostic)")) + .font(.caption).help(result.root.path) + } + }.frame(maxWidth: .infinity, alignment: .leading) + }.frame(maxHeight: 90) + Button("Dismiss Results") { feature.dismissWorkspaceCommitResults() } + .disabled(feature.isCommitting) + if feature.canRetryWorkspaceCommit { + Button("Review and Retry Unfinished Steps…") { + Task { await feature.prepareWorkspaceCommitRetry() } + }.disabled(feature.isCommitting) + } + } + HStack(spacing: 8) { Button { Task { await commitWorkflow.commit() } @@ -115,16 +134,75 @@ struct CommitAreaView: View { } message: { Text("The generated message will replace the text currently in the editor.") } + .sheet(isPresented: Binding( + get: { feature.pendingSubmoduleCommitPlan != nil }, + set: { if !$0 { feature.cancelPendingSubmoduleCommit() } } + )) { + WorkspaceCommitPlanView(feature: feature, commitWorkflow: commitWorkflow) + } } private var stagedChanges: [GitChange] { - feature.activeRepositoryChanges.filter(\.isStaged) + // Commit operates on every repository in the workspace, not only the + // repository selected by the branch toolbar. + feature.gitChanges.filter(\.isStaged) } private var canCommit: Bool { !stagedChanges.isEmpty && !draft.message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && - !feature.isCommitting + !feature.isCommitting && !feature.isStagingChanges && feature.pendingSubmoduleCommitPlan == nil && !feature.canRetryWorkspaceCommit } } + +/// Observe the plan directly so a changed selection updates the open sheet. +private struct WorkspaceCommitPlanView: View { + @ObservedObject var feature: GitFeatureModel + let commitWorkflow: CommitWorkflowCoordinator + + var body: some View { + if let plan = feature.pendingSubmoduleCommitPlan { + VStack(alignment: .leading, spacing: 12) { + Text(LocalizedStringKey(plan.isRetry ? "Review remaining steps" : "Review repository commits")).font(.headline) + Text("Each repository has its own commit. Completed steps are kept if another repository fails.") + Text("Commit message: \(plan.message)").font(.caption) + if plan.amend { Text("Amend applies to repositories with selected files.").font(.caption) } + ScrollView { + VStack(alignment: .leading, spacing: 8) { + ForEach(Array(plan.orderedRoots.enumerated()), id: \.element) { index, root in + VStack(alignment: .leading, spacing: 2) { + let action = plan.committedRoots.contains(root) ? "Push only" : (plan.push ? "Commit and push" : "Commit") + (Text("\(index + 1). ") + Text(LocalizedStringKey(action)) + Text(": \(root.path)")) + if let state = plan.states[root] { + Text("\(state.branch ?? "Detached HEAD") · \(state.head?.prefix(10) ?? "New repository")") + .font(.caption).foregroundStyle(.secondary) + if !plan.committedRoots.contains(root) { + ForEach(state.stagedPaths, id: \.self) { path in + Text(path).font(.caption) + } + } + } + } + } + ForEach(plan.propagatedRelations, id: \.self) { relation in + Text("Update \(relation.parent.lastPathComponent)/\(relation.path) after \(relation.child.lastPathComponent)") + .font(.caption) + } + }.frame(maxWidth: .infinity, alignment: .leading) + }.frame(maxHeight: 260) + Toggle("Update parent repository references", isOn: Binding( + get: { plan.includeParentReferences }, + set: { include in Task { await feature.setCommitPlanParentReferences(include) } } + )).disabled(feature.isCommitting) + if plan.push { Text("Each submodule is pushed before its parent.").font(.caption) } + HStack { + Spacer() + Button("Cancel") { feature.cancelPendingSubmoduleCommit() } + Button("Continue") { Task { await commitWorkflow.confirmPendingSubmoduleCommit() } } + .keyboardShortcut(.defaultAction) + }.disabled(feature.isCommitting) + }.padding(20).frame(width: 540) + } + } +} diff --git a/macos/Sources/Lithe/Views/Git/GitChangeSectionsCache.swift b/macos/Sources/Lithe/Views/Git/GitChangeSectionsCache.swift index 832c0626f..a554fd833 100644 --- a/macos/Sources/Lithe/Views/Git/GitChangeSectionsCache.swift +++ b/macos/Sources/Lithe/Views/Git/GitChangeSectionsCache.swift @@ -1,3 +1,4 @@ +import Foundation import LitheGitModule /// Splits the working-tree changes into the sections the sidebar renders, once @@ -11,12 +12,22 @@ import LitheGitModule /// invalidate the view that reads it. @MainActor final class GitChangeSectionsCache { + struct RepositorySection: Identifiable { + let root: URL + let changes: [GitChange] + let tracked: [GitChange] + let added: [GitChange] + + var id: String { root.standardizedFileURL.path } + } + struct Sections { /// All changes, minus anything hidden by an active conflict filter. let displayed: [GitChange] let tracked: [GitChange] let added: [GitChange] let staged: [GitChange] + let repositories: [RepositorySection] } private var cachedChanges: [GitChange] = [] @@ -35,6 +46,10 @@ final class GitChangeSectionsCache { var tracked: [GitChange] = [] var added: [GitChange] = [] var staged: [GitChange] = [] + var repositoryOrder: [String] = [] + var repositoryChanges: [String: [GitChange]] = [:] + var repositoryTracked: [String: [GitChange]] = [:] + var repositoryAdded: [String: [GitChange]] = [:] displayed.reserveCapacity(changes.count) for change in changes { @@ -45,18 +60,36 @@ final class GitChangeSectionsCache { continue } displayed.append(change) + let repositoryID = change.repositoryRoot.standardizedFileURL.path + if repositoryChanges[repositoryID] == nil { + repositoryOrder.append(repositoryID) + } + repositoryChanges[repositoryID, default: []].append(change) if change.kind == .added { added.append(change) + repositoryAdded[repositoryID, default: []].append(change) } else { tracked.append(change) + repositoryTracked[repositoryID, default: []].append(change) } } + let repositories = repositoryOrder.compactMap { repositoryID -> RepositorySection? in + guard let changes = repositoryChanges[repositoryID], + let root = changes.first?.repositoryRoot else { return nil } + return RepositorySection( + root: root, + changes: changes, + tracked: repositoryTracked[repositoryID] ?? [], + added: repositoryAdded[repositoryID] ?? [] + ) + } let sections = Sections( displayed: displayed, tracked: tracked, added: added, - staged: staged + staged: staged, + repositories: repositories ) cachedChanges = changes cachedFilterPaths = conflictFilterPaths diff --git a/macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift b/macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift index 83e407ec4..16777af0e 100644 --- a/macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift +++ b/macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift @@ -9,11 +9,19 @@ public final class AgentConnectionModel: ObservableObject { public enum ConnectionState: Equatable, Sendable { case idle case connecting + case authenticationRequired + case authenticating case ready case failed(String) } @Published public private(set) var connectionState: ConnectionState = .idle + @Published public private(set) var usesSubscription = false + @Published public private(set) var subscriptionEmail: String? + @Published public private(set) var subscriptionPlan: String? + @Published public private(set) var subscriptionQuota: AgentSubscriptionQuota? + @Published public private(set) var quotaFailure: String? + /// Name and version the agent reported on `ready`, for the panel header. @Published public private(set) var agentName: String? @Published public private(set) var agentVersion: String? @@ -75,6 +83,11 @@ public final class AgentConnectionModel: ObservableObject { guard closeTask == nil else { throw AgentConversationError.sessionStopping } let (events, continuation) = AsyncStream.makeStream() errorMessage = nil + usesSubscription = configuration.authentication == .codexSubscription + subscriptionEmail = nil + subscriptionPlan = nil + subscriptionQuota = nil + quotaFailure = nil connectionState = .connecting do { connection = try transport.open(configuration: configuration) { event in @@ -108,6 +121,29 @@ public final class AgentConnectionModel: ObservableObject { if let closeTask { await closeTask.value } } + public func authenticate() { + guard usesSubscription, connectionState == .authenticationRequired else { return } + if sendCommand(["kind": "authenticate"]) { connectionState = .authenticating } + } + + /// Keep a cancelled login out of the idle view, which auto-connects on appear. + public func cancelAuthentication() async { + guard connectionState == .authenticating else { return } + let old = detachConnection(failure: String(localized: "ChatGPT sign-in was cancelled.")) + if let old { + let closing = Task { await old.close() } + closeTask = closing + await closing.value + closeTask = nil + } + } + + /// The visible panel owns the polling task; the native host coalesces requests. + public func refreshQuota() { + guard usesSubscription, connectionState == .ready else { return } + sendCommand(["kind": "refreshQuota"]) + } + public var canRefreshSessions: Bool { canListSessions && connectionState == .ready } public func refreshSessions() { @@ -279,6 +315,26 @@ public final class AgentConnectionModel: ObservableObject { let sessionID = event["sessionId"] as? String let token = event["token"] as? String switch kind { + case "authenticationRequired": + guard usesSubscription else { return } + connectionState = .authenticationRequired + case "authenticating": + guard usesSubscription else { return } + connectionState = .authenticating + case "account": + guard usesSubscription, let account = event["account"] as? [String: Any] else { return } + subscriptionEmail = account["email"] as? String + subscriptionPlan = account["plan"] as? String + case "quota": + guard usesSubscription, connectionState == .ready else { return } + if let snapshot = AgentSubscriptionQuota.parse(event["snapshot"]) { + subscriptionQuota = snapshot + quotaFailure = nil + } else { quotaFailure = "unparsable" } + case "quotaFailed": + guard usesSubscription, connectionState == .ready else { return } + quotaFailure = event["code"] as? String ?? "unavailable" + if quotaFailure == "accountChanged" || quotaFailure == "unauthorized" { subscriptionQuota = nil } case "ready": connectionState = .ready agentName = event["agentName"] as? String @@ -327,6 +383,7 @@ public final class AgentConnectionModel: ObservableObject { conversations[sessionID, default: AgentConversation()].enqueuePermission(prompt) updateAttention() case "turnFinished": + refreshQuota() guard let sessionID else { return } flushPendingText() conversations[sessionID]?.isResponding = false @@ -572,6 +629,10 @@ public final class AgentConnectionModel: ObservableObject { eventTask = nil let old = connection connection = nil + subscriptionQuota = nil + subscriptionEmail = nil + subscriptionPlan = nil + quotaFailure = nil connectionState = failure.map(ConnectionState.failed) ?? .idle canListSessions = false canLoadSessions = false diff --git a/macos/Sources/LitheAgentConversationModule/Application/AgentSubscriptionQuota.swift b/macos/Sources/LitheAgentConversationModule/Application/AgentSubscriptionQuota.swift new file mode 100644 index 000000000..f5becce62 --- /dev/null +++ b/macos/Sources/LitheAgentConversationModule/Application/AgentSubscriptionQuota.swift @@ -0,0 +1,35 @@ +import Foundation + +/// Account-owned subscription windows, separate from session context occupancy. +public struct AgentSubscriptionQuota: Decodable, Equatable, Sendable { + public struct Window: Decodable, Equatable, Sendable, Identifiable { + public let id: String + public let name: String + public let limitSeconds: UInt64 + public let usedPercent: Double? + public let resetsAt: Int64? + } + + public let windows: [Window] + public let fetchedAt: UInt64 + + public var mostUsedWindow: Window? { + windows.filter { $0.usedPercent != nil }.max { ($0.usedPercent ?? 0) < ($1.usedPercent ?? 0) } + } + + public func isStale(at now: Date) -> Bool { + now.timeIntervalSince1970 - Double(fetchedAt) > 120 || windows.contains { + $0.resetsAt.map { Double($0) <= now.timeIntervalSince1970 } ?? false + } + } + + static func parse(_ value: Any?) -> Self? { + guard let value, JSONSerialization.isValidJSONObject(value), + let data = try? JSONSerialization.data(withJSONObject: value), + let result = try? JSONDecoder().decode(Self.self, from: data), + !result.windows.isEmpty, result.windows.allSatisfy({ window in + window.limitSeconds > 0 && (window.usedPercent.map { $0.isFinite && (0...100).contains($0) } ?? true) + }) else { return nil } + return result + } +} diff --git a/macos/Sources/LitheCoreContracts/Agent/AgentConversationPorts.swift b/macos/Sources/LitheCoreContracts/Agent/AgentConversationPorts.swift index 442fc7abf..41af2cc44 100644 --- a/macos/Sources/LitheCoreContracts/Agent/AgentConversationPorts.swift +++ b/macos/Sources/LitheCoreContracts/Agent/AgentConversationPorts.swift @@ -1,10 +1,16 @@ import Foundation +/// The billing source explicitly selected for an Agent connection. +public enum AgentAuthentication: String, Codable, Equatable, Sendable { + case apiKey + case codexSubscription +} + /// Settings required to launch one ACP agent for a workspace. /// -/// Phase one signs in only with a user-supplied API key; account logins -/// offered by the agent are never used. +/// API providers and local Codex subscription accounts use distinct launch paths. public struct AgentLaunchConfiguration: Equatable, Sendable { + public let authentication: AgentAuthentication /// ACP registry id of a Lithe-installed adapter, or `nil` for `command`. public let agentID: String? public let command: String @@ -32,8 +38,10 @@ public struct AgentLaunchConfiguration: Equatable, Sendable { apiKey: String, providerName: String, model: String, - allowsInsecureHTTP: Bool + allowsInsecureHTTP: Bool, + authentication: AgentAuthentication = .apiKey ) { + self.authentication = authentication self.agentID = agentID self.command = command self.arguments = arguments diff --git a/macos/Sources/LitheGitModule/Application/GitFeatureModel+WorkspaceCommit.swift b/macos/Sources/LitheGitModule/Application/GitFeatureModel+WorkspaceCommit.swift new file mode 100644 index 000000000..656f1b391 --- /dev/null +++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel+WorkspaceCommit.swift @@ -0,0 +1,135 @@ +import Foundation + +// Decisions: .agents/notes/implemented/feature/2026-09-27-workspace-git-commit-plans.md +extension GitFeatureModel { + package func dismissWorkspaceCommitResults() { + guard !isCommitting, pendingSubmoduleCommitPlan == nil else { return } + workspaceCommitAttempt = nil + workspaceCommitResults = [] + } + + package var canRetryWorkspaceCommit: Bool { + workspaceCommitAttempt?.canRetry == true + } + + package func commitStagedChanges(message: String, amend: Bool) async -> Bool { + await prepareWorkspaceCommit(message: message, amend: amend, push: false) + } + + @discardableResult + package func commitAndPushStagedChanges(message: String, amend: Bool) async -> Bool { + await prepareWorkspaceCommit(message: message, amend: amend, push: true) + } + + private func prepareWorkspaceCommit(message: String, amend: Bool, push: Bool) async -> Bool { + guard !isCommitting, pendingSubmoduleCommitPlan == nil else { return false } + let message = message.trimmingCharacters(in: .whitespacesAndNewlines) + guard !message.isEmpty else { notify?("Enter a commit message"); return false } + isCommitting = true + let generation = workspaceCommitGeneration + defer { if generation == workspaceCommitGeneration { isCommitting = false } } + guard let plan = await makeWorkspaceCommitPlan(message: message, amend: amend, push: push, + includeParentReferences: true, retry: false), generation == workspaceCommitGeneration else { return false } + workspaceCommitAttempt = nil + workspaceCommitResults = [] + if plan.preparation.requiresConfirmation { + pendingSubmoduleCommitPlan = plan + return false + } + return await executeWorkspaceCommit(plan, generation: generation) + } + + package func cancelPendingSubmoduleCommit() { pendingSubmoduleCommitPlan = nil } + + /// Rebuild even when a dialog is already visible: confirmation authorizes + /// this exact plan, never a new selection silently substituted for it. + @discardableResult + package func confirmPendingSubmoduleCommit() async -> Bool { + guard !isCommitting, let reviewed = pendingSubmoduleCommitPlan else { return false } + isCommitting = true + let generation = workspaceCommitGeneration + defer { if generation == workspaceCommitGeneration { isCommitting = false } } + guard let current = await makeWorkspaceCommitPlan(message: reviewed.message, amend: reviewed.amend, + push: reviewed.push, includeParentReferences: reviewed.includeParentReferences, retry: reviewed.isRetry, reviewed: reviewed.core), + generation == workspaceCommitGeneration, pendingSubmoduleCommitPlan?.id == reviewed.id else { return false } + guard !current.preparation.reviewChanged else { + pendingSubmoduleCommitPlan = current + notify?("The commit plan changed. Review the updated repositories and references before continuing.") + return false + } + pendingSubmoduleCommitPlan = nil + return await executeWorkspaceCommit(current, generation: generation) + } + + package func setCommitPlanParentReferences(_ include: Bool) async { + guard !isCommitting, let plan = pendingSubmoduleCommitPlan else { return } + isCommitting = true + let generation = workspaceCommitGeneration + defer { if generation == workspaceCommitGeneration { isCommitting = false } } + let replacement = await makeWorkspaceCommitPlan(message: plan.message, amend: plan.amend, push: plan.push, + includeParentReferences: include, retry: plan.isRetry) + guard generation == workspaceCommitGeneration, pendingSubmoduleCommitPlan?.id == plan.id else { return } + pendingSubmoduleCommitPlan = replacement + } + + /// Retry is explicit and always reviewable, including when only a push remains. + package func prepareWorkspaceCommitRetry() async { + guard !isCommitting, pendingSubmoduleCommitPlan == nil, let attempt = workspaceCommitAttempt else { return } + isCommitting = true + let generation = workspaceCommitGeneration + defer { if generation == workspaceCommitGeneration { isCommitting = false } } + let plan = await makeWorkspaceCommitPlan(message: attempt.plan.message, amend: attempt.plan.amend, + push: attempt.plan.push, includeParentReferences: attempt.plan.includeParentReferences, retry: true) + guard generation == workspaceCommitGeneration else { return } + pendingSubmoduleCommitPlan = plan + } + + private func makeWorkspaceCommitPlan(message: String, amend: Bool, push: Bool, + includeParentReferences: Bool, retry: Bool, reviewed: GitWorkspaceCommitPlan? = nil) async -> GitSubmoduleCommitPlan? { + guard !isStagingChanges else { notify?("Wait for staging to finish before reviewing the commit plan."); return nil } + guard let workspace = workspaceURLProvider?() else { return nil } + let generation = workspaceCommitGeneration + let base = workspace.standardizedFileURL.pathComponents + let bindings = availableRepositoryRoots.map { root in + let components = root.standardizedFileURL.pathComponents + let shared = zip(base, components).prefix { $0 == $1 }.count + let relative = Array(repeating: "..", count: base.count - shared) + Array(components.dropFirst(shared)) + return GitWorkspaceRepositoryBinding(id: relative.isEmpty ? "." : relative.joined(separator: "/"), + root: root.standardizedFileURL.path) + } + let request = GitWorkspaceCommitRequest(repositories: bindings, + message: message, amend: amend, push: push, includeParentReferences: includeParentReferences, + previous: retry ? workspaceCommitAttempt : nil, reviewed: reviewed) + let response = await withGitOperation { await service.prepareWorkspaceCommit(request) } + guard generation == workspaceCommitGeneration, !Task.isCancelled else { return nil } + switch response { + case .success(let preparation): return GitSubmoduleCommitPlan(preparation: preparation) + case .failure(let error): notify?(error.message); return nil + } + } + + private func executeWorkspaceCommit(_ plan: GitSubmoduleCommitPlan, generation: UUID) async -> Bool { + var session = plan.preparation.session + workspaceCommitAttempt = session + workspaceCommitResults = session.displayedResults + while !session.finished { + guard generation == workspaceCommitGeneration, !Task.isCancelled else { return false } + // Core chooses the repository and operation. Native code only owns + // execution UI, authentication, cancellation and workspace lifetime. + let current = session + let response = await withGitOperation { await service.stepWorkspaceCommit(current) } + guard generation == workspaceCommitGeneration else { return false } + switch response { + case .success(let next): session = next + case .failure(let error): notify?(error.message); return false + } + workspaceCommitAttempt = session + workspaceCommitResults = session.displayedResults + } + await refreshGit() + guard generation == workspaceCommitGeneration else { return false } + notify?(session.succeeded ? (plan.push ? "Committed and pushed all selected repositories" : "Committed changes") + : "Some repositories need attention. Completed steps are saved; retry to continue.") + return session.succeeded + } +} diff --git a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift index 25946b71d..05114b1bc 100644 --- a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift +++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -185,7 +185,13 @@ package final class GitFeatureModel: ObservableObject { /// Set whenever Git is mid-merge, mid-rebase, mid-cherry-pick, or mid-revert. @Published package var gitOperationState: GitOperationState? @Published package var isResolvingGitOperation = false - @Published package private(set) var isCommitting = false + @Published package internal(set) var isCommitting = false + /// Set when the staged selection spans a real parent/submodule edge. The + /// commit UI must explain the child-first order before continuing. + @Published package internal(set) var pendingSubmoduleCommitPlan: GitSubmoduleCommitPlan? + @Published package internal(set) var workspaceCommitResults: [GitRepositoryCommitResult] = [] + var workspaceCommitAttempt: GitWorkspaceCommitSession? + var workspaceCommitGeneration = UUID() @Published package private(set) var gitBlameLines: [URL: [GitBlameLine]] = [:] @Published package private(set) var gitLineChangeMarkers: [URL: [GitLineChangeMarker]] = [:] @Published package private(set) var gitReferences: [GitReference] = [] { @@ -276,7 +282,7 @@ package final class GitFeatureModel: ObservableObject { @Published package private(set) var isCloningRepository = false @Published package private(set) var availableRepositoryRoots: [URL] = [] - private let service: GitService + let service: GitService private let executionJournal: GitExecutionJournal? private var executionJournalSubscription: AnyCancellable? private var journalEntryIDs: Set = [] @@ -286,16 +292,16 @@ package final class GitFeatureModel: ObservableObject { private var selectedGitCommitFilesGeneration: UInt64 = 0 private var gitLogFilterGeneration = UUID() private let shelveService: ShelveService? - private let snapshotProvider: @Sendable (URL) async -> GitSnapshot? + private let snapshotProvider: @Sendable (URL, [URL]) async -> GitSnapshot? private let stashesProvider: @Sendable (URL) async -> [GitStash] private let operationStateProvider: @Sendable (URL) async -> GitOperationState? private let worktreesProvider: @Sendable (URL) async -> [GitWorktree]? private let repositoryRootsProvider: @Sendable (URL) async -> [URL] private var requestedRepositoryRoot: URL? private let diffDocumentProvider: @Sendable (GitChange, GitDiffWhitespaceMode) async -> DiffDocument - private var workspaceURLProvider: (@MainActor () -> URL?)? + var workspaceURLProvider: (@MainActor () -> URL?)? private var isGitLogVisibleProvider: (@MainActor () -> Bool)? - private var notify: (@MainActor (String) -> Void)? + var notify: (@MainActor (String) -> Void)? private var onStateRefreshed: (@MainActor () async -> Void)? private var saveChangesPolicy: (@MainActor () -> GitSaveChangesPolicy)? private var onGitOperationBegan: (@MainActor () -> Void)? @@ -350,7 +356,11 @@ package final class GitFeatureModel: ObservableObject { self.executionJournal = executionJournal commitFilesLoader = GitCommitFilesLoader(service: service) self.shelveService = shelveService - self.snapshotProvider = snapshotProvider ?? { await service.snapshot(for: $0) } + if let snapshotProvider { + self.snapshotProvider = { root, _ in await snapshotProvider(root) } + } else { + self.snapshotProvider = { root, roots in await service.snapshot(for: root, repositoryRoots: roots) } + } self.stashesProvider = stashesProvider ?? { await service.stashes(at: $0) } self.operationStateProvider = operationStateProvider ?? { await service.operationState(at: $0) } self.worktreesProvider = worktreesProvider ?? { await service.worktrees(at: $0) } @@ -460,6 +470,10 @@ package final class GitFeatureModel: ObservableObject { pendingDiscardChange = nil pendingDiscardHunk = nil isCommitting = false + pendingSubmoduleCommitPlan = nil + workspaceCommitAttempt = nil + workspaceCommitResults = [] + workspaceCommitGeneration = UUID() gitBlameLines = [:] gitLineChangeMarkers = [:] loadingLineChangeURLs = [] @@ -700,13 +714,13 @@ package final class GitFeatureModel: ObservableObject { repositoryRoots: [URL] ) async -> GitSnapshot? { if repositoryRoots.isEmpty { - return await snapshotProvider(workspaceURL) + return await snapshotProvider(workspaceURL, []) } var snapshots: [GitSnapshot] = [] for repositoryRoot in repositoryRoots { guard !Task.isCancelled else { return nil } - if let snapshot = await snapshotProvider(repositoryRoot) { + if let snapshot = await snapshotProvider(repositoryRoot, repositoryRoots) { snapshots.append(snapshot) } } @@ -878,7 +892,7 @@ package final class GitFeatureModel: ObservableObject { return saveChangesPolicy?() ?? .stash } - private func withGitOperation( + func withGitOperation( title: String? = nil, plannedArguments: [String]? = nil, _ operation: () async -> T ) async -> T { @@ -1101,7 +1115,10 @@ package final class GitFeatureModel: ObservableObject { /// deliberately bypasses the selected file's working-tree diff so a file /// with both staged and unstaged edits is represented correctly. package func stagedCommitMessageInput() async -> CommitMessageInput? { - let stagedChanges = activeRepositoryChanges.filter(\.isStaged) + // `gitChanges` is the workspace aggregate. Keeping this input + // aggregate in the same way as the commit operation prevents the AI + // button from silently ignoring staged files in child repositories. + let stagedChanges = gitChanges.filter(\.isStaged) guard !stagedChanges.isEmpty else { return nil } var files: [CommitMessageFileInput] = [] @@ -1128,14 +1145,14 @@ package final class GitFeatureModel: ObservableObject { } package func stageSelectedChange() async { - guard let selectedChange else { return } + guard !isCommitting, let selectedChange, selectedChange.canToggleStaging else { return } let result = await withGitOperation { await service.stage(selectedChange) } showResult(result, success: "Staged \(selectedChange.path)") await refreshGit() } package func unstageSelectedChange() async { - guard let selectedChange else { return } + guard !isCommitting, let selectedChange else { return } let result = await withGitOperation { await service.unstage(selectedChange) } showResult(result, success: "Unstaged \(selectedChange.path)") await refreshGit() @@ -1270,107 +1287,6 @@ package final class GitFeatureModel: ObservableObject { } } - /// Paths still holding conflict markers. Committing during a merge or rebase - /// would finish that operation, so an unresolved file has to stop the commit - /// rather than be recorded with its `<<<<<<<` markers intact. - private var conflictedPaths: [String] { - activeRepositoryChanges.filter(\.isConflicted).map(\.path) - } - - private func blockCommitWhenConflicted() -> Bool { - let paths = conflictedPaths - guard !paths.isEmpty else { return false } - notify?("Resolve the conflicts first: \(paths.joined(separator: ", "))") - return true - } - - /// Refuses a commit whose staged content still carries conflict markers. - /// - /// Separate from `blockCommitWhenConflicted`: Git stops marking a file as - /// conflicted the moment it is staged, so a user who stages before deleting the - /// `<<<<<<<` lines would otherwise commit them. This reads the staged blobs. - private func blockCommitWhenMarkersRemain() async -> Bool { - guard let gitRepositoryRoot else { return false } - let paths = await service.conflictMarkerPaths(at: gitRepositoryRoot) - guard !paths.isEmpty else { return false } - notify?("Conflict markers remain in: \(paths.joined(separator: ", "))") - return true - } - - package func commitStagedChanges(message rawMessage: String, amend: Bool) async -> Bool { - guard let gitRepositoryRoot else { return false } - let message = rawMessage.trimmingCharacters(in: .whitespacesAndNewlines) - guard !message.isEmpty else { - notify?("Enter a commit message") - return false - } - guard !blockCommitWhenConflicted() else { return false } - guard await !blockCommitWhenMarkersRemain() else { return false } - - isCommitting = true - let result = await withGitOperation { - await service.commit(at: gitRepositoryRoot, message: message, amend: amend) - } - isCommitting = false - if result.succeeded { - notify?("Changes committed") - } else { - notify?(trimmedMessage(result)) - } - await refreshGit() - return result.succeeded - } - - @discardableResult - package func commitAndPushStagedChanges(message rawMessage: String, amend: Bool) async -> Bool { - guard let gitRepositoryRoot else { return false } - let message = rawMessage.trimmingCharacters(in: .whitespacesAndNewlines) - guard !message.isEmpty else { - notify?("Enter a commit message") - return false - } - guard activeRepositoryChanges.contains(where: \.isStaged) else { - notify?("Stage at least one change before committing") - return false - } - guard !blockCommitWhenConflicted() else { return false } - guard await !blockCommitWhenMarkersRemain() else { return false } - - isCommitting = true - let commitResult = await withGitOperation { - await service.commit( - at: gitRepositoryRoot, - message: message, - amend: amend - ) - } - guard commitResult.succeeded else { - isCommitting = false - notify?(trimmedMessage(commitResult)) - await refreshGit() - return false - } - - guard let currentReference = currentGitReference else { - isCommitting = false - notify?("Committed changes, but detached HEAD cannot be pushed") - await refreshGit() - return true - } - - let pushResult = await withGitOperation { - await service.push(currentReference, at: gitRepositoryRoot) - } - isCommitting = false - if pushResult.succeeded { - notify?("Committed and pushed \(currentReference.shortName)") - } else { - notify?("Committed changes, but push failed: \(trimmedMessage(pushResult))") - } - await refreshGit() - return true - } - func reconcilePendingStagingStates(with changes: [GitChange]) { let changesByID = Dictionary(uniqueKeysWithValues: changes.map { ($0.id, $0) }) pendingStagingStates = pendingStagingStates.filter { id, staged in @@ -1378,20 +1294,23 @@ package final class GitFeatureModel: ObservableObject { } } + package var isStagingChanges: Bool { !pendingStagingStates.isEmpty } + package func effectiveStagingState(for change: GitChange) -> Bool { pendingStagingStates[change.id] ?? change.isStaged } package func beginToggleStaging(_ change: GitChange) -> Bool? { - guard pendingStagingStates[change.id] == nil else { return nil } + guard !isCommitting, change.canToggleStaging, pendingStagingStates[change.id] == nil else { return nil } let staged = !change.isStaged pendingStagingStates[change.id] = staged return staged } package func beginSetStaging(_ changes: [GitChange], staged: Bool) -> [GitChange] { + guard !isCommitting else { return [] } let pendingChanges = changes.filter { - pendingStagingStates[$0.id] == nil && $0.isStaged != staged + $0.canToggleStaging && pendingStagingStates[$0.id] == nil && $0.isStaged != staged } for change in pendingChanges { pendingStagingStates[change.id] = staged @@ -3563,7 +3482,7 @@ package final class GitFeatureModel: ObservableObject { return "\(fallback): \(warning.message)" } - private func trimmedMessage(_ result: GitService.CommandResult) -> String { + func trimmedMessage(_ result: GitService.CommandResult) -> String { let message = result.output.trimmingCharacters(in: .whitespacesAndNewlines) return message.isEmpty ? "Git operation failed" : message } diff --git a/macos/Sources/LitheGitModule/Models/GitModels.swift b/macos/Sources/LitheGitModule/Models/GitModels.swift index 9aba267ad..509699776 100644 --- a/macos/Sources/LitheGitModule/Models/GitModels.swift +++ b/macos/Sources/LitheGitModule/Models/GitModels.swift @@ -237,9 +237,52 @@ package struct GitReference: Identifiable, Hashable, Sendable { } } -/// One repository's references when a workspace aggregates several Git -/// repositories. Only references are aggregated here; history, diff, and the -/// console stay scoped to the active repository. +/// A real Git submodule edge between two discovered repository roots. +/// +/// Nested paths alone are not enough to establish this relationship: a workspace +/// may contain independent repositories below another repository. `path` is the +/// parent repository's gitlink path, relative to `parent`. +package struct GitRepositorySubmoduleRelation: Hashable, Sendable { + package let parent: URL + package let child: URL + package let path: String + + package init(parent: URL, child: URL, path: String) { + self.parent = parent.standardizedFileURL + self.child = child.standardizedFileURL + self.path = path + } +} + +/// Shared Core payload: Git owns the index fingerprint and gitlink interpretation. +package struct GitCommitState: Codable, Equatable, Sendable { + package let head: String? + package let branch: String? + package let indexEntries: String + package let gitlinks: [GitCommitGitlink] + package let stagedPaths: [String] + package let conflictedPaths: [String] + package init(head: String?, branch: String?, indexEntries: String, gitlinks: [GitCommitGitlink], stagedPaths: [String], conflictedPaths: [String] = []) { + self.head = head; self.branch = branch; self.indexEntries = indexEntries + self.gitlinks = gitlinks; self.stagedPaths = stagedPaths; self.conflictedPaths = conflictedPaths + } +} + +package struct GitCommitGitlink: Codable, Equatable, Sendable { + package let path: String + package let revision: String + package init(path: String, revision: String) { self.path = path; self.revision = revision } +} + +package struct GitRepositoryCommitResult: Identifiable, Sendable { + package let root: URL + package var committed = false + package var pushed = false + package var detail = "Pending" + package var diagnostic = "" + package var id: String { root.path } +} + package struct GitRepositoryReferences: Hashable, Sendable { package let repositoryRoot: URL package let references: [GitReference] @@ -786,7 +829,9 @@ package struct GitChange: Identifiable, Hashable, Sendable { package let originalPath: String? package let indexStatus: Character package let workTreeStatus: Character - package init(repositoryRoot: URL, path: String, originalPath: String?, indexStatus: Character, workTreeStatus: Character) { self.repositoryRoot = repositoryRoot; self.path = path; self.originalPath = originalPath; self.indexStatus = indexStatus; self.workTreeStatus = workTreeStatus } + package let submodule: GitSubmoduleStatus? + package let canToggleStaging: Bool + package init(repositoryRoot: URL, path: String, originalPath: String?, indexStatus: Character, workTreeStatus: Character, submodule: GitSubmoduleStatus? = nil, canToggleStaging: Bool = true) { self.canToggleStaging = canToggleStaging; self.submodule = submodule; self.repositoryRoot = repositoryRoot; self.path = path; self.originalPath = originalPath; self.indexStatus = indexStatus; self.workTreeStatus = workTreeStatus } package var id: String { "\(repositoryRoot.standardizedFileURL.path):\(originalPath ?? "")->\(path)" @@ -1549,3 +1594,12 @@ package enum DiffParser { return (old, new) } } + +package struct GitSubmoduleStatus: Codable, Hashable, Sendable { + package let commitChanged: Bool + package let trackedChanges: Bool + package let untrackedChanges: Bool + package init(commitChanged: Bool, trackedChanges: Bool, untrackedChanges: Bool) { + self.commitChanged = commitChanged; self.trackedChanges = trackedChanges; self.untrackedChanges = untrackedChanges + } +} diff --git a/macos/Sources/LitheGitModule/Models/GitWorkspaceCommitModels.swift b/macos/Sources/LitheGitModule/Models/GitWorkspaceCommitModels.swift new file mode 100644 index 000000000..a26f8192b --- /dev/null +++ b/macos/Sources/LitheGitModule/Models/GitWorkspaceCommitModels.swift @@ -0,0 +1,120 @@ +import Foundation + +// Wire DTOs are owned by Rust's git::workspace_commit. Native projections below +// translate identifiers and status keys for presentation; they do not plan work. +package struct GitWorkspaceRepositoryBinding: Codable, Equatable, Sendable { + package let id: String + package let root: String +} + +package struct GitWorkspaceCommitRelation: Codable, Equatable, Sendable { + package let parent: String + package let child: String + package let path: String +} + +package struct GitWorkspaceCommitPlan: Codable, Equatable, Sendable { + package let repositories: [GitWorkspaceRepositoryBinding] + package let message: String + package let amend: Bool + package let push: Bool + package let includeParentReferences: Bool + package let isRetry: Bool + package let orderedIds: [String] + package let propagatedRelations: [GitWorkspaceCommitRelation] + package let dependencyRelations: [GitWorkspaceCommitRelation] + package let states: [String: GitCommitState] + package let committedIds: Set + package let pendingPushIds: Set + + func root(_ id: String) -> URL? { + repositories.first { $0.id == id }.map { URL(fileURLWithPath: $0.root).standardizedFileURL } + } +} + +package struct GitWorkspaceRepositoryResult: Codable, Sendable { + package var committed: Bool + package var pushed: Bool + package var status: String + package var detail: String + + var displayDetail: String { + let label: String + switch status { + case "pending": label = "Pending" + case "notIncluded": label = "Not included in the updated plan" + case "waitingForSubmodule": label = "Waiting for submodule" + case "committed": label = "Committed" + case "committedPushPending": label = "Committed; push pending" + case "committedAndPushed": label = "Committed and pushed" + case "pushFailed": label = "Committed; push failed" + case "headAdvanced": label = "HEAD advanced; review before continuing." + case "outcomeUnknown": label = "Could not verify the commit outcome. Review before retrying." + default: label = "Repository needs attention" + } + return label + } +} + +package struct GitWorkspaceCommitSession: Codable, Sendable { + package let plan: GitWorkspaceCommitPlan + package var states: [String: GitCommitState] + package var results: [String: GitWorkspaceRepositoryResult] + package var blocked: Set + package var cursor: Int + package var commandFailed: Bool + package var finished: Bool + package var succeeded: Bool + package var canRetry: Bool + + var displayedResults: [GitRepositoryCommitResult] { + results.compactMap { id, result in + plan.root(id).map { GitRepositoryCommitResult(root: $0, committed: result.committed, + pushed: result.pushed, detail: result.displayDetail, diagnostic: result.detail) } + }.sorted { $0.root.path < $1.root.path } + } +} + +package struct GitWorkspaceCommitRequest: Encodable, Sendable { + package let repositories: [GitWorkspaceRepositoryBinding] + package let message: String + package let amend: Bool + package let push: Bool + package let includeParentReferences: Bool + package let previous: GitWorkspaceCommitSession? + package let reviewed: GitWorkspaceCommitPlan? +} + +package struct GitWorkspaceCommitPreparation: Codable, Sendable { + package let session: GitWorkspaceCommitSession + package let reviewChanged: Bool + package let requiresConfirmation: Bool +} + +package struct GitWorkspaceCommitFailure: Error, Sendable { + package let message: String + package init(_ message: String) { self.message = message } +} + +/// View identity and native URL projections of the shared review model. +package struct GitSubmoduleCommitPlan: Identifiable, Sendable { + package let id = UUID() + package let preparation: GitWorkspaceCommitPreparation + var core: GitWorkspaceCommitPlan { preparation.session.plan } + package var message: String { core.message } + package var amend: Bool { core.amend } + package var push: Bool { core.push } + package var includeParentReferences: Bool { core.includeParentReferences } + package var isRetry: Bool { core.isRetry } + package var orderedRoots: [URL] { core.orderedIds.compactMap { core.root($0) } } + package var committedRoots: Set { Set(core.committedIds.compactMap { core.root($0) }) } + package var states: [URL: GitCommitState] { + Dictionary(uniqueKeysWithValues: core.states.compactMap { id, state in core.root(id).map { ($0, state) } }) + } + package var propagatedRelations: [GitRepositorySubmoduleRelation] { + core.propagatedRelations.compactMap { relation in + guard let parent = core.root(relation.parent), let child = core.root(relation.child) else { return nil } + return GitRepositorySubmoduleRelation(parent: parent, child: child, path: relation.path) + } + } +} diff --git a/macos/Sources/LitheGitModule/Services/GitService.swift b/macos/Sources/LitheGitModule/Services/GitService.swift index 18bcff000..47a19de71 100644 --- a/macos/Sources/LitheGitModule/Services/GitService.swift +++ b/macos/Sources/LitheGitModule/Services/GitService.swift @@ -12,6 +12,9 @@ package struct NullGitPerformanceLogger: GitPerformanceLogger { } package protocol GitOperations: Sendable { + func prepareWorkspaceCommit(_ request: GitWorkspaceCommitRequest) -> Result + func stepWorkspaceCommit(_ session: GitWorkspaceCommitSession) -> Result + func consolePresentation(_ request: GitConsolePresentationRequest) -> GitConsolePresentation? func executionSettings(_ request: GitConfigurationEdit, save: Bool) -> Result func remoteURL(at rootURL: URL, remote: String) -> String? @@ -26,6 +29,7 @@ package protocol GitOperations: Sendable { ) -> GitProcessResult func snapshot(at rootURL: URL) -> GitSnapshot? + func snapshot(at rootURL: URL, repositoryRoots: [URL]) -> GitSnapshot? func repositories(in workspaceURL: URL) -> [URL] func watchContext(at rootURL: URL) -> GitWatchContext? func worktrees(at rootURL: URL) -> [GitWorktree]? @@ -174,6 +178,14 @@ package protocol GitOperations: Sendable { } package extension GitOperations { + func prepareWorkspaceCommit(_ request: GitWorkspaceCommitRequest) -> Result { + .failure(GitWorkspaceCommitFailure("Workspace commit planning is unavailable")) + } + func stepWorkspaceCommit(_ session: GitWorkspaceCommitSession) -> Result { + .failure(GitWorkspaceCommitFailure("Workspace commit execution is unavailable")) + } + func snapshot(at rootURL: URL, repositoryRoots: [URL]) -> GitSnapshot? { snapshot(at: rootURL) } + func consolePresentation(_ request: GitConsolePresentationRequest) -> GitConsolePresentation? { nil } func executionSettings(_ request: GitConfigurationEdit, save: Bool) -> Result { .failure(GitFetchFailure("Git configuration inspection is unavailable.")) } func remoteURL(at rootURL: URL, remote: String) -> String? { nil } @@ -362,14 +374,37 @@ package struct GitService: Sendable { } } - func snapshot(for workspace: URL) async -> GitSnapshot? { - await read(priority: .utility) { $0.snapshot(at: workspace) } + func snapshot(for workspace: URL, repositoryRoots: [URL] = []) async -> GitSnapshot? { + await read(priority: .utility) { $0.snapshot(at: workspace, repositoryRoots: repositoryRoots) } } func repositories(in workspace: URL) async -> [URL] { await read(priority: .utility) { $0.repositories(in: workspace) } ?? [] } + func prepareWorkspaceCommit(_ request: GitWorkspaceCommitRequest) async -> Result { + await workspaceCommitOperation { $0.prepareWorkspaceCommit(request) } + } + + func stepWorkspaceCommit(_ session: GitWorkspaceCommitSession) async -> Result { + await workspaceCommitOperation { $0.stepWorkspaceCommit(session) } + } + + private func workspaceCommitOperation( + _ operation: @escaping @Sendable (any GitOperations) -> Result + ) async -> Result { + let operations = self.operations + let execution = GitExecutionContext.current + return await withTaskCancellationHandler { + await Task.detached(priority: .userInitiated) { + GitExecutionContext.$current.withValue(execution) { operation(operations) } + }.value + } onCancel: { + execution?.requestCancellation() + if let execution { _ = operations.cancel(operationID: execution.operationID) } + } + } + func worktrees(at repositoryRoot: URL) async -> [GitWorktree]? { await read(priority: .utility) { $0.worktrees(at: repositoryRoot) } } diff --git a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift index aee25042d..1133a2aa1 100644 --- a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift +++ b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift @@ -597,8 +597,6 @@ struct GitModuleTests { #expect(feature.gitChanges == [firstChange, secondChange]) #expect(feature.currentBranch == "main") #expect(feature.activeRepositoryChanges == [firstChange]) - // A conflict or staged entry in another repository must not block this commit. - #expect(await feature.commitStagedChanges(message: "First repository", amend: false)) #expect(firstChange.id != secondChange.id) #expect(feature.gitTreeStatus.change(relativePath: firstChange.url.path) == firstChange) #expect(feature.gitTreeStatus.change(relativePath: secondChange.url.path) == secondChange) @@ -606,10 +604,165 @@ struct GitModuleTests { #expect(feature.gitRepositoryRoot == secondRoot) #expect(feature.currentBranch == "develop") #expect(feature.activeRepositoryChanges == [secondChange]) - #expect(await !feature.commitStagedChanges(message: "Conflicted repository", amend: false)) #expect(feature.gitChanges == [firstChange, secondChange]) } + @Test + func sharedStagingEligibilityDisablesParentCheckbox() { + let feature = GitFeatureModel(service: GitService(operations: TestGitOperations())) + let change = GitChange(repositoryRoot: URL(fileURLWithPath: "/workspace"), path: "libs/B", + originalPath: nil, indexStatus: " ", workTreeStatus: "M", + submodule: GitSubmoduleStatus(commitChanged: false, trackedChanges: true, untrackedChanges: false), canToggleStaging: false) + #expect(feature.beginToggleStaging(change) == nil) + #expect(feature.beginSetStaging([change], staged: true).isEmpty) + } + + @Test + func workspaceCommitConfirmationUsesTheSharedPlanAndForwardsOptions() async throws { + let preparation = try workspacePreparation() + let probe = WorkspaceCommitProbe(preparations: [preparation, preparation], steps: [completedWorkspace(preparation)]) + let feature = workspaceCommitFeature(probe: probe) + defer { feature.reset() } + await feature.refreshGit() + #expect(await !feature.commitAndPushStagedChanges(message: "commit", amend: true)) + #expect(probe.stepCount == 0) + #expect(feature.pendingSubmoduleCommitPlan?.orderedRoots.map(\.lastPathComponent) == ["B", "A"]) + #expect(await feature.confirmPendingSubmoduleCommit()) + #expect(probe.stepCount == 1) + #expect(probe.requests.first?.amend == true) + #expect(probe.requests.first?.push == true) + #expect(probe.requests.first?.repositories.map(\.id) == ["A", "A/B"]) + #expect(probe.requests.last?.reviewed == preparation.session.plan) + #expect(feature.workspaceCommitResults.allSatisfy { $0.committed && $0.pushed }) + } + + @Test + func changedSharedCommitPlanRequiresAnotherConfirmationBeforeAnyStep() async throws { + let original = try workspacePreparation() + let changed = GitWorkspaceCommitPreparation(session: original.session, reviewChanged: true, requiresConfirmation: true) + let probe = WorkspaceCommitProbe(preparations: [original, changed, original], steps: [completedWorkspace(original)]) + let feature = workspaceCommitFeature(probe: probe) + defer { feature.reset() } + await feature.refreshGit() + #expect(await !feature.commitStagedChanges(message: "commit", amend: false)) + let oldID = feature.pendingSubmoduleCommitPlan?.id + #expect(await !feature.confirmPendingSubmoduleCommit()) + #expect(probe.stepCount == 0) + #expect(feature.pendingSubmoduleCommitPlan?.id != oldID) + #expect(await feature.confirmPendingSubmoduleCommit()) + #expect(probe.stepCount == 1) + } + + @Test + func parentReferenceToggleAndRetryAreForwardedToCore() async throws { + let original = try workspacePreparation() + var failed = original.session + failed.finished = true + failed.results["A/B"] = GitWorkspaceRepositoryResult(committed: true, pushed: false, status: "pushFailed", detail: "stopped") + failed.results["A"] = GitWorkspaceRepositoryResult(committed: false, pushed: false, status: "waitingForSubmodule", detail: "") + let probe = WorkspaceCommitProbe(preparations: [original, original, original, original], steps: [failed]) + let feature = workspaceCommitFeature(probe: probe) + defer { feature.reset() } + await feature.refreshGit() + #expect(await !feature.commitAndPushStagedChanges(message: "commit", amend: false)) + await feature.setCommitPlanParentReferences(false) + #expect(probe.requests.last?.includeParentReferences == false) + #expect(await !feature.confirmPendingSubmoduleCommit()) + #expect(feature.canRetryWorkspaceCommit) + #expect(feature.workspaceCommitResults.first { $0.root.lastPathComponent == "A" }?.detail == "Waiting for submodule") + await feature.prepareWorkspaceCommitRetry() + #expect(probe.requests.last?.previous?.results["A/B"]?.committed == true) + #expect(feature.pendingSubmoduleCommitPlan != nil) + feature.cancelPendingSubmoduleCommit() + #expect(feature.pendingSubmoduleCommitPlan == nil) + #expect(feature.canRetryWorkspaceCommit) + } + + @Test + func workspaceWithoutRequiredConfirmationDrivesCoreStepsUntilFinished() async throws { + let original = try workspacePreparation() + let immediate = GitWorkspaceCommitPreparation(session: original.session, reviewChanged: false, requiresConfirmation: false) + var progress = original.session + progress.cursor = 1 + let probe = WorkspaceCommitProbe(preparations: [immediate], steps: [progress, completedWorkspace(original)]) + let feature = workspaceCommitFeature(probe: probe) + defer { feature.reset() } + await feature.refreshGit() + #expect(await feature.commitStagedChanges(message: "commit", amend: false)) + #expect(probe.stepCount == 2) + #expect(!feature.canRetryWorkspaceCommit) + feature.dismissWorkspaceCommitResults() + #expect(feature.workspaceCommitResults.isEmpty) + } + + @Test + func firstCoreStepFailureKeepsRecoveryActionsVisible() async throws { + let original = try workspacePreparation() + let immediate = GitWorkspaceCommitPreparation(session: original.session, reviewChanged: false, requiresConfirmation: false) + let probe = WorkspaceCommitProbe(preparations: [immediate]) + let feature = workspaceCommitFeature(probe: probe) + defer { feature.reset() } + await feature.refreshGit() + #expect(await !feature.commitStagedChanges(message: "commit", amend: false)) + #expect(feature.canRetryWorkspaceCommit) + #expect(!feature.workspaceCommitResults.isEmpty) + #expect(!feature.isCommitting) + feature.dismissWorkspaceCommitResults() + #expect(!feature.canRetryWorkspaceCommit) + #expect(feature.workspaceCommitResults.isEmpty) + } + + @Test + func workspaceResetDuringCommitDoesNotStartTheNextStepOrPublishOldResults() async throws { + let original = try workspacePreparation() + let immediate = GitWorkspaceCommitPreparation(session: original.session, reviewChanged: false, requiresConfirmation: false) + var progress = original.session + progress.cursor = 1 + let started = GitModuleTestGate() + let release = GitModuleTestGate() + let probe = WorkspaceCommitProbe(preparations: [immediate], steps: [progress], started: started, release: release) + let feature = workspaceCommitFeature(probe: probe) + await feature.refreshGit() + let task = Task { await feature.commitStagedChanges(message: "commit", amend: false) } + defer { release.open(); task.cancel(); feature.reset() } + #expect(await started.waitUntilOpen()) + feature.reset() + release.open() + #expect(await !task.value) + #expect(probe.stepCount == 1) + #expect(feature.workspaceCommitResults.isEmpty) + #expect(!feature.isCommitting) + } + + private func workspacePreparation() throws -> GitWorkspaceCommitPreparation { + struct Fixture: Decodable { let preparation: GitWorkspaceCommitPreparation } + let fixtureURL = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent() + .appendingPathComponent("shared/fixtures/git/workspace-commit-workflow-v1.json") + return try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: fixtureURL)).preparation + } + + private func completedWorkspace(_ preparation: GitWorkspaceCommitPreparation) -> GitWorkspaceCommitSession { + var session = preparation.session + session.finished = true; session.succeeded = true; session.canRetry = false + session.cursor = session.plan.orderedIds.count + session.results = ["A": GitWorkspaceRepositoryResult(committed: true, pushed: true, status: "committedAndPushed", detail: ""), + "A/B": GitWorkspaceRepositoryResult(committed: true, pushed: true, status: "committedAndPushed", detail: "")] + return session + } + + private func workspaceCommitFeature(roots: [URL] = [URL(fileURLWithPath: "/workspace/A"), URL(fileURLWithPath: "/workspace/A/B")], + changes: [GitChange] = [], probe: WorkspaceCommitProbe = WorkspaceCommitProbe()) -> GitFeatureModel { + let feature = GitFeatureModel(service: GitService(operations: TestGitOperations( + snapshotsByRoot: Dictionary(uniqueKeysWithValues: roots.map { root in + (root.path, GitSnapshot(repositoryRoot: root, branch: "main", changes: changes.filter { $0.repositoryRoot == root })) + }), repositoryRoots: roots, workspaceCommitProbe: probe))) + feature.configure(workspaceURLProvider: { URL(fileURLWithPath: "/workspace") }, isGitLogVisibleProvider: { false }, + notify: { _ in }, onStateRefreshed: {}) + return feature + } + @Test func linkedWorktreeDetectionUsesPathComponentBoundaries() { let primary = URL(fileURLWithPath: "/workspace/op-platform", isDirectory: true) @@ -3393,6 +3546,54 @@ private final class GitPerformanceLogRecorder: GitPerformanceLogger, @unchecked } } +/// Records each repository commit so multi-repository submission can assert +/// that one Git commit is issued per independent index. +private final class CommitCallRecorder: @unchecked Sendable { + struct Call: Equatable { + let root: URL + let message: String + let amend: Bool + } + + private let lock = NSLock() + private var calls: [Call] = [] + + func record(_ call: Call) { + lock.lock() + calls.append(call) + lock.unlock() + } + + var recorded: [Call] { + lock.lock() + defer { lock.unlock() } + return calls + } +} + +/// Records the parent gitlink restage issued after a child submodule commit. +private final class StageCallRecorder: @unchecked Sendable { + struct Call: Equatable { + let root: URL + let path: String + } + + private let lock = NSLock() + private var calls: [Call] = [] + + func record(_ change: GitChange) { + lock.lock() + calls.append(Call(root: change.repositoryRoot, path: change.path)) + lock.unlock() + } + + var recorded: [Call] { + lock.lock() + defer { lock.unlock() } + return calls + } +} + /// Records tag create/delete arguments so restore flows can be asserted on /// the exact parameters the feature model replays. private final class TagCallRecorder: @unchecked Sendable { @@ -3568,6 +3769,10 @@ private struct TestGitOperations: GitOperations { private let applyPatchHandler: (@Sendable (String, URL, String) -> GitProcessResult?)? private let stageResult: GitProcessResult? private let commitResult: GitProcessResult? + private let commitCallRecorder: CommitCallRecorder? + private let stageCallRecorder: StageCallRecorder? + private let gitlinkPathsByRoot: [String: [String]] + private let workspaceCommitProbe: WorkspaceCommitProbe private let revertHandler: (@Sendable (String) -> GitProcessResult?)? private let runGate: TestGitRunGate? private let filesRecorder: GitFilesCallRecorder? @@ -3609,6 +3814,10 @@ private struct TestGitOperations: GitOperations { applyPatchHandler: (@Sendable (String, URL, String) -> GitProcessResult?)? = nil, stageResult: GitProcessResult? = nil, commitResult: GitProcessResult? = nil, + commitCallRecorder: CommitCallRecorder? = nil, + stageCallRecorder: StageCallRecorder? = nil, + gitlinkPathsByRoot: [String: [String]] = [:], + workspaceCommitProbe: WorkspaceCommitProbe? = nil, revertHandler: (@Sendable (String) -> GitProcessResult?)? = nil, runGate: TestGitRunGate? = nil, filesRecorder: GitFilesCallRecorder? = nil, @@ -3649,6 +3858,10 @@ private struct TestGitOperations: GitOperations { self.applyPatchHandler = applyPatchHandler self.stageResult = stageResult self.commitResult = commitResult + self.commitCallRecorder = commitCallRecorder + self.stageCallRecorder = stageCallRecorder + self.gitlinkPathsByRoot = gitlinkPathsByRoot + self.workspaceCommitProbe = workspaceCommitProbe ?? WorkspaceCommitProbe() self.revertHandler = revertHandler self.runGate = runGate self.filesRecorder = filesRecorder @@ -3673,10 +3886,17 @@ private struct TestGitOperations: GitOperations { func run(arguments: [String], workingDirectory: String, input: String?) -> GitProcessResult { runGate?.blockFirstRun() + let standardOutput: String + if arguments == ["ls-files", "--stage", "-z"] { + let paths = gitlinkPathsByRoot[URL(fileURLWithPath: workingDirectory).standardizedFileURL.path] ?? [] + standardOutput = paths.map { "160000 abc123 0\t\($0)\0" }.joined() + } else { + standardOutput = "git version 2.55.0\n" + } return GitProcessResult( arguments: arguments, - output: "git version 2.55.0\n", - standardOutput: "git version 2.55.0\n", + output: standardOutput, + standardOutput: standardOutput, standardError: "", exitCode: 0 ) @@ -3773,11 +3993,23 @@ private struct TestGitOperations: GitOperations { func comparison(from reference: GitReference, to target: GitReference, at rootURL: URL) -> GitBranchComparison? { typedComparisonValue } func stashes(at rootURL: URL) -> [GitStash]? { nil } func blame(at rootURL: URL, relativePath: String) -> [GitBlameLine]? { nil } - func stage(_ change: GitChange) -> GitProcessResult? { stageResult } + func stage(_ change: GitChange) -> GitProcessResult? { + stageCallRecorder?.record(change) + return stageResult + } func unstage(_ change: GitChange) -> GitProcessResult? { nil } func discard(_ change: GitChange) -> GitProcessResult? { discardHandler?(change) } func discardAll(_ change: GitChange) -> GitProcessResult? { nil } - func commit(at rootURL: URL, message: String, amend: Bool) -> GitProcessResult? { commitResult } + func prepareWorkspaceCommit(_ request: GitWorkspaceCommitRequest) -> Result { + workspaceCommitProbe.prepare(request) + } + func stepWorkspaceCommit(_ session: GitWorkspaceCommitSession) -> Result { + workspaceCommitProbe.step() + } + func commit(at rootURL: URL, message: String, amend: Bool) -> GitProcessResult? { + commitCallRecorder?.record(CommitCallRecorder.Call(root: rootURL, message: message, amend: amend)) + return commitResult + } func cherryPick(_ hash: String, at rootURL: URL) -> GitProcessResult? { nil } func revert(_ hash: String, at rootURL: URL) -> GitProcessResult? { revertHandler?(hash) } func resetCurrentBranch(to hash: String, mode: String, at rootURL: URL) -> GitProcessResult? { nil } @@ -3840,7 +4072,7 @@ private struct TestGitOperations: GitOperations { func abortOperation(at rootURL: URL) -> GitProcessResult? { nil } func skipOperationStep(at rootURL: URL) -> GitProcessResult? { nil } func checkoutRevision(_ revision: String, at rootURL: URL) -> GitProcessResult? { nil } - func push(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { nil } + func push(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { GitProcessResult(output: "Pushed", exitCode: 0) } func cloneRepository(from remote: String, to destination: URL) -> GitProcessResult? { nil } func stash(message: String, includeUntracked: Bool, at rootURL: URL) -> GitProcessResult? { nil } func applyStash(_ stash: GitStash, at rootURL: URL) -> GitProcessResult? { nil } @@ -3856,3 +4088,37 @@ private struct TestGitOperations: GitOperations { return deleteTagResults?.next() ?? deleteTagResult } } + +/// Synchronous GitOperations test state; the production service reads it on workers. +/// Scripted Core responses only: ordering, dependencies and retry policy are +/// exercised in Rust instead of reimplemented in this native test double. +private final class WorkspaceCommitProbe: @unchecked Sendable { + private let lock = NSLock() + private var preparations: [GitWorkspaceCommitPreparation] + private var steps: [GitWorkspaceCommitSession] + private var recordedRequests: [GitWorkspaceCommitRequest] = [] + private var recordedSteps = 0 + private let started: GitModuleTestGate? + private let release: GitModuleTestGate? + init(preparations: [GitWorkspaceCommitPreparation] = [], steps: [GitWorkspaceCommitSession] = [], + started: GitModuleTestGate? = nil, release: GitModuleTestGate? = nil) { + self.preparations = preparations; self.steps = steps + self.started = started; self.release = release + } + func prepare(_ request: GitWorkspaceCommitRequest) -> Result { + lock.lock(); defer { lock.unlock() } + recordedRequests.append(request) + guard !preparations.isEmpty else { return .failure(GitWorkspaceCommitFailure("No scripted preparation")) } + return .success(preparations.removeFirst()) + } + func step() -> Result { + started?.open() + guard release?.waitSynchronously() ?? true else { return .failure(GitWorkspaceCommitFailure("Test gate timed out")) } + lock.lock(); defer { lock.unlock() } + recordedSteps += 1 + guard !steps.isEmpty else { return .failure(GitWorkspaceCommitFailure("No scripted step")) } + return .success(steps.removeFirst()) + } + var requests: [GitWorkspaceCommitRequest] { lock.lock(); defer { lock.unlock() }; return recordedRequests } + var stepCount: Int { lock.lock(); defer { lock.unlock() }; return recordedSteps } +} diff --git a/macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift b/macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift index 1085f6542..99c4aa203 100644 --- a/macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift +++ b/macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift @@ -608,6 +608,75 @@ struct AgentConversationFeatureModelTests { #expect((connection.commands.last?["files"] as? [[String: String]])?.count == 1) } + @Test + func subscriptionWaitsForUserLoginAndRetainsOnlySameAccountQuota() async throws { + let transport = TestAgentTransport() + let feature = AgentConnectionModel(transport: transport) + let configuration = AgentLaunchConfiguration(agentID: "codex-acp", command: "", arguments: [], + workspaceURL: URL(fileURLWithPath: "/example/project"), dataDirectory: URL(fileURLWithPath: "/example/data"), + providerProtocol: "", providerEndpoint: "", apiKey: "", providerName: "", model: "", + allowsInsecureHTTP: false, authentication: .codexSubscription) + do { + try feature.connect(configuration: configuration) + let connection = try #require(transport.connections.first) + try feature.receive(event("authenticationRequired")) + #expect(feature.connectionState == .authenticationRequired) + feature.refreshQuota() + #expect(connection.commands.isEmpty) + feature.authenticate() + #expect(connection.commands.last?["kind"] as? String == "authenticate") + feature.authenticate() + #expect(connection.commands.count == 1) + try feature.receive(event("account")) + try feature.receive(event("ready")) + feature.refreshQuota() + #expect(connection.commands.last?["kind"] as? String == "refreshQuota") + try feature.receive(event("quota")) + #expect(feature.subscriptionQuota?.mostUsedWindow?.usedPercent == 68) + let previous = feature.subscriptionQuota + try feature.receive(event("quotaFailed")) + #expect(feature.subscriptionQuota == previous) + #expect(feature.quotaFailure == "timeout") + try feature.receive(event("quotaFailed", ["code": "accountChanged"])) + #expect(feature.subscriptionQuota == nil) + try feature.receive(event("quota")) + await feature.stop() + #expect(feature.subscriptionQuota == nil) + #expect(feature.subscriptionEmail == nil) + try feature.receive(event("quota")) + #expect(feature.subscriptionQuota == nil) + #expect(connection.closeCount == 1) + try feature.connect(configuration: configuration) + try feature.receive(event("authenticationRequired")) + feature.authenticate() + await feature.cancelAuthentication() + guard case .failed = feature.connectionState else { + Issue.record("Cancelled login must not enter the auto-connecting idle view") + return + } + #expect(transport.connections.count == 2) + #expect(transport.connections[1].closeCount == 1) + } catch { await feature.stop(); throw error } + } + + @Test + func apiKeyConnectionsNeverRequestOrDisplaySubscriptionQuota() async throws { + let (feature, connection) = try connectedFeature() + do { + let before = connection.commands.count + feature.refreshQuota() + feature.authenticate() + try feature.receive(event("quota")) + try feature.receive(event("account")) + try feature.receive(event("authenticationRequired")) + #expect(connection.commands.count == before) + #expect(feature.subscriptionQuota == nil) + #expect(feature.subscriptionEmail == nil) + #expect(feature.connectionState == .ready) + } catch { await feature.stop(); throw error } + await feature.stop() + } + private func connectedFeature() throws -> (AgentConnectionModel, TestAgentConnection) { let transport = TestAgentTransport() let feature = AgentConnectionModel(transport: transport) diff --git a/macos/Tests/LitheTests/AgentConversationPresentationTests.swift b/macos/Tests/LitheTests/AgentConversationPresentationTests.swift index 12e78d52d..9d1a82bd9 100644 --- a/macos/Tests/LitheTests/AgentConversationPresentationTests.swift +++ b/macos/Tests/LitheTests/AgentConversationPresentationTests.swift @@ -7,6 +7,23 @@ import Testing @MainActor @Suite("Agent conversation presentation") struct AgentConversationPresentationTests { + @Test + func subscriptionQuotaPreservesWindowLengthsUnknownUsageAndStaleness() throws { + let windows: [[String: Any]] = [ + ["id": "weekly", "name": "codex", "limitSeconds": 604800, "usedPercent": 68, "resetsAt": 1800000200], + ["id": "short", "name": "codex", "limitSeconds": 18000, "usedPercent": NSNull()] + ] + let value: [String: Any] = ["windows": windows, "fetchedAt": 1800000000] + let snapshot = try #require(AgentSubscriptionQuota.parse(value)) + #expect(AgentSubscriptionQuotaPresentation.duration(snapshot.windows[0].limitSeconds) == "7d") + #expect(AgentSubscriptionQuotaPresentation.duration(snapshot.windows[1].limitSeconds) == "5h") + #expect(snapshot.mostUsedWindow?.id == "weekly") + #expect(snapshot.windows[1].usedPercent == nil) + #expect(!snapshot.isStale(at: Date(timeIntervalSince1970: 1800000100))) + #expect(snapshot.isStale(at: Date(timeIntervalSince1970: 1800000121))) + #expect(AgentSubscriptionQuota.parse(["windows": [], "fetchedAt": 1] as [String: Any]) == nil) + } + @Test func contextIndicatorUsesZeroPlaceholderAndReportedCountsWhenInUse() throws { let locale = Locale(identifier: "en_US") diff --git a/macos/Tests/LitheTests/AgentProviderConfigurationTests.swift b/macos/Tests/LitheTests/AgentProviderConfigurationTests.swift index 218471d0a..a1d25a512 100644 --- a/macos/Tests/LitheTests/AgentProviderConfigurationTests.swift +++ b/macos/Tests/LitheTests/AgentProviderConfigurationTests.swift @@ -7,6 +7,24 @@ import Testing @MainActor @Suite("Agent provider configuration") struct AgentProviderConfigurationTests { + @Test func subscriptionBindingRoundTripsWithoutBecomingAnAPIProvider() throws { + let settings = AppSettings(store: ProviderSettingsStore()) + let before = settings.commitMessageAI + let configuration = AgentConfiguration(name: "Codex", providerID: nil, authentication: .codexSubscription) + settings.agentConfigurations["codex-acp"] = configuration + #expect(settings.agentProvider(for: "codex-acp") == nil) + #expect(settings.commitMessageAI == before) + let decoded = try JSONDecoder().decode(AgentConfiguration.self, from: JSONEncoder().encode(configuration)) + #expect(decoded == configuration) + #expect(decoded.isConfigured) + let legacy = try JSONDecoder().decode(AgentConfiguration.self, from: Data(#"{"name":"Codex","providerID":null}"#.utf8)) + #expect(legacy.authentication == .apiKey) + #expect(!legacy.isConfigured) + settings.setAgentProvider(nil, for: "codex-acp", name: "Codex") + #expect(settings.agentConfigurations["codex-acp"]?.authentication == .apiKey) + #expect(settings.agentConfigurations["codex-acp"]?.isConfigured == false) + } + @Test func configurationInputKeepsLiteralSyntax() { let editor = MacConfigurationTextView() #expect(!editor.isAutomaticQuoteSubstitutionEnabled) diff --git a/macos/Tests/LitheTests/AppLocalizationTests.swift b/macos/Tests/LitheTests/AppLocalizationTests.swift index 730a3a412..d49e6ca59 100644 --- a/macos/Tests/LitheTests/AppLocalizationTests.swift +++ b/macos/Tests/LitheTests/AppLocalizationTests.swift @@ -315,7 +315,37 @@ struct GitLocalizationTests { "Show worktree repositories", "Hide worktree repositories", "Copy Branch Name", "Tracking Branch", "Stop Tracking Branch", "No Remote Branches", "Soft Reset (Keep Changes Staged)", "Mixed Reset (Keep Changes Unstaged)", - "Hard Reset (Discard Changes)" + "Hard Reset (Discard Changes)", + "Review remaining steps", + "Review repository commits", + "Each repository has its own commit. Completed steps are kept if another repository fails.", + "Review and Retry Unfinished Steps…", + "Dismiss Results", + "Update parent repository references", + "Each submodule is pushed before its parent.", + "Uncommitted submodule changes", + "Commit changed files in the submodule first", + "Amend applies to repositories with selected files.", + "Commit message: %@", + "Push only", + "Commit and push", + "Committed; push pending", + "Committed and pushed", + "Waiting for submodule", + "Pending", + "Not included in the updated plan", + "Committed; push failed", + "HEAD advanced; review before continuing.", + "Could not verify the commit outcome. Review before retrying.", + "Repository needs attention", + + "Update %@/%@ after %@", + "Repository changed; review and retry", + "Committed", + "Collapse repository", + "Expand repository", + "Unstage all files in repository", + "Stage all files in repository" ] let pattern = try NSRegularExpression(pattern: #"%(?:\d+\$)?(?:lld|ld|d|@)"#) for key in keys { diff --git a/macos/Tests/LitheTests/GitChangeSectionsCacheTests.swift b/macos/Tests/LitheTests/GitChangeSectionsCacheTests.swift index 4c4618961..b31f0fa75 100644 --- a/macos/Tests/LitheTests/GitChangeSectionsCacheTests.swift +++ b/macos/Tests/LitheTests/GitChangeSectionsCacheTests.swift @@ -13,11 +13,12 @@ struct GitChangeSectionsCacheTests { private func change( _ path: String, + at repositoryRoot: URL? = nil, indexStatus: Character = " ", workTreeStatus: Character = "M" ) -> GitChange { GitChange( - repositoryRoot: root, + repositoryRoot: repositoryRoot ?? root, path: path, originalPath: nil, indexStatus: indexStatus, @@ -94,4 +95,49 @@ struct GitChangeSectionsCacheTests { #expect(filtered.displayed.map(\.path) == ["a.swift"]) } + @Test + func groupsDisplayedChangesByRepositoryInStableInputOrder() { + let secondRoot = URL(fileURLWithPath: "/tmp/second-repo") + let firstTracked = change("src/Main.swift") + let secondAdded = change( + "README.md", + at: secondRoot, + indexStatus: "?", + workTreeStatus: "?" + ) + let firstAdded = change( + "notes.md", + indexStatus: "?", + workTreeStatus: "?" + ) + let cache = GitChangeSectionsCache() + + let sections = cache.sections( + changes: [firstTracked, secondAdded, firstAdded], + conflictFilterPaths: [] + ) + + #expect(sections.repositories.map(\.root) == [root, secondRoot]) + #expect(sections.repositories[0].changes.map(\.path) == ["src/Main.swift", "notes.md"]) + #expect(sections.repositories[0].tracked.map(\.path) == ["src/Main.swift"]) + #expect(sections.repositories[0].added.map(\.path) == ["notes.md"]) + #expect(sections.repositories[1].changes.map(\.path) == ["README.md"]) + } + + @Test + func conflictFilterRemovesEmptyRepositories() { + let secondRoot = URL(fileURLWithPath: "/tmp/second-repo") + let first = change("first.swift") + let second = change("second.swift", at: secondRoot) + let cache = GitChangeSectionsCache() + + let sections = cache.sections( + changes: [first, second], + conflictFilterPaths: [second.path] + ) + + #expect(sections.repositories.map(\.root) == [secondRoot]) + #expect(sections.repositories[0].changes.map(\.path) == ["second.swift"]) + } + } diff --git a/rust/lithe-agent-host/src/lib.rs b/rust/lithe-agent-host/src/lib.rs index f9a1fca42..0c434a5c8 100644 --- a/rust/lithe-agent-host/src/lib.rs +++ b/rust/lithe-agent-host/src/lib.rs @@ -12,6 +12,7 @@ pub mod environment; pub mod install; mod prompt; mod session_defaults; +mod subscription; pub use catalog::{ModelDelivery, ProviderProtocol}; pub use prompt::PromptFile; @@ -51,8 +52,7 @@ const MAX_SESSION_LIST_PAGES: usize = 50; const STDERR_TAIL_BYTES: usize = 16 * 1024; const STDERR_TAIL_LINES: usize = 20; /// Auth method id and `_meta` key of the ACP custom model gateway extension. -/// Lithe authenticates only with user-supplied API keys through this method and -/// never triggers an agent's own account login. +/// API-key mode uses this method and never falls back to account login. const GATEWAY_AUTH_METHOD: &str = "gateway"; /// Launch configuration supplied by the owning desktop product. @@ -74,7 +74,18 @@ pub struct AgentLaunch { /// Directory holding Lithe-managed adapter installs; required with `agentId`. #[serde(default)] pub data_directory: Option, - pub provider: ProviderCredentials, + #[serde(default)] + pub authentication: AgentAuthentication, + pub provider: Option, +} + +/// Subscription access is explicit and limited to the installed Codex adapter. +#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum AgentAuthentication { + #[default] + ApiKey, + CodexSubscription, } /// User-supplied AI provider endpoint and API key. @@ -158,6 +169,8 @@ struct ResolvedLaunch { gateway: Option, /// Key to redact from diagnostics. secret: String, + /// The already detected Codex executable; never a separately downloaded runtime. + subscription_cli: Option, } #[cfg(test)] @@ -182,7 +195,10 @@ fn resolve_with( launch: AgentLaunch, find_cli: &dyn Fn(&str) -> Option, ) -> Result { - let provider = launch.provider; + if launch.authentication == AgentAuthentication::CodexSubscription { + return subscription::resolve(launch, find_cli); + } + let provider = launch.provider.ok_or("An API provider is required")?; if provider.api_key.trim().is_empty() { return Err("An API key is required".into()); } @@ -226,6 +242,7 @@ fn resolve_with( env: Vec::new(), gateway: Some(gateway(&provider)?), secret: provider.api_key, + subscription_cli: None, }); }; let agent = catalog::find(&agent_id).ok_or_else(|| format!("Unknown agent `{agent_id}`"))?; @@ -284,6 +301,7 @@ fn resolve_with( env, gateway, secret: provider.api_key, + subscription_cli: None, }) } @@ -298,6 +316,10 @@ fn resolve_with( rename_all_fields = "camelCase" )] pub enum AgentCommand { + /// Explicit user action; this may open the upstream browser login. + Authenticate, + /// Read-only snapshot, only supported on an authenticated subscription connection. + RefreshQuota, NewSession { token: String, }, @@ -349,7 +371,20 @@ pub struct AgentSessionSummary { rename_all_fields = "camelCase" )] pub enum AgentEvent { - /// Initialization and API-key authentication succeeded. + /// No local ChatGPT login is available. Opening a panel never starts browser login. + AuthenticationRequired, + Authenticating, + /// Whitelisted upstream account identity; never contains credentials. + Account { + account: subscription::Account, + }, + Quota { + snapshot: subscription::QuotaSnapshot, + }, + QuotaFailed { + code: String, + }, + /// Initialization and the selected authentication succeeded. Ready { agent_name: Option, agent_version: Option, @@ -644,6 +679,9 @@ async fn run_agent( return Err("Agent launch was cancelled".into()); } let mut command = std::process::Command::new(&launch.command); + if launch.subscription_cli.is_some() { + subscription::isolate_environment(&mut command); + } command .args(&launch.args) .current_dir(&launch.cwd) @@ -692,12 +730,14 @@ async fn run_agent( } } }); + let subscription = launch.subscription_cli.is_some(); let secret = launch.secret.clone(); let transport = ByteStreams::new(stdin.compat_write(), stdout.compat()); let result = run_connection( transport, launch.cwd, launch.gateway, + launch.subscription_cli, controls, permissions, emit, @@ -710,6 +750,11 @@ async fn run_agent( let _ = tokio::time::timeout(STOP_TIMEOUT, stderr_task).await; result.map_err(|message| { let message = redact(&message, &secret); + // Subscription credentials belong to Codex, so Lithe has no secret to + // redact. Do not surface arbitrary upstream stderr for this mode. + if subscription { + return message; + } match tail.lock().ok().and_then(|tail| tail.summary(&secret)) { Some(stderr) => format!("{message}\n\nAgent output:\n{stderr}"), None => message, @@ -772,6 +817,7 @@ async fn run_connection( transport: ByteStreams, cwd: PathBuf, gateway: Option, + subscription_cli: Option, mut controls: async_mpsc::UnboundedReceiver, permissions: PendingPermissions, emit: Emit, @@ -780,6 +826,7 @@ where OB: futures::io::AsyncWrite + Send + 'static, IB: futures::io::AsyncRead + Send + 'static, { + let (auth_tx, mut auth_rx) = tokio::sync::watch::channel(None::); let turns: RunningTurns = Arc::new(Mutex::new(HashMap::new())); let updates = emit.clone(); let requests = emit.clone(); @@ -789,6 +836,13 @@ where let stopped = stop_requested.clone(); let result = Client .builder() + .on_receive_notification( + async move |notification: subscription::AuthNotification, _| { + auth_tx.send_replace(Some(notification.auth_status)); + Ok(()) + }, + agent_client_protocol::on_receive_notification!(), + ) .on_receive_notification( async move |notification: SessionNotification, _| { if let Ok(update) = serde_json::to_value(notification.update) { @@ -887,8 +941,7 @@ where ) .await .map_err(|_| internal("The Agent did not finish initialization in time"))??; - // Only agents that take the key over ACP sign in here; the others - // received it in their environment. Account logins are never used. + // API-key routing is explicit and never falls back to account login. if let Some(gateway) = &gateway { if !initialized .auth_methods @@ -908,6 +961,17 @@ where .await .map_err(|_| internal("The Agent did not finish API key sign-in in time"))??; } + let account = if subscription_cli.is_some() { + if !initialized.auth_methods.iter().any(|method| method.id().0.as_ref() == "chat-gpt") { + return Err(internal("This Codex adapter does not support ChatGPT sign-in")); + } + let Some(account) = subscription::authenticate(&connection, &mut auth_rx, &mut controls, &emit).await? else { + stopped.store(true, Ordering::SeqCst); + return Ok(()); + }; + emit(AgentEvent::Account { account: account.clone() }); + Some(account) + } else { None }; let agent = initialized.agent_info.as_ref(); emit(AgentEvent::Ready { agent_name: agent.map(|info| info.name.clone()), @@ -920,6 +984,8 @@ where .is_some(), }); + let quota_running = Arc::new(AtomicBool::new(false)); + let mut last_quota = None::; let generations = AtomicU64::new(0); let mut tasks = JoinSet::new(); let (cancel_deadline_tx, mut cancel_deadlines) = async_mpsc::unbounded_channel(); @@ -929,6 +995,15 @@ where Some(control) => control, None => break, }, + changed = auth_rx.changed(), if account.is_some() => { + if changed.is_err() { return Err(internal("Codex account reporting stopped")); } + let status = auth_rx.borrow_and_update().clone(); + if status.as_ref().is_none_or(|status| status.kind != "account" || + status.account.as_ref().and_then(|a| a.email.as_ref()) != account.as_ref().and_then(|a| a.email.as_ref())) { + return Err(internal("The local Codex account changed. Reconnect to use the current account.")); + } + continue; + } deadline = cancel_deadlines.recv() => { let Some((session_id, generation)) = deadline else { continue }; if turns.lock().is_ok_and(|turns| turns.get(&session_id).is_some_and( @@ -948,6 +1023,22 @@ where Control::Command(command) => command, }; match command { + AgentCommand::Authenticate => {} + AgentCommand::RefreshQuota => { + let (Some(cli), Some(account)) = (&subscription_cli, &account) else { continue; }; + if quota_running.load(Ordering::SeqCst) || last_quota.is_some_and(|time| time.elapsed() < subscription::QUOTA_INTERVAL) { continue; } + quota_running.store(true, Ordering::SeqCst); + last_quota = Some(tokio::time::Instant::now()); + let (cli, cwd, account, emit, running) = (cli.clone(), cwd.clone(), account.clone(), emit.clone(), quota_running.clone()); + tasks.spawn(async move { + let result = subscription::read_quota(&cli, &cwd, &account).await; + emit(match result { + Ok(snapshot) => AgentEvent::Quota { snapshot }, + Err(code) => AgentEvent::QuotaFailed { code: code.into() }, + }); + running.store(false, Ordering::SeqCst); + }); + } AgentCommand::NewSession { token } => { let connection = connection.clone(); let emit = emit.clone(); @@ -1106,6 +1197,7 @@ where let _ = connection.send_notification(CancelNotification::new(session_id)); } tasks.abort_all(); + while tasks.join_next().await.is_some() {} Ok(()) }) .await diff --git a/rust/lithe-agent-host/src/subscription.rs b/rust/lithe-agent-host/src/subscription.rs new file mode 100644 index 000000000..33dd8b387 --- /dev/null +++ b/rust/lithe-agent-host/src/subscription.rs @@ -0,0 +1,400 @@ +//! Codex-owned subscription authentication and bounded, read-only quota probes. +//! +//! The pinned ACP adapter does not expose structured rate limits. A short-lived +//! official App Server reads them without Lithe opening any credential file. +//! No thread, prompt, model request, or persistent Lithe cache is created. + +use super::*; +use serde_json::{json, Value}; +use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader}; + +// Official Codex ChatGPT route, matching the upstream default. This is passed +// to Codex configuration only; Lithe never issues authenticated HTTP requests. +const CHATGPT_BASE_URL: &str = "https://chatgpt.com/backend-api/"; + +const PROBE_TIMEOUT: Duration = Duration::from_secs(20); +const MAX_REPLY_BYTES: u64 = 1024 * 1024; +pub(super) const LOGIN_TIMEOUT: Duration = Duration::from_secs(300); +pub(super) const QUOTA_INTERVAL: Duration = Duration::from_secs(60); + +/// Account identity reported by Codex, used only in memory and never logged. +#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +pub struct Account { + pub email: Option, + pub plan: Option, +} + +/// A real quota window. Unknown utilization is never interpreted as zero. +#[derive(Debug, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct QuotaWindow { + pub id: String, + pub name: String, + pub limit_seconds: u64, + pub used_percent: Option, + /// Unix seconds, as reported by Codex. + pub resets_at: Option, +} + +/// Last successful read; consumers retain it as stale on transient failures. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct QuotaSnapshot { + pub windows: Vec, + /// Unix seconds at completion of the bounded query. + pub fetched_at: u64, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(super) struct AuthStatus { + pub kind: String, + pub account: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, agent_client_protocol::JsonRpcNotification)] +#[notification(method = "_auth/status_update")] +#[serde(rename_all = "camelCase")] +pub(super) struct AuthNotification { + pub auth_status: AuthStatus, +} + +/// Return None on an explicit stop. No panel lifecycle event starts browser login. +pub(super) async fn authenticate( + connection: &ConnectionTo, + status: &mut tokio::sync::watch::Receiver>, + controls: &mut async_mpsc::UnboundedReceiver, + emit: &Emit, +) -> Result, agent_client_protocol::Error> { + let initial = tokio::select! { + result = wait_status(status, false) => result?, + _ = wait_for_stop(controls) => return Ok(None), + }; + if initial.kind != "account" { + emit(AgentEvent::AuthenticationRequired); + loop { + match controls.recv().await { + Some(Control::Command(AgentCommand::Authenticate)) => break, + Some(Control::Stop) | None => return Ok(None), + _ => {} + } + } + emit(AgentEvent::Authenticating); + tokio::select! { + result = tokio::time::timeout(LOGIN_TIMEOUT, + connection.send_request(AuthenticateRequest::new("chat-gpt")).block_task()) => { + result.map_err(|_| internal("ChatGPT sign-in timed out. Try again."))? + .map_err(|_| internal("ChatGPT sign-in did not complete. Try again."))?; + } + _ = wait_for_stop(controls) => return Ok(None), + } + } + // Notifications and the authenticate response may be dispatched separately. + // Wait for the confirmation instead of assuming its handler ran first. + let confirmed = tokio::select! { + result = wait_status(status, true) => result?, + _ = wait_for_stop(controls) => return Ok(None), + }; + Ok(Some(confirmed.account.unwrap_or_default())) +} + +async fn wait_for_stop(controls: &mut async_mpsc::UnboundedReceiver) { + while let Some(control) = controls.recv().await { + if matches!(control, Control::Stop) { + return; + } + } +} + +async fn wait_status( + status: &mut tokio::sync::watch::Receiver>, + require_account: bool, +) -> Result { + tokio::time::timeout(HANDSHAKE_TIMEOUT, async { + loop { + if let Some(value) = status.borrow_and_update().clone() { + if !require_account || value.kind == "account" { + return Ok(value); + } + } + status + .changed() + .await + .map_err(|_| internal("Codex account reporting stopped"))?; + } + }) + .await + .map_err(|_| internal("Codex did not confirm its account in time"))? +} + +pub(super) fn resolve( + launch: AgentLaunch, + find_cli: &dyn Fn(&str) -> Option, +) -> Result { + if launch.agent_id.as_deref() != Some("codex-acp") || launch.provider.is_some() { + return Err( + "Official subscription access is only available for Codex, without an API provider" + .into(), + ); + } + if !launch.cwd.is_absolute() { + return Err("The workspace path must be absolute".into()); + } + let data = launch + .data_directory + .ok_or("The Agent install directory is not configured")?; + let agent = catalog::find("codex-acp").ok_or("Codex is unavailable")?; + if install::installed_version(&data, agent).is_none() { + return Err("Codex is not installed. Install it in Agent Settings.".into()); + } + let cli = agent.cli.as_ref().ok_or("Codex CLI is unavailable")?; + let detected = find_cli(cli.command); + if let Some(issue) = install::cli_issue(cli, detected.as_ref()) { + return Err(issue); + } + let executable = detected.ok_or("Codex CLI is unavailable")?.path; + Ok(ResolvedLaunch { + command: install::installed_command(&data, agent), + args: launch.args, + cwd: launch.cwd, + // Override only this process's routing, preserving the user's CLI files, + // MCP servers and skills. Session model choices still come from Codex. + env: vec![ + ( + cli.path_env.into(), + executable.to_string_lossy().into_owned(), + ), + ("MODEL_PROVIDER".into(), "openai".into()), + ( + "CODEX_CONFIG".into(), + json!({ + "model_provider": "openai", + // Empty disables a saved openai_base_url override in Codex. + "openai_base_url": "", + "chatgpt_base_url": CHATGPT_BASE_URL, + }) + .to_string(), + ), + ], + gateway: None, + secret: String::new(), + subscription_cli: Some(executable), + }) +} + +/// Prevent inherited API overrides from silently changing the selected billing source. +/// Codex still owns account storage and refresh; CODEX_HOME remains untouched. +pub(super) fn isolate_environment(command: &mut std::process::Command) { + for name in [ + "OPENAI_API_KEY", + "CODEX_API_KEY", + "OPENAI_BASE_URL", + "CODEX_ACCESS_TOKEN", + "OPENAI_IDENTITY_TOKEN_FILE", + "OPENAI_WORKLOAD_IDENTITY_PROVIDER", + "MODEL_PROVIDER", + "CODEX_CONFIG", + "DEFAULT_AUTH_REQUEST", + "APP_SERVER_LOGS", + ] { + command.env_remove(name); + } +} + +/// Own the probe tree even if its future is cancelled during a read or cleanup. +struct ProbeProcess(tokio::process::Child); + +impl Drop for ProbeProcess { + fn drop(&mut self) { + if let Some(pid) = self.0.id() { + force_kill_tree(pid); + } + } +} + +pub(super) async fn read_quota( + command: &Path, + cwd: &Path, + account: &Account, +) -> Result { + let mut process = std::process::Command::new(command); + isolate_environment(&mut process); + process + .args([ + "-c", + "model_provider=\"openai\"", + "-c", + "openai_base_url=\"\"", + "-c", + ]) + .arg(format!("chatgpt_base_url=\"{CHATGPT_BASE_URL}\"")) + .arg("app-server") + .current_dir(cwd) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + if let Some(path) = child_path(command, environment::search_path()) { + process.env("PATH", path); + } + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + process.process_group(0); + } + let mut process = tokio::process::Command::from(process); + process.kill_on_drop(true); + let mut child = ProbeProcess(process.spawn().map_err(|_| "unavailable")?); + let result = async { + let mut input = child.0.stdin.take().ok_or("unavailable")?; + let mut output = BufReader::new(child.0.stdout.take().ok_or("unavailable")?); + exchange(&mut input, &mut output, account).await + }; + let result = tokio::time::timeout(PROBE_TIMEOUT, result) + .await + .unwrap_or(Err("timeout")); + terminate_tree(&mut child.0).await; + result +} + +async fn exchange( + input: &mut (impl AsyncWrite + Unpin), + output: &mut (impl AsyncBufRead + Unpin), + expected: &Account, +) -> Result { + request( + input, + output, + 1, + "initialize", + json!({"clientInfo": {"name": "lithe-quota", "version": "1"}}), + ) + .await?; + input + .write_all(b"{\"method\":\"initialized\"}\n") + .await + .map_err(|_| "unavailable")?; + let account = request( + input, + output, + 2, + "account/read", + json!({"refreshToken": false}), + ) + .await?; + check_account(&account, expected)?; + let limits = request(input, output, 3, "account/rateLimits/read", Value::Null).await?; + // A CLI login in another terminal during the probe must not cross account data. + let account = request( + input, + output, + 4, + "account/read", + json!({"refreshToken": false}), + ) + .await?; + check_account(&account, expected)?; + Ok(QuotaSnapshot { + windows: windows(&limits)?, + fetched_at: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|_| "unavailable")? + .as_secs(), + }) +} + +fn check_account(value: &Value, expected: &Account) -> Result<(), &'static str> { + let account = &value["account"]; + if account["type"] != "chatgpt" { + return Err("unauthorized"); + } + let email = account["email"].as_str().filter(|value| !value.is_empty()); + if email.is_none() || expected.email.as_deref() != email { + return Err("accountChanged"); + } + Ok(()) +} + +async fn request( + input: &mut (impl AsyncWrite + Unpin), + output: &mut (impl AsyncBufRead + Unpin), + id: u64, + method: &str, + params: Value, +) -> Result { + let line = format!( + "{}\n", + json!({"id": id, "method": method, "params": params}) + ); + input + .write_all(line.as_bytes()) + .await + .map_err(|_| "unavailable")?; + loop { + let mut line = Vec::new(); + output + .take(MAX_REPLY_BYTES + 1) + .read_until(b'\n', &mut line) + .await + .map_err(|_| "unavailable")?; + if line.is_empty() { + return Err("unavailable"); + } + if line.len() as u64 > MAX_REPLY_BYTES { + return Err("unparsable"); + } + let reply: Value = serde_json::from_slice(&line).map_err(|_| "unparsable")?; + if reply.get("id").and_then(Value::as_u64) != Some(id) { + continue; + } + // Never expose upstream error text: it may contain paths or account data. + if reply.get("error").is_some() { + return Err("unavailable"); + } + return reply.get("result").cloned().ok_or("unparsable"); + } +} + +/// Preserve provider window lengths, including Pro accounts whose primary is weekly. +fn windows(value: &Value) -> Result, &'static str> { + let mut snapshots = std::collections::BTreeMap::new(); + if let Some(entries) = value["rateLimitsByLimitId"].as_object() { + for (id, snapshot) in entries { + if snapshot.is_object() { + snapshots.insert(id.as_str(), snapshot); + } + } + } + if snapshots.is_empty() { + let snapshot = &value["rateLimits"]; + snapshots.insert(snapshot["limitId"].as_str().unwrap_or("codex"), snapshot); + } + let mut windows = Vec::new(); + for (id, snapshot) in snapshots { + for field in ["primary", "secondary"] { + let window = &snapshot[field]; + let Some(seconds) = window["windowDurationMins"] + .as_u64() + .and_then(|m| m.checked_mul(60)) + .filter(|s| *s > 0) + else { + continue; + }; + windows.push(QuotaWindow { + id: format!("{id}:{field}"), + name: snapshot["limitName"].as_str().unwrap_or(id).to_owned(), + limit_seconds: seconds, + used_percent: window["usedPercent"] + .as_f64() + .filter(|p| p.is_finite() && (0.0..=100.0).contains(p)), + resets_at: window["resetsAt"].as_i64().filter(|s| *s > 0), + }); + } + } + if windows.is_empty() { + Err("unparsable") + } else { + Ok(windows) + } +} + +#[cfg(test)] +mod tests; diff --git a/rust/lithe-agent-host/src/subscription/tests.rs b/rust/lithe-agent-host/src/subscription/tests.rs new file mode 100644 index 000000000..388826cb0 --- /dev/null +++ b/rust/lithe-agent-host/src/subscription/tests.rs @@ -0,0 +1,166 @@ +//! Regression coverage for account-scoped subscription observations. +use super::*; + +#[test] +fn windows_use_reported_duration_and_preserve_unknown_usage() { + let result = windows(&json!({"rateLimits": { + "primary": {"windowDurationMins":10080,"usedPercent":70,"resetsAt":1800000000}, + "secondary": {"windowDurationMins":300,"usedPercent":null} + }})) + .unwrap(); + assert_eq!(result[0].limit_seconds, 604800); + assert_eq!(result[1].limit_seconds, 18000); + assert_eq!(result[1].used_percent, None); +} + +#[test] +fn missing_duration_and_invalid_usage_never_become_unused_windows() { + assert!(windows(&json!({"rateLimits":{"primary":{"usedPercent":5}}})).is_err()); + let result = + windows(&json!({"rateLimits":{"primary":{"windowDurationMins":300,"usedPercent":101}}})) + .unwrap(); + assert_eq!(result[0].used_percent, None); +} + +#[test] +fn multiple_buckets_are_ordered_and_do_not_duplicate_legacy_snapshot() { + let window = json!({"primary":{"windowDurationMins":300,"usedPercent":50}}); + let result = + windows(&json!({"rateLimits":window,"rateLimitsByLimitId":{"z":window,"a":window}})) + .unwrap(); + assert_eq!(result.len(), 2); + assert_eq!(result[0].id, "a:primary"); +} + +#[test] +fn quota_requires_the_same_subscription_identity() { + let expected = Account { + email: Some("person@example.test".into()), + plan: Some("plus".into()), + }; + assert!(check_account( + &json!({"account":{"type":"chatgpt","email":"person@example.test"}}), + &expected + ) + .is_ok()); + assert_eq!( + check_account(&json!({"account":{"type":"apiKey"}}), &expected), + Err("unauthorized") + ); + assert_eq!( + check_account( + &json!({"account":{"type":"chatgpt","email":"other@example.test"}}), + &expected + ), + Err("accountChanged") + ); + assert_eq!( + check_account(&json!({"account":{"type":"chatgpt"}}), &expected), + Err("accountChanged") + ); +} + +#[test] +fn subscription_environment_removes_api_overrides_without_changing_cli_home() { + let mut command = std::process::Command::new("fixture-codex"); + command + .env("CODEX_HOME", "/fixture/codex-home") + .env("OPENAI_API_KEY", "fixture-key"); + isolate_environment(&mut command); + let entries: std::collections::BTreeMap<_, _> = command.get_envs().collect(); + assert_eq!( + entries.get(std::ffi::OsStr::new("OPENAI_API_KEY")), + Some(&None) + ); + assert_eq!( + entries.get(std::ffi::OsStr::new("CODEX_HOME")), + Some(&Some(std::ffi::OsStr::new("/fixture/codex-home"))) + ); +} + +#[tokio::test] +async fn probe_only_reads_identity_and_limits_and_rechecks_account() { + let (client, server) = tokio::io::duplex(8192); + let (reader, mut writer) = tokio::io::split(client); + let mut reader = BufReader::new(reader); + let expected = Account { + email: Some("person@example.test".into()), + plan: None, + }; + let peer = async { + let (reader, mut writer) = tokio::io::split(server); + let mut lines = BufReader::new(reader).lines(); + for method in [ + "initialize", + "initialized", + "account/read", + "account/rateLimits/read", + "account/read", + ] { + let request: Value = + serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap(); + assert_eq!(request["method"], method); + if method == "initialized" { + continue; + } + let result = match method { + "account/read" => { + json!({"account":{"type":"chatgpt","email":"person@example.test"}}) + } + "account/rateLimits/read" => { + json!({"rateLimits":{"primary":{"usedPercent":25,"windowDurationMins":300}}}) + } + _ => json!({}), + }; + writer + .write_all(format!("{}\n", json!({"id":request["id"],"result":result})).as_bytes()) + .await + .unwrap(); + } + }; + let (result, ()) = tokio::time::timeout(Duration::from_secs(5), async { + tokio::join!(exchange(&mut writer, &mut reader, &expected), peer) + }) + .await + .expect("bounded in-memory protocol exchange"); + assert_eq!(result.unwrap().windows[0].used_percent, Some(25.0)); +} + +#[cfg(unix)] +#[tokio::test] +async fn dropping_a_probe_closes_descendant_pipes() { + use std::os::unix::process::CommandExt; + let mut command = std::process::Command::new("sh"); + command + .args(["-c", "sh -c 'printf ready; cat'; :"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .process_group(0); + let mut command = tokio::process::Command::from(command); + command.kill_on_drop(true); + let mut child = ProbeProcess(command.spawn().unwrap()); + let input = child.0.stdin.take().unwrap(); + let mut output = child.0.stdout.take().unwrap(); + let mut ready = [0; 5]; + tokio::time::timeout(Duration::from_secs(5), output.read_exact(&mut ready)) + .await + .expect("bounded child readiness") + .unwrap(); + assert_eq!(&ready, b"ready"); + // Keep stdin open: only process-tree cleanup can release the inherited stdout. + drop(child); + let mut remaining = Vec::new(); + tokio::time::timeout(Duration::from_secs(5), output.read_to_end(&mut remaining)) + .await + .expect("probe cancellation killed descendants") + .unwrap(); + drop(input); +} + +#[tokio::test(start_paused = true)] +async fn missing_account_confirmation_has_a_bounded_deadline() { + let (_sender, mut status) = tokio::sync::watch::channel(None); + let error = wait_status(&mut status, true).await.unwrap_err(); + assert!(error.to_string().contains("confirm its account in time")); +} diff --git a/rust/lithe-agent-host/src/tests.rs b/rust/lithe-agent-host/src/tests.rs index 796546da3..1c9279cac 100644 --- a/rust/lithe-agent-host/src/tests.rs +++ b/rust/lithe-agent-host/src/tests.rs @@ -39,7 +39,8 @@ fn custom_launch(command: &str, provider: ProviderCredentials) -> AgentLaunch { args: vec![], cwd: std::env::temp_dir(), data_directory: None, - provider, + provider: Some(provider), + authentication: AgentAuthentication::ApiKey, } } @@ -111,6 +112,10 @@ impl Drop for Harness { impl Harness { fn start() -> Self { + Self::start_with(false) + } + + fn start_with(subscription: bool) -> Self { let (client, peer) = tokio::io::duplex(64 * 1024); let (client_reader, client_writer) = tokio::io::split(client); let (peer_reader, peer_writer) = tokio::io::split(peer); @@ -120,7 +125,8 @@ impl Harness { let connection = tokio::spawn(run_connection( ByteStreams::new(client_writer.compat_write(), client_reader.compat()), std::env::temp_dir(), - gateway(), + if subscription { None } else { gateway() }, + subscription.then(|| PathBuf::from("/fixture/codex")), receiver, permissions.clone(), Arc::new(move |event| { @@ -999,7 +1005,8 @@ fn catalog_agents_resolve_to_their_install_and_key_delivery() { args: vec![], cwd: std::env::temp_dir(), data_directory: Some(data.clone()), - provider, + provider: Some(provider), + authentication: AgentAuthentication::ApiKey, }; let anthropic = ProviderCredentials { protocol: ProviderProtocol::AnthropicMessages, @@ -1023,6 +1030,33 @@ fn catalog_agents_resolve_to_their_install_and_key_delivery() { fake_install(&data, "codex-acp"); fake_install(&data, "claude-acp"); + let mut subscription = launch("codex-acp", provider()); + subscription.authentication = AgentAuthentication::CodexSubscription; + subscription.provider = None; + let subscription = resolve(subscription).unwrap(); + assert!(subscription.gateway.is_none()); + assert!(subscription.secret.is_empty()); + assert_eq!( + subscription.subscription_cli, + Some("/opt/example/bin/codex".into()) + ); + let config: Value = serde_json::from_str( + &subscription + .env + .iter() + .find(|(name, _)| name == "CODEX_CONFIG") + .unwrap() + .1, + ) + .unwrap(); + assert_eq!(config["model_provider"], "openai"); + assert_eq!(config["openai_base_url"], ""); + assert_eq!( + config["chatgpt_base_url"], + "https://chatgpt.com/backend-api/" + ); + assert!(config.get("model").is_none()); + let codex = resolve(launch("codex-acp", provider())).unwrap(); assert!(codex.command.ends_with("node_modules/.bin/codex-acp") || cfg!(windows)); // The adapter drives the user's own Codex; the key never enters the environment. @@ -1275,3 +1309,126 @@ async fn invalid_file_reference_does_not_reserve_or_finish_a_turn() { )); harness.stop().await.expect("owned connection stopped"); } + +/// The adapter owns login and reports identity before any session can start. +async fn subscription_initialize(harness: &mut Harness, account: bool) { + let initialize = harness.agent.expect("initialize").await; + harness + .agent + .reply( + &initialize, + json!({ + "protocolVersion": 1, + "authMethods": [{"id":"chat-gpt","name":"ChatGPT"}], + "agentCapabilities": {"_meta":{"authStatus":{}}} + }), + ) + .await; + subscription_identity(harness, account).await; +} + +async fn subscription_identity(harness: &mut Harness, account: bool) { + harness.agent.write(json!({"jsonrpc":"2.0","method":"_auth/status_update","params":{ + "authStatus": if account { + json!({"kind":"account","label":"ChatGPT Plus","account":{"email":"person@example.test","plan":"plus"}}) + } else { json!({"kind":"none","label":"Not logged in"}) } + }})).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn subscription_reuses_account_without_api_key_or_browser_login() { + let mut harness = Harness::start_with(true); + subscription_initialize(&mut harness, true).await; + assert!(matches!(harness.event().await, AgentEvent::Account { .. })); + assert!(matches!(harness.event().await, AgentEvent::Ready { .. })); + harness.send(json!({"kind":"newSession","token":"new"})); + // No authenticate call is needed for an already reported account. + let request = harness.agent.expect("session/new").await; + harness + .agent + .reply(&request, json!({"sessionId":"subscription-session"})) + .await; + assert!(matches!( + harness.event().await, + AgentEvent::SessionCreated { .. } + )); + assert_eq!(harness.stop().await, Ok(())); +} + +#[tokio::test(flavor = "current_thread")] +async fn subscription_login_requires_explicit_command_and_confirmed_account() { + let mut harness = Harness::start_with(true); + subscription_initialize(&mut harness, false).await; + assert!(matches!( + harness.event().await, + AgentEvent::AuthenticationRequired + )); + harness.send(json!({"kind":"authenticate"})); + assert!(matches!(harness.event().await, AgentEvent::Authenticating)); + let request = harness.agent.expect("authenticate").await; + assert_eq!(request["params"], json!({"methodId":"chat-gpt"})); + // A response can be dispatched before its separate account notification. + harness.agent.reply(&request, json!({})).await; + subscription_identity(&mut harness, true).await; + assert!(matches!(harness.event().await, AgentEvent::Account { .. })); + assert!(matches!(harness.event().await, AgentEvent::Ready { .. })); + assert_eq!(harness.stop().await, Ok(())); +} + +#[tokio::test(flavor = "current_thread")] +async fn subscription_login_can_stop_without_waiting_for_browser() { + let mut harness = Harness::start_with(true); + subscription_initialize(&mut harness, false).await; + assert!(matches!( + harness.event().await, + AgentEvent::AuthenticationRequired + )); + harness.send(json!({"kind":"authenticate"})); + assert!(matches!(harness.event().await, AgentEvent::Authenticating)); + harness.agent.expect("authenticate").await; + assert_eq!(harness.stop().await, Ok(())); +} + +#[tokio::test(flavor = "current_thread")] +async fn subscription_account_change_disconnects_before_reusing_old_identity() { + let mut harness = Harness::start_with(true); + subscription_initialize(&mut harness, true).await; + harness.event().await; + harness.event().await; + subscription_identity(&mut harness, false).await; + let result = tokio::time::timeout(WAIT, &mut harness.connection) + .await + .expect("bounded account change") + .unwrap(); + assert!(result.unwrap_err().contains("account changed")); +} + +#[test] +fn subscription_rejects_claude_custom_agents_and_ambiguous_api_credentials() { + for agent in [None, Some("claude-acp"), Some("codex-acp")] { + let mut launch = custom_launch("fixture-agent", provider()); + launch.authentication = AgentAuthentication::CodexSubscription; + launch.agent_id = agent.map(str::to_owned); + assert!(resolve_with(launch, &|_| None) + .err() + .unwrap() + .contains("only available for Codex")); + } +} + +#[tokio::test(flavor = "current_thread")] +async fn subscription_login_deadline_does_not_leave_a_waiting_connection() { + let mut harness = Harness::start_with(true); + subscription_initialize(&mut harness, false).await; + harness.event().await; + harness.send(json!({"kind":"authenticate"})); + harness.event().await; + harness.agent.expect("authenticate").await; + tokio::time::pause(); + tokio::time::advance(subscription::LOGIN_TIMEOUT + Duration::from_secs(1)).await; + let result = tokio::time::timeout(WAIT, &mut harness.connection) + .await + .expect("bounded login deadline") + .unwrap(); + assert!(result.unwrap_err().contains("sign-in timed out")); +} diff --git a/rust/lithe-agent-host/tests/real_agent.rs b/rust/lithe-agent-host/tests/real_agent.rs index 0c9300b46..0a2210786 100644 --- a/rust/lithe-agent-host/tests/real_agent.rs +++ b/rust/lithe-agent-host/tests/real_agent.rs @@ -148,14 +148,15 @@ fn open(workspace: &std::path::Path) -> Session { .unwrap_or_default(), cwd: workspace.to_path_buf(), data_directory, - provider: ProviderCredentials { + authentication: lithe_agent_host::AgentAuthentication::ApiKey, + provider: Some(ProviderCredentials { protocol: ProviderProtocol::Responses, base_url: required("LITHE_ACP_E2E_BASE_URL"), api_key: required("LITHE_ACP_E2E_API_KEY"), name: Some("Lithe end-to-end test".into()), model: std::env::var("LITHE_ACP_E2E_MODEL").ok(), allow_insecure_http: false, - }, + }), }; let handle = AgentHandle::open( launch, diff --git a/rust/lithe-core/src/git/commit_state.rs b/rust/lithe-core/src/git/commit_state.rs new file mode 100644 index 000000000..7c7320dc6 --- /dev/null +++ b/rust/lithe-core/src/git/commit_state.rs @@ -0,0 +1,244 @@ +//! Shared commit preconditions and exact submodule pointer updates. + +use super::{execute_git, execute_git_readonly, validate_paths, validate_root, GitStatusRequest}; +use crate::protocol::{CoreError, ErrorCode}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Immutable Git state reviewed before a workspace commit or retry. +pub struct GitCommitState { + pub head: Option, + pub branch: Option, + /// Git's NUL-delimited index entries, including object IDs and conflict stages. + pub index_entries: String, + pub gitlinks: Vec, + pub staged_paths: Vec, + pub conflicted_paths: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +/// An exact submodule commit recorded at a parent-relative path. +pub struct GitCommitGitlink { + pub path: String, + pub revision: String, +} + +fn read(root: &str, arguments: &[&str]) -> Result { + let result = execute_git_readonly( + root, + &arguments.iter().map(|s| s.to_string()).collect::>(), + None, + )?; + if result.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Could not inspect commit state") + .with_details(result.output), + ); + } + Ok(result.stdout) +} + +fn ensure_repository_root(root: &str) -> Result<(), CoreError> { + // Git walks up to a parent repository when a nested .git disappears. A + // reviewed workspace root must still own its index before any guarded write. + let actual = read(root, &["rev-parse", "--show-toplevel"])?; + let actual = actual.strip_suffix('\n').unwrap_or(&actual); + if validate_root(actual)? != validate_root(root)? { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Repository boundary changed; refresh the workspace", + )); + } + Ok(()) +} + +/// Reads HEAD and index through Git, without refreshing or modifying the index. +pub fn inspect(request: GitStatusRequest) -> Result { + let root = validate_root(&request.root)?; + inspect_root(&root) +} + +/// Confirms an unborn branch without treating corrupt refs as empty history. +pub(super) fn is_unborn(root: &str) -> Result { + head_state(root).map(|(head, _)| head.is_none()) +} + +fn head_state(root: &str) -> Result<(Option, Option), CoreError> { + // `--verify HEAD` cannot distinguish an unborn branch from a corrupt ref. + // show-ref's documented exit 1 means the symbolic branch does not exist. + let symbolic = execute_git_readonly( + root, + &["symbolic-ref".into(), "--quiet".into(), "HEAD".into()], + None, + )?; + let branch = if symbolic.exit_code == 0 { + Some(symbolic.stdout.trim().to_string()) + } else if symbolic.exit_code == 1 { + None + } else { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Could not inspect HEAD") + .with_details(symbolic.output), + ); + }; + let head = if let Some(branch) = &branch { + let exists = execute_git_readonly( + root, + &[ + "show-ref".into(), + "--verify".into(), + "--quiet".into(), + branch.clone(), + ], + None, + )?; + match exists.exit_code { + 0 => Some( + read(root, &["rev-parse", "--verify", "HEAD^{commit}"])? + .trim() + .to_string(), + ), + 1 => None, + _ => { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Could not inspect branch") + .with_details(exists.output), + ) + } + } + } else { + Some( + read(root, &["rev-parse", "--verify", "HEAD^{commit}"])? + .trim() + .to_string(), + ) + }; + Ok((head, branch)) +} + +pub(super) fn inspect_root(root: &str) -> Result { + ensure_repository_root(root)?; + let (head, branch) = head_state(root)?; + let index_entries = read(root, &["ls-files", "--stage", "-z"])?; + let gitlinks = index_entries + .split('\0') + .filter_map(|record| { + let (header, path) = record.split_once('\t')?; + let mut fields = header.split_whitespace(); + if fields.next()? != "160000" { + return None; + } + let revision = fields.next()?.to_string(); + if fields.next()? != "0" { + return None; + } + Some(GitCommitGitlink { + path: path.to_string(), + revision, + }) + }) + .collect(); + let conflicted_paths = index_entries + .split('\0') + .filter_map(|record| { + let (header, path) = record.split_once('\t')?; + let stage = header.split_whitespace().nth(2)?; + (stage != "0").then(|| path.to_string()) + }) + .collect::>() + .into_iter() + .collect(); + let staged_paths = read( + root, + &[ + "diff", + "--cached", + "--name-only", + "--ignore-submodules=none", + "-z", + ], + )? + .split('\0') + .filter(|path| !path.is_empty()) + .map(str::to_string) + .collect(); + Ok(GitCommitState { + head, + branch, + index_entries, + gitlinks, + staged_paths, + conflicted_paths, + }) +} + +/// Validates the reviewed state under the typed writer lease before changing +/// any parent pointers. Unrelated staged files retain their exact index content. +pub(super) fn prepare( + root: &str, + expected: &GitCommitState, + updates: &[GitCommitGitlink], +) -> Result<(), CoreError> { + if inspect_root(root)? != *expected { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Commit plan changed; review it again", + )); + } + if !expected.conflicted_paths.is_empty() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Resolve repository conflicts before committing", + )); + } + for update in updates { + validate_paths(std::slice::from_ref(&update.path))?; + if !expected + .gitlinks + .iter() + .any(|entry| entry.path == update.path) + || !matches!(update.revision.len(), 40 | 64) + || !update.revision.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid submodule pointer update", + )); + } + let child = std::path::Path::new(root).join(&update.path); + ensure_repository_root(&child.to_string_lossy())?; + let actual = read( + &child.to_string_lossy(), + &["rev-parse", "--verify", "HEAD^{commit}"], + )?; + if actual.trim() != update.revision { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Submodule changed; review the commit plan again", + )); + } + } + if !updates.is_empty() { + // One index transaction; use NUL records so spaces, tabs and newlines in + // paths remain literal. Never run `git add` on unrelated parent files. + let input = updates + .iter() + .map(|update| format!("160000 {}\t{}\0", update.revision, update.path)) + .collect::(); + let result = execute_git( + root, + &["update-index".into(), "-z".into(), "--index-info".into()], + Some(input), + )?; + if result.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not update submodule references", + ) + .with_details(result.output)); + } + } + Ok(()) +} diff --git a/rust/lithe-core/src/git/history.rs b/rust/lithe-core/src/git/history.rs index 2f912c0fe..04d1c9c94 100644 --- a/rust/lithe-core/src/git/history.rs +++ b/rust/lithe-core/src/git/history.rs @@ -215,6 +215,29 @@ pub fn history_page(request: GitHistoryPageRequest) -> Result, } #[derive(Debug, Deserialize)] @@ -418,7 +429,7 @@ pub struct GitPushTagExpectationRequest { pub object_id: String, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Default, Deserialize)] #[serde(rename_all = "camelCase")] /// Typed mutation request translated into a controlled Git invocation. pub struct GitWriteRequest { @@ -468,6 +479,12 @@ pub struct GitWriteRequest { pub no_checkout: bool, #[serde(default)] pub amend: bool, + /// Reviewed HEAD/index required by a planned workspace commit. + #[serde(default)] + pub expected_commit_state: Option, + /// Exact child commits to record; requires expected_commit_state. + #[serde(default)] + pub gitlink_updates: Vec, #[serde(default)] pub force: bool, /// Tag scope for push: `none`, `all`, or `reachable`. @@ -476,6 +493,9 @@ pub struct GitWriteRequest { /// Optional reviewed preview snapshot that must still match before pushing. #[serde(default)] pub expected_push: Option, + /// Verify submodule commits are published before a workspace branch push. + #[serde(default)] + pub check_submodules: bool, #[serde(default)] pub auto_stash: bool, /// Mandatory immutable preview for undo, reword, squash, and drop. @@ -878,6 +898,21 @@ fn write_with_trace(request: GitWriteRequest) -> Result Result { let paths = validate_paths(&request.paths)?; let pathspec_input = nul_pathspec_input(&paths); + + // An unborn repository has no HEAD for `restore --staged` or + // `reset HEAD` to resolve. Its index can only contain newly added + // paths, so remove those entries from the index while preserving + // the working tree files. + let head = execute_git( + &root, + &["rev-parse".into(), "--verify".into(), "HEAD".into()], + None, + )?; + if head.exit_code != 0 && commit_state::is_unborn(&root)? { + arguments = vec![ + "rm".into(), + "--cached".into(), + "--force".into(), + "--ignore-unmatch".into(), + "--pathspec-from-file=-".into(), + "--pathspec-file-nul".into(), + ]; + return execute_git(&root, &arguments, Some(pathspec_input)); + } + let restore_arguments = vec![ "restore".into(), "--staged".into(), @@ -959,6 +1016,20 @@ fn write_with_trace(request: GitWriteRequest) -> Result arguments = vec!["add".into(), "--all".into()], "commit" => { let message = required_text(request.message.as_deref(), "commit message")?; + if request.expected_commit_state.is_some() && !request.paths.is_empty() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Planned commits use the reviewed index", + )); + } + if let Some(expected) = &request.expected_commit_state { + commit_state::prepare(&root, expected, &request.gitlink_updates)?; + } else if !request.gitlink_updates.is_empty() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Submodule updates require a reviewed commit state", + )); + } if !request.paths.is_empty() { let paths = validate_paths(&request.paths)?; return commit_selected_paths(&root, paths, message, request.amend); @@ -1178,6 +1249,14 @@ fn write_with_trace(request: GitWriteRequest) -> Result { + if let Some(expected) = &request.expected_commit_state { + if commit_state::inspect_root(&root)? != *expected { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Push state changed; review the commit plan again", + )); + } + } let reference = optional_write_request_reference(&root, &request)?; return push( &root, @@ -1185,6 +1264,7 @@ fn write_with_trace(request: GitWriteRequest) -> Result return checkout(&root, request), @@ -4866,6 +4946,7 @@ fn push( force: bool, push_tags: Option<&str>, expected_push: Option<&GitPushExpectationRequest>, + check_submodules: bool, ) -> Result { let tag_scope = push_tags.unwrap_or("none"); let tag_argument = match tag_scope { @@ -4885,6 +4966,9 @@ fn push( validate_push_expectation(root, &target, tag_scope, expected_push)?; } let mut arguments = vec!["push".to_string()]; + if check_submodules { + arguments.push("--recurse-submodules=check".into()); + } if force { // Bind reviewed pushes to the observed remote OID so an unseen remote update is rejected. arguments.push(match expected_push { @@ -6473,7 +6557,8 @@ pub fn status(request: GitStatusRequest) -> Result "-c", "core.quotepath=false", "status", - "--porcelain=v1", + "--porcelain=v2", + "--ignore-submodules=none", "-z", "--untracked-files=all", ], @@ -6484,7 +6569,24 @@ pub fn status(request: GitStatusRequest) -> Result .with_details(String::from_utf8_lossy(&status_output.stderr)), ); } - let changes = parse_status(&status_output.stdout); + let mut changes = parse_status(&status_output.stdout, request.include_index_only_changes); + // Native bindings may use macOS /var aliases or Windows short/verbatim + // paths. Compare the same canonical identity as Git's reported root. + let known_roots = request + .repository_roots + .iter() + .map(|root| canonicalize_or_original(Path::new(root)).map_err(git_path_error)) + .collect::, _>>()?; + // An embedded independent repository must not become a gitlink through a + // parent Stage All. Gitlinks already tracked by the parent remain visible. + changes.retain(|change| { + !change.untracked + || !known_roots.iter().any(|other| { + other != &repository_root + && other.starts_with(&repository_root) + && repository_root.join(&change.path).starts_with(other) + }) + }); let (ahead, behind) = tracking_counts(&repository_root); Ok(GitStatusResponse { repository_root: Some(relative_or_absolute(&repository_root, &root)), @@ -6589,43 +6691,74 @@ fn run_git(directory: &Path, arguments: &[&str]) -> Result Vec { +fn parse_status(output: &[u8], include_index_only_changes: bool) -> Vec { let mut changes = Vec::new(); - let records = output + let mut records = output .split(|byte| *byte == 0) - .filter(|record| !record.is_empty()) - .collect::>(); - let mut index = 0; - while index < records.len() { - let record = String::from_utf8_lossy(records[index]).to_string(); - let bytes = record.as_bytes(); - if bytes.len() < 3 { - index += 1; + .filter(|record| !record.is_empty()); + while let Some(record) = records.next() { + let record = String::from_utf8_lossy(record); + let (status, submodule, path, original_path) = match record.as_bytes().first() { + Some(b'?') => ("??".to_string(), None, record[2..].to_string(), None), + Some(kind @ (b'1' | b'2' | b'u')) => { + // v2 headers have fixed field counts; the remaining path may + // contain whitespace. A rename's old path is the next NUL record. + let count = match kind { + b'1' => 9, + b'2' => 10, + _ => 11, + }; + let fields = record.splitn(count, ' ').collect::>(); + if fields.len() != count { + continue; + } + let status = fields[1].replace('.', " "); + let submodule = if fields[2].starts_with('S') { + Some(crate::protocol::GitSubmoduleStatus { + commit_changed: fields[2].as_bytes().get(1) == Some(&b'C'), + tracked_changes: fields[2].as_bytes().get(2) == Some(&b'M'), + untracked_changes: fields[2].as_bytes().get(3) == Some(&b'U'), + }) + } else { + None + }; + let original = if *kind == b'2' { + records + .next() + .map(|path| String::from_utf8_lossy(path).to_string()) + } else { + None + }; + (status, submodule, fields[count - 1].to_string(), original) + } + _ => continue, + }; + let bytes = status.as_bytes(); + if bytes.len() != 2 { continue; } let x = bytes[0] as char; let y = bytes[1] as char; - // The commit checkbox represents the final worktree snapshot. A path - // added only to the index and then deleted is identical to HEAD. - if x == 'A' && y == 'D' { - index += 1; + // Preserve the existing final-worktree projection used by Windows. + if !include_index_only_changes && x == 'A' && y == 'D' { continue; } - let path = record[3..].to_string(); - let mut original_path = None; - if (matches!(x, 'R' | 'C') || matches!(y, 'R' | 'C')) && index + 1 < records.len() { - original_path = Some(String::from_utf8_lossy(records[index + 1]).to_string()); - index += 1; - } + let can_toggle_staging = (x != ' ' && x != '?') + || submodule.as_ref().is_none_or(|s| s.commit_changed) + || x == 'U' + || y == 'U' + || y == 'D' + || (x == 'A' && y == 'A'); changes.push(GitChange { + can_toggle_staging, path, original_path, - status: format!("{}{}", x, y), + status, staged: x != ' ' && x != '?', worktree: y != ' ' && y != '?', untracked: x == '?' && y == '?', + submodule, }); - index += 1; } changes.sort_by(|left, right| left.path.cmp(&right.path)); changes diff --git a/rust/lithe-core/src/git/workspace_commit.rs b/rust/lithe-core/src/git/workspace_commit.rs new file mode 100644 index 000000000..916af50b0 --- /dev/null +++ b/rust/lithe-core/src/git/workspace_commit.rs @@ -0,0 +1,626 @@ +//! Shared workspace commit planning, guarded execution, and partial-success recovery. +// Decisions: .agents/notes/implemented/feature/2026-09-27-workspace-git-commit-plans.md + +use super::{commit_state, GitCommitGitlink, GitCommitState, GitWriteRequest}; +use crate::protocol::{CoreError, ErrorCode}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; +use std::time::Duration; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Platform binding for a workspace-relative repository identity. +pub struct RepositoryBinding { + /// Relative path with `/` separators, including `..` for an enclosing repository. + pub id: String, + /// Native execution location; never used as a portable identifier. + pub root: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +/// A mode-160000 index entry between two discovered repositories. +pub struct Relation { + pub parent: String, + pub child: String, + /// Gitlink path relative to the parent repository. + pub path: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Exact repositories, options, and Git state presented for review. +pub struct Plan { + pub repositories: Vec, + pub message: String, + pub amend: bool, + pub push: bool, + pub include_parent_references: bool, + pub is_retry: bool, + /// Child-before-parent order, preserving input order for independent roots. + pub ordered_ids: Vec, + pub propagated_relations: Vec, + pub dependency_relations: Vec, + pub states: BTreeMap, + pub committed_ids: BTreeSet, + pub pending_push_ids: BTreeSet, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Per-repository outcome; successful mutations are never rolled back as a batch. +pub struct RepositoryResult { + pub committed: bool, + pub pushed: bool, + /// Stable presentation key, separate from optional Git diagnostics. + pub status: String, + pub detail: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +/// In-memory continuation returned unchanged by clients to the next step or retry. +/// It owns no native resources and is discarded when the workspace closes. +pub struct Session { + pub plan: Plan, + /// Last observed states, including successful commits and failed-hook index changes. + pub states: BTreeMap, + pub results: BTreeMap, + pub blocked: BTreeSet, + /// Next repository; a successful commit keeps this position for its separate push. + pub cursor: usize, + pub command_failed: bool, + pub finished: bool, + pub succeeded: bool, + pub can_retry: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Builds a fresh plan; retry and reviewed snapshots are optional continuations. +pub struct PrepareRequest { + pub repositories: Vec, + pub message: String, + #[serde(default)] + pub amend: bool, + #[serde(default)] + pub push: bool, + pub include_parent_references: bool, + #[serde(default)] + pub previous: Option, + #[serde(default)] + pub reviewed: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +/// Review decision and initial continuation, both computed by Core. +pub struct Preparation { + pub session: Session, + pub review_changed: bool, + pub requires_confirmation: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Executes at most one commit or push using a fresh host operation context. +pub struct StepRequest { + pub session: Session, +} + +fn invalid(message: &str) -> CoreError { + CoreError::new(ErrorCode::InvalidRequest, message) +} + +fn validate_bindings(bindings: &[RepositoryBinding]) -> Result<(), CoreError> { + let mut ids = BTreeSet::new(); + let mut roots = BTreeSet::new(); + for binding in bindings { + if binding.id.is_empty() + || binding.id.starts_with('/') + || binding.id.contains('\\') + || binding.id.contains('\0') + || !ids.insert(&binding.id) + { + return Err(invalid( + "Repository identifiers must be unique workspace-relative paths", + )); + } + if binding.root.is_empty() || !roots.insert(Path::new(&binding.root)) { + return Err(invalid("A repository cannot appear twice in a commit plan")); + } + } + Ok(()) +} + +/// Inspects every discovered repository before calculating dependencies and retry scope. +pub fn prepare(mut request: PrepareRequest) -> Result { + for binding in &mut request.repositories { + binding.root = super::validate_root(&binding.root)?; + } + validate_bindings(&request.repositories)?; + let mut states = BTreeMap::new(); + for binding in &request.repositories { + states.insert( + binding.id.clone(), + commit_state::inspect_root(&binding.root)?, + ); + } + build_plan(request, states) +} + +fn build_plan( + request: PrepareRequest, + states: BTreeMap, +) -> Result { + let message = request.message.trim().to_string(); + if message.is_empty() { + return Err(invalid("Enter a commit message")); + } + let previous = request.previous.as_ref(); + let mut results = previous.map(|s| s.results.clone()).unwrap_or_default(); + if let Some(previous) = previous { + for id in &previous.plan.ordered_ids { + let result = previous.results.get(id).cloned().unwrap_or_default(); + let unfinished = !result.committed || (request.push && !result.pushed); + let binding = request.repositories.iter().find(|b| &b.id == id); + if unfinished + && (binding.is_none() + || binding != previous.plan.repositories.iter().find(|b| &b.id == id)) + { + return Err(invalid("A repository with unfinished steps is no longer in this workspace. Restore it before retrying.")); + } + if !result.committed + && result.status != "outcomeUnknown" + && states + .get(id) + .zip(previous.states.get(id)) + .is_some_and(|(current, old)| current.head != old.head) + { + return Err(invalid("An unfinished repository HEAD changed. Inspect its history and dismiss the previous batch before starting a new commit.")); + } + if result.status == "outcomeUnknown" { + // A cancelled hook may finish the commit before its process exits. + // Never repeat that commit merely because cleanup inspection failed. + let current = states + .get(id) + .ok_or_else(|| invalid("Restore the repository before retrying"))?; + if current.head != previous.states.get(id).and_then(|s| s.head.clone()) + && current.head.is_some() + { + results.entry(id.clone()).or_default().committed = true; + } + } + } + } + let mut committed: BTreeSet<_> = results + .iter() + .filter(|(_, r)| r.committed) + .map(|(id, _)| id.clone()) + .collect(); + let mut pending_push: BTreeSet<_> = results + .iter() + .filter(|(id, result)| { + result.committed + && states.contains_key(*id) + && request.push + && (!result.pushed + || previous + .and_then(|s| s.states.get(*id)) + .zip(states.get(*id)) + .is_none_or(|(old, new)| old.head != new.head || old.branch != new.branch)) + }) + .map(|(id, _)| id.clone()) + .collect(); + let mut selected: BTreeSet<_> = states + .iter() + .filter(|(id, state)| !committed.contains(*id) && !state.staged_paths.is_empty()) + .map(|(id, _)| id.clone()) + .collect(); + selected.extend(pending_push.iter().cloned()); + let relations = relations(&request.repositories, &states)?; + let previously_propagated = |relation: &Relation| { + previous.is_some_and(|s| s.plan.propagated_relations.contains(relation)) + }; + if request.include_parent_references { + for relation in &relations { + if committed.contains(&relation.child) + && previously_propagated(relation) + && !committed.contains(&relation.parent) + { + selected.insert(relation.parent.clone()); + } + } + loop { + let before = selected.len(); + for relation in &relations { + if selected.contains(&relation.child) && !committed.contains(&relation.parent) { + selected.insert(relation.parent.clone()); + } + } + if before == selected.len() { + break; + } + } + } + if selected.is_empty() { + return Err(invalid("Stage at least one change before committing")); + } + let propagation: Vec<_> = relations + .iter() + .filter(|r| { + ((selected.contains(&r.child) && !committed.contains(&r.child)) + || previously_propagated(r)) + && selected.contains(&r.parent) + && !committed.contains(&r.parent) + && (request.include_parent_references + || states[&r.parent].staged_paths.contains(&r.path)) + }) + .cloned() + .collect(); + let mut dependencies = propagation.clone(); + if request.push { + // Iterate to a fixed point: a push-only child can itself reference a grandchild. + loop { + let before = selected.len(); + for relation in &relations { + if selected.contains(&relation.parent) + && (states[&relation.parent] + .staged_paths + .contains(&relation.path) + || previous.is_some_and(|s| s.plan.dependency_relations.contains(relation))) + && states[&relation.child].branch.is_some() + { + if selected.insert(relation.child.clone()) { + committed.insert(relation.child.clone()); + pending_push.insert(relation.child.clone()); + } + if !dependencies.contains(relation) { + dependencies.push(relation.clone()); + } + } + } + if before == selected.len() { + break; + } + } + } + let order = commit_order( + request + .repositories + .iter() + .filter(|b| selected.contains(&b.id)) + .map(|b| b.id.clone()) + .collect(), + &dependencies, + )?; + let plan = Plan { + repositories: request.repositories, + message, + amend: request.amend, + push: request.push, + include_parent_references: request.include_parent_references, + is_retry: previous.is_some(), + ordered_ids: order, + propagated_relations: propagation, + dependency_relations: dependencies, + states: states.clone(), + committed_ids: committed, + pending_push_ids: pending_push, + }; + let review_changed = request + .reviewed + .as_ref() + .is_some_and(|reviewed| reviewed != &plan); + let requires_confirmation = plan.is_retry || !plan.dependency_relations.is_empty(); + for id in &plan.ordered_ids { + let result = results.entry(id.clone()).or_default(); + result.committed = plan.committed_ids.contains(id); + if plan.pending_push_ids.contains(id) { + result.pushed = false; + } + result.status = "pending".into(); + result.detail.clear(); + } + for (id, result) in &mut results { + if !plan.ordered_ids.contains(id) && !result.committed { + result.status = "notIncluded".into(); + result.detail.clear(); + } + } + Ok(Preparation { + session: Session { + plan, + states, + results, + blocked: BTreeSet::new(), + cursor: 0, + command_failed: false, + finished: false, + succeeded: false, + can_retry: true, + }, + review_changed, + requires_confirmation, + }) +} + +fn relations( + bindings: &[RepositoryBinding], + states: &BTreeMap, +) -> Result, CoreError> { + let mut relations = Vec::new(); + for parent in bindings { + for link in &states[&parent.id].gitlinks { + super::validate_paths(std::slice::from_ref(&link.path))?; + let target = Path::new(&parent.root).join(&link.path); + if let Some(child) = bindings + .iter() + .find(|b| b.id != parent.id && Path::new(&b.root) == target) + { + relations.push(Relation { + parent: parent.id.clone(), + child: child.id.clone(), + path: link.path.clone(), + }); + } + } + } + Ok(relations) +} + +fn commit_order( + mut remaining: Vec, + relations: &[Relation], +) -> Result, CoreError> { + let mut ordered = Vec::new(); + while !remaining.is_empty() { + let index = remaining + .iter() + .position(|candidate| { + !relations + .iter() + .any(|r| &r.parent == candidate && remaining.contains(&r.child)) + }) + .ok_or_else(|| invalid("Cyclic repository dependencies cannot be committed"))?; + ordered.push(remaining.remove(index)); + } + Ok(ordered) +} + +fn block_parents(session: &mut Session, child: &str) { + let mut children = vec![child.to_string()]; + while let Some(child) = children.pop() { + for relation in &session.plan.dependency_relations { + if relation.child == child && session.blocked.insert(relation.parent.clone()) { + children.push(relation.parent.clone()); + } + } + } +} + +fn fail(session: &mut Session, id: &str, status: &str, detail: String) { + let result = session.results.entry(id.into()).or_default(); + result.status = status.into(); + result.detail = detail; + session.command_failed = true; + block_parents(session, id); + session.cursor += 1; +} + +fn finish(session: &mut Session) { + session.finished = session.cursor >= session.plan.ordered_ids.len(); + session.can_retry = session.plan.ordered_ids.iter().any(|id| { + session + .results + .get(id) + .is_none_or(|r| !r.committed || (session.plan.push && !r.pushed)) + }); + session.succeeded = session.finished && !session.command_failed && !session.can_retry; +} + +/// Runs one mutation, reconciles its outcome even after cancellation, and returns +/// the continuation. The caller supplies a fresh operation ID for each next step. +pub fn step(request: StepRequest) -> Result { + let mut session = request.session; + validate_session(&session)?; + if session.finished { + return Ok(session); + } + let id = session.plan.ordered_ids[session.cursor].clone(); + if session.blocked.contains(&id) { + fail(&mut session, &id, "waitingForSubmodule", String::new()); + finish(&mut session); + return Ok(session); + } + let root = session + .plan + .repositories + .iter() + .find(|b| b.id == id) + .unwrap() + .root + .clone(); + let expected = session.states.get(&id).cloned(); + let preparation = prepare_step(&session, &id, &root); + let write = match preparation { + Ok(Some(write)) => write, + Ok(None) => { + session.cursor += 1; + finish(&mut session); + return Ok(session); + } + Err(error) => { + fail(&mut session, &id, "reviewRequired", error.message); + finish(&mut session); + return Ok(session); + } + }; + let committing = write.operation == "commit"; + let result = super::write(write); + let succeeded = result + .as_ref() + .is_ok_and(|r| r.exit_code == 0 && r.operation_error.is_none()); + let detail = match &result { + Ok(r) => r + .operation_error + .as_ref() + .map(|e| e.message.clone()) + .unwrap_or_else(|| r.output.trim().to_string()), + Err(e) => e.message.clone(), + }; + if committing { + // Read-only cleanup has its own bounded deadline, preserving a commit + // that completed just before cancellation without starting another write. + let after = + crate::protocol::cancellation::with_cleanup_deadline(Duration::from_secs(5), || { + commit_state::inspect_root(&root) + }); + let advanced = after.as_ref().is_ok_and(|s| { + s.head.is_some() && s.head != expected.as_ref().and_then(|e| e.head.clone()) + }); + if let Ok(after) = &after { + session.states.insert(id.clone(), after.clone()); + } + let outcome = session.results.get_mut(&id).unwrap(); + outcome.committed = succeeded || advanced; + outcome.status = if session.plan.push { + "committedPushPending" + } else { + "committed" + } + .into(); + if !succeeded || after.is_err() { + let status = if after.is_err() { + "outcomeUnknown" + } else if advanced { + "headAdvanced" + } else { + "commitFailed" + }; + fail( + &mut session, + &id, + status, + if let Err(error) = after { + error.message + } else { + detail + }, + ); + } else if !session.plan.push { + session.cursor += 1; + } + } else { + let outcome = session.results.get_mut(&id).unwrap(); + outcome.pushed = succeeded; + outcome.status = "committedAndPushed".into(); + if succeeded { + session.cursor += 1; + } else { + fail(&mut session, &id, "pushFailed", detail); + } + } + finish(&mut session); + Ok(session) +} + +fn validate_session(session: &Session) -> Result<(), CoreError> { + validate_bindings(&session.plan.repositories)?; + let ids: BTreeSet<_> = session.plan.repositories.iter().map(|b| &b.id).collect(); + let order: BTreeSet<_> = session.plan.ordered_ids.iter().collect(); + if order.len() != session.plan.ordered_ids.len() + || !order.is_subset(&ids) + || session.cursor > order.len() + || (!session.finished && session.cursor == order.len()) + || order + .iter() + .any(|id| !session.states.contains_key(*id) || !session.results.contains_key(*id)) + || session + .plan + .dependency_relations + .iter() + .chain(&session.plan.propagated_relations) + .any(|r| !ids.contains(&r.parent) || !ids.contains(&r.child)) + { + return Err(invalid("Invalid workspace commit continuation")); + } + if commit_order( + session.plan.ordered_ids.clone(), + &session.plan.dependency_relations, + )? != session.plan.ordered_ids + { + return Err(invalid( + "Workspace commit dependencies must precede their parents", + )); + } + Ok(()) +} + +fn prepare_step( + session: &Session, + id: &str, + root: &str, +) -> Result, CoreError> { + let expected = &session.states[id]; + if commit_state::inspect_root(root)? != *expected { + return Err(invalid("Repository changed; review and retry")); + } + let mut request = GitWriteRequest { + root: root.into(), + expected_commit_state: Some(expected.clone()), + ..Default::default() + }; + if !session.results[id].committed { + if !expected.conflicted_paths.is_empty() { + return Err(invalid("Resolve conflicts before committing")); + } + let markers = super::staged_conflict_marker_paths(root)?; + if !markers.is_empty() { + return Err(invalid(&format!( + "Resolve conflict markers: {}", + markers.join(", ") + ))); + } + for relation in session + .plan + .propagated_relations + .iter() + .filter(|r| r.parent == id) + { + let child = session + .plan + .repositories + .iter() + .find(|b| b.id == relation.child) + .unwrap(); + let actual = commit_state::inspect_root(&child.root)?; + if !session.results.get(&child.id).is_some_and(|r| r.committed) + || actual.head.is_none() + || actual.head != session.states.get(&child.id).and_then(|s| s.head.clone()) + { + return Err(invalid("Submodule changed; review and retry")); + } + request.gitlink_updates.push(GitCommitGitlink { + path: relation.path.clone(), + revision: actual.head.unwrap(), + }); + } + request.operation = "commit".into(); + request.message = Some(session.plan.message.clone()); + request.amend = session.plan.amend && !expected.staged_paths.is_empty(); + } else if session.plan.push && !session.results[id].pushed { + request.operation = "push".into(); + request.reference = Some(expected.branch.clone().ok_or_else(|| { + invalid("Committed; branch changed or detached. Review and retry push.") + })?); + request.check_submodules = true; + } else { + return Ok(None); + } + Ok(Some(request)) +} + +#[cfg(test)] +mod tests; diff --git a/rust/lithe-core/src/git/workspace_commit/tests.rs b/rust/lithe-core/src/git/workspace_commit/tests.rs new file mode 100644 index 000000000..14b2afa2d --- /dev/null +++ b/rust/lithe-core/src/git/workspace_commit/tests.rs @@ -0,0 +1,177 @@ +//! Deterministic policy regressions migrated from the native feature model. +use super::*; +use serde_json::json; + +fn state(staged: &[&str], links: &[&str]) -> GitCommitState { + GitCommitState { + head: Some("initial".into()), + branch: Some("refs/heads/main".into()), + index_entries: staged.join("\0"), + gitlinks: links + .iter() + .map(|p| GitCommitGitlink { + path: (*p).into(), + revision: "initial".into(), + }) + .collect(), + staged_paths: staged.iter().map(|p| (*p).into()).collect(), + conflicted_paths: vec![], + } +} + +fn input() -> (PrepareRequest, BTreeMap) { + let request: PrepareRequest = serde_json::from_value(json!({ + "repositories": [{"id":"A","root":"/workspace/A"}, {"id":"A/B","root":"/workspace/A/B"}, + {"id":"independent","root":"/workspace/independent"}], + "message":"commit", "push":true, "includeParentReferences":true + })) + .unwrap(); + let states = [ + ("A", state(&[], &["B"])), + ("A/B", state(&["hello.ts"], &[])), + ("independent", state(&["file"], &[])), + ] + .into_iter() + .map(|(id, s)| (id.into(), s)) + .collect(); + (request, states) +} + +#[test] +fn true_gitlinks_order_children_and_include_clean_ancestors() { + let (mut request, mut states) = input(); + request.repositories.push(RepositoryBinding { + id: "A/B/C".into(), + root: "/workspace/A/B/C".into(), + }); + states.insert("A/B".into(), state(&[], &["C"])); + states.insert("A/B/C".into(), state(&["hello.ts"], &[])); + let prepared = build_plan(request, states).unwrap(); + assert_eq!( + prepared.session.plan.ordered_ids, + ["independent", "A/B/C", "A/B", "A"] + ); + assert_eq!(prepared.session.plan.propagated_relations.len(), 2); + assert!(prepared.requires_confirmation); +} + +#[test] +fn independent_nested_paths_do_not_create_dependencies_and_parent_opt_out_is_honored() { + let (mut request, states) = input(); + request.include_parent_references = false; + let prepared = build_plan(request, states).unwrap(); + assert_eq!(prepared.session.plan.ordered_ids, ["A/B", "independent"]); + assert!(prepared.session.plan.dependency_relations.is_empty()); + let (request, mut states) = input(); + states.insert("A".into(), state(&["parent.txt"], &[])); + assert_eq!( + build_plan(request, states) + .unwrap() + .session + .plan + .ordered_ids, + ["A", "A/B", "independent"] + ); +} + +#[test] +fn changed_index_or_new_selection_requires_reconfirmation() { + let (request, states) = input(); + let original = build_plan(request, states.clone()).unwrap(); + let (mut request, mut states) = input(); + request.reviewed = Some(original.session.plan); + states.get_mut("independent").unwrap().index_entries = "new selection".into(); + assert!(build_plan(request, states).unwrap().review_changed); +} + +fn failed_push() -> (Session, BTreeMap) { + let (request, mut states) = input(); + let mut session = build_plan(request, states.clone()).unwrap().session; + for id in ["A/B", "independent"] { + let result = session.results.get_mut(id).unwrap(); + result.committed = true; + result.pushed = id == "independent"; + states.insert(id.into(), state(&[], &[])); + } + session.states = states.clone(); + block_parents(&mut session, "A/B"); + assert_eq!(session.blocked, BTreeSet::from(["A".into()])); + (session, states) +} + +#[test] +fn child_push_failure_blocks_only_ancestors_and_retry_does_not_recommit_successes() { + let (session, states) = failed_push(); + let (mut request, _) = input(); + request.previous = Some(session); + let prepared = build_plan(request, states).unwrap(); + assert_eq!(prepared.session.plan.ordered_ids, ["A/B", "A"]); + assert!(prepared.session.results["A/B"].committed); + assert!(!prepared.session.results["A/B"].pushed); + assert!(prepared.session.blocked.is_empty()); + assert!(prepared.requires_confirmation); +} + +#[test] +fn parent_pointer_selection_adds_a_push_only_child() { + let (request, mut states) = input(); + states.insert("A".into(), state(&["B"], &["B"])); + states.insert("A/B".into(), state(&[], &[])); + states.insert("independent".into(), state(&[], &[])); + let prepared = build_plan(request, states).unwrap(); + assert_eq!(prepared.session.plan.ordered_ids, ["A/B", "A"]); + assert!(prepared.session.plan.propagated_relations.is_empty()); + assert!(prepared.session.results["A/B"].committed); + assert!(!prepared.session.results["A/B"].pushed); +} + +#[test] +fn retry_republishes_an_externally_advanced_child_before_its_clean_parent() { + let (mut session, mut states) = failed_push(); + session.results.get_mut("A/B").unwrap().pushed = true; + states.get_mut("A/B").unwrap().head = Some("external".into()); + let (mut request, _) = input(); + request.previous = Some(session); + let prepared = build_plan(request, states).unwrap(); + assert_eq!(prepared.session.plan.ordered_ids, ["A/B", "A"]); + assert!(prepared.session.plan.pending_push_ids.contains("A/B")); + assert!(prepared.session.results["A/B"].committed); +} + +#[test] +fn missing_unfinished_roots_and_cycles_fail_closed() { + let (session, mut states) = failed_push(); + let (mut request, _) = input(); + request.previous = Some(session); + request.repositories.retain(|b| b.id != "A/B"); + states.remove("A/B"); + assert!(build_plan(request, states).is_err()); + let cycle = vec![ + Relation { + parent: "A".into(), + child: "B".into(), + path: "B".into(), + }, + Relation { + parent: "B".into(), + child: "A".into(), + path: "A".into(), + }, + ]; + assert!(commit_order(vec!["A".into(), "B".into()], &cycle).is_err()); +} + +#[test] +fn shared_workflow_fixture_is_the_complete_portable_contract() { + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../../../shared/fixtures/git/workspace-commit-workflow-v1.json" + )) + .unwrap(); + let request = serde_json::from_value(fixture["request"].clone()).unwrap(); + let states = serde_json::from_value(fixture["states"].clone()).unwrap(); + let prepared = build_plan(request, states).unwrap(); + assert_eq!( + serde_json::to_value(prepared).unwrap(), + fixture["preparation"] + ); +} diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index e217a2c91..8df12504c 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -226,6 +226,12 @@ pub enum CoreCommand { MybatisIndex, /// Reads normalized repository and working-tree state (`git.status`). GitStatus, + /// Reads HEAD and index preconditions for workspace commits (`git.commitState`). + GitCommitState, + /// Builds a reviewed multi-repository plan (`git.workspaceCommitPrepare`). + GitWorkspaceCommitPrepare, + /// Executes one guarded workspace commit/push (`git.workspaceCommitStep`). + GitWorkspaceCommitStep, /// Resolves paths a Git-aware watcher must observe (`git.watchContext`). GitWatchContext, /// Lists worktrees registered for the repository (`git.worktrees`). @@ -419,6 +425,9 @@ impl CoreCommand { "spring.index" => Some(Self::SpringIndex), "mybatis.index" => Some(Self::MybatisIndex), "git.status" => Some(Self::GitStatus), + "git.commitState" => Some(Self::GitCommitState), + "git.workspaceCommitPrepare" => Some(Self::GitWorkspaceCommitPrepare), + "git.workspaceCommitStep" => Some(Self::GitWorkspaceCommitStep), "git.watchContext" => Some(Self::GitWatchContext), "git.worktrees" => Some(Self::GitWorktrees), "git.pullRequestContext" => Some(Self::GitPullRequestContext), diff --git a/rust/lithe-core/src/protocol/contracts.rs b/rust/lithe-core/src/protocol/contracts.rs index 2b5b429fb..dc03e484a 100644 --- a/rust/lithe-core/src/protocol/contracts.rs +++ b/rust/lithe-core/src/protocol/contracts.rs @@ -532,6 +532,19 @@ pub struct GitChange { pub staged: bool, pub worktree: bool, pub untracked: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub submodule: Option, + /// False for parent gitlinks with only uncommitted child content. + pub can_toggle_staging: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Git porcelain v2 distinguishes a new gitlink target from child worktree dirt. +pub struct GitSubmoduleStatus { + pub commit_changed: bool, + pub tracked_changes: bool, + pub untracked_changes: bool, } #[derive(Debug, Clone, Serialize)] diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index a386a2a2b..95d2bc233 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -105,6 +105,7 @@ fn execute(request: &str) -> CoreResponse { ); }; + let preserve_workspace_outcome = matches!(command, CoreCommand::GitWorkspaceCommitStep); let response = match command { CoreCommand::Ping => CoreResponse::success( id, @@ -1601,6 +1602,59 @@ fn execute(request: &str) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::GitWorkspaceCommitPrepare => { + let result = + serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid workspace commit request", + ) + .with_details(error.to_string()) + }) + .and_then(git::workspace_commit::prepare); + match result { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Workspace commit response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::GitWorkspaceCommitStep => { + let result = + serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid workspace commit request", + ) + .with_details(error.to_string()) + }) + .and_then(git::workspace_commit::step); + match result { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Workspace commit response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::GitCommitState => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid commit state request") + .with_details(error.to_string()) + }) + .and_then(git::commit_state) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Commit state should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::GitStatus => match serde_json::from_value::(parsed.payload) .map_err(|error| { CoreError::new(ErrorCode::InvalidRequest, "Invalid Git status request") @@ -2300,7 +2354,9 @@ fn execute(request: &str) -> CoreResponse { } } }; - if response.is_success() { + // Workspace steps already reconcile cancelled writes under a bounded cleanup + // deadline. Preserve that continuation so a retry cannot duplicate a commit. + if response.is_success() && !preserve_workspace_outcome { match crate::protocol::cancellation::check() { Ok(()) => response, Err(error) => CoreResponse::failure(response_id, error), diff --git a/rust/lithe-core/src/tests/git.rs b/rust/lithe-core/src/tests/git.rs index d822bb262..31075dfec 100644 --- a/rust/lithe-core/src/tests/git.rs +++ b/rust/lithe-core/src/tests/git.rs @@ -1019,6 +1019,33 @@ fn git_history_rewrite_rejects_a_dirty_working_tree() { fs::remove_dir_all(root).expect("temporary repository should be removable"); } +#[test] +fn git_write_unstages_paths_in_an_unborn_repository() { + let root = git_write_repository("git-write-unstage-unborn"); + let run = |arguments: &[&str]| history_git(&root, arguments); + fs::write(root.join("pom.xml"), "\n").expect("test file should be writable"); + assert_eq!( + git_write_request(&root, "stage", serde_json::json!({"paths": ["pom.xml"]}),)["ok"], + true + ); + assert_eq!( + String::from_utf8_lossy(&run(&["status", "--porcelain"]).stdout), + "A pom.xml\n" + ); + + let response = git_write_request(&root, "unstage", serde_json::json!({"paths": ["pom.xml"]})); + assert_eq!(response["ok"], true, "{response}"); + assert_eq!( + String::from_utf8_lossy(&run(&["status", "--porcelain"]).stdout), + "?? pom.xml\n" + ); + assert_eq!( + fs::read_to_string(root.join("pom.xml")).expect("test file should remain"), + "\n" + ); + fs::remove_dir_all(root).expect("temporary repository should be removable"); +} + #[test] fn git_write_executes_stage_and_discard_mutations() { let root = git_write_repository("git-write-stage-discard"); @@ -3387,6 +3414,36 @@ fn git_history_returns_bounded_recent_checkout_order_and_stable_fallback() { ); } +#[test] +fn git_history_page_treats_unborn_head_as_empty_history() { + 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-history-unborn-head"); + let _cleanup = RemoveOnDrop(root.clone()); + fs::create_dir_all(&root).expect("temporary repository should be creatable"); + assert!(history_git(&root, &["init", "-q"]).status.success()); + + let request = serde_json::json!({ + "id": "history-unborn-head", + "command": "git.historyPage", + "payload": {"root": root, "reference": "HEAD", "limit": 10} + }); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("history request should encode"), + )) + .expect("history response should be JSON"); + + assert_eq!(response["ok"], true, "{response:?}"); + assert_eq!(response["data"]["commits"], serde_json::json!([])); + assert_eq!(response["data"]["hasMore"], false); +} + #[test] fn git_history_page_returns_disjoint_incremental_pages() { struct RemoveOnDrop(std::path::PathBuf); diff --git a/rust/lithe-core/src/tests/git_workspace_commit.rs b/rust/lithe-core/src/tests/git_workspace_commit.rs new file mode 100644 index 000000000..2b6570ff1 --- /dev/null +++ b/rust/lithe-core/src/tests/git_workspace_commit.rs @@ -0,0 +1,528 @@ +//! Real-Git workspace commit regressions, isolated by the timed Rust harness. + +use super::support::temporary_root; +use crate::execute_json; +use serde_json::{json, Value}; +use std::fs; +use std::path::{Path, PathBuf}; + +struct Repository(PathBuf); +impl Drop for Repository { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn request(root: &Path, command: &str, mut payload: Value) -> Value { + payload["root"] = json!(root); + serde_json::from_str(&execute_json( + &json!({"id":"workspace-commit-test", "command":command, "payload":payload}).to_string(), + )) + .unwrap() +} + +fn git(root: &Path, arguments: &[&str]) -> String { + // The shared host owns the subprocess deadline and process-tree cleanup. + let response = request(root, "git.command", json!({"arguments": arguments})); + assert_eq!(response["ok"], true, "{response}"); + assert_eq!(response["data"]["exitCode"], 0, "{arguments:?}: {response}"); + response["data"]["stdout"].as_str().unwrap().to_string() +} + +fn init(root: &Path) { + fs::create_dir_all(root).unwrap(); + git(root, &["init", "-q"]); + git(root, &["config", "user.name", "Workspace Test"]); + git(root, &["config", "user.email", "workspace@example.invalid"]); + git(root, &["config", "commit.gpgsign", "false"]); + git(root, &["config", "core.autocrlf", "false"]); +} + +fn repository(name: &str) -> Repository { + let result = Repository(temporary_root(name)); + init(&result.0); + result +} + +fn state(root: &Path) -> Value { + let result = request(root, "git.commitState", json!({})); + assert_eq!(result["ok"], true, "{result}"); + result["data"].clone() +} + +#[test] +fn git_workspace_commit_rejects_a_changed_index_before_writing() { + let repo = repository("workspace-commit-stale"); + fs::write(repo.0.join("first.txt"), "first").unwrap(); + git(&repo.0, &["add", "first.txt"]); + let expected = state(&repo.0); + assert!(expected["head"].is_null()); + fs::write(repo.0.join("second.txt"), "second").unwrap(); + git(&repo.0, &["add", "second.txt"]); + let result = request( + &repo.0, + "git.write", + json!({"operation":"commit", "message":"reviewed", "expectedCommitState":expected}), + ); + assert_eq!( + result["data"]["operationError"]["code"], "invalid_request", + "{result}" + ); + assert!(state(&repo.0)["head"].is_null()); + assert_eq!( + git(&repo.0, &["diff", "--cached", "--name-only"]), + "first.txt\nsecond.txt\n" + ); +} + +#[test] +fn git_workspace_commit_distinguishes_child_dirt_and_updates_only_the_gitlink() { + let repo = repository("workspace-commit-submodule"); + let child = repo.0.join("libs/B"); + init(&child); + fs::write(child.join("hello.ts"), "one").unwrap(); + git(&child, &["add", "hello.ts"]); + git(&child, &["commit", "-qm", "child initial"]); + fs::write(repo.0.join("parent.txt"), "parent one").unwrap(); + fs::write( + repo.0.join(".gitmodules"), + "[submodule \"B\"]\npath = libs/B\nurl = ./libs/B\n", + ) + .unwrap(); + git(&repo.0, &["add", "."]); + git(&repo.0, &["commit", "-qm", "parent initial"]); + fs::write(child.join("hello.ts"), "two").unwrap(); + let dirty = request(&repo.0, "git.status", json!({})); + assert_eq!(dirty["data"]["changes"][0]["path"], "libs/B"); + assert_eq!( + dirty["data"]["changes"][0]["submodule"]["trackedChanges"], + true + ); + assert_eq!( + dirty["data"]["changes"][0]["submodule"]["commitChanged"], + false + ); + assert_eq!(dirty["data"]["changes"].as_array().unwrap().len(), 1); + assert_eq!(dirty["data"]["changes"][0]["canToggleStaging"], false); + fs::write(repo.0.join("parent.txt"), "must stay unstaged").unwrap(); + let expected = state(&repo.0); + git(&child, &["add", "hello.ts"]); + let child_commit = request( + &child, + "git.write", + json!({"operation":"commit", "message":"child update", "expectedCommitState":state(&child)}), + ); + assert_eq!(child_commit["data"]["exitCode"], 0, "{child_commit}"); + let child_state = state(&child); + let advanced = request(&repo.0, "git.status", json!({})); + assert_eq!( + advanced["data"]["changes"][0]["submodule"]["commitChanged"], + true + ); + let result = request( + &repo.0, + "git.write", + json!({"operation":"commit", "message":"parent reference", "expectedCommitState":expected, + "gitlinkUpdates":[{"path":"libs/B", "revision":child_state["head"]}]}), + ); + assert_eq!(result["ok"], true, "{result}"); + assert_eq!(result["data"]["exitCode"], 0, "{result}"); + assert_eq!(git(&repo.0, &["show", "HEAD:parent.txt"]), "parent one"); + assert_eq!( + git(&repo.0, &["rev-parse", "HEAD:libs/B"]).trim(), + child_state["head"].as_str().unwrap() + ); + assert_eq!( + fs::read_to_string(repo.0.join("parent.txt")).unwrap(), + "must stay unstaged" + ); +} + +#[test] +fn git_workspace_commit_unstage_preserves_edits_after_initial_staging() { + let repo = repository("workspace-commit-unstage"); + fs::write(repo.0.join("new.txt"), "staged").unwrap(); + git(&repo.0, &["add", "new.txt"]); + fs::write(repo.0.join("new.txt"), "newer worktree").unwrap(); + let result = request( + &repo.0, + "git.write", + json!({"operation":"unstage", "paths":["new.txt"]}), + ); + assert_eq!(result["data"]["exitCode"], 0, "{result}"); + assert!(state(&repo.0)["stagedPaths"].as_array().unwrap().is_empty()); + assert_eq!( + fs::read_to_string(repo.0.join("new.txt")).unwrap(), + "newer worktree" + ); +} + +#[test] +fn git_workspace_commit_state_matches_the_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/git/workspace-commit-v1.json" + )) + .unwrap(); + let state: crate::git::GitCommitState = + serde_json::from_value(fixture["commitState"].clone()).unwrap(); + assert_eq!(serde_json::to_value(state).unwrap(), fixture["commitState"]); +} + +#[test] +fn git_workspace_commit_status_keeps_index_only_files_visible_when_requested() { + let repo = repository("workspace-commit-index-only"); + fs::write(repo.0.join("new.txt"), "staged content").unwrap(); + git(&repo.0, &["add", "new.txt"]); + fs::remove_file(repo.0.join("new.txt")).unwrap(); + let legacy = request(&repo.0, "git.status", json!({})); + assert!(legacy["data"]["changes"].as_array().unwrap().is_empty()); + let staged = request( + &repo.0, + "git.status", + json!({"includeIndexOnlyChanges":true}), + ); + assert_eq!(staged["data"]["changes"][0]["status"], "AD"); + assert_eq!(staged["data"]["changes"][0]["staged"], true); + assert_eq!(state(&repo.0)["stagedPaths"], json!(["new.txt"])); +} + +#[test] +fn git_workspace_commit_does_not_treat_corrupt_refs_as_an_unborn_repository() { + let repo = repository("workspace-commit-corrupt-ref"); + let branch = git(&repo.0, &["symbolic-ref", "HEAD"]).trim().to_string(); + let reference = repo.0.join(".git").join(branch); + fs::create_dir_all(reference.parent().unwrap()).unwrap(); + fs::write(reference, "not-an-object-id\n").unwrap(); + assert_eq!(request(&repo.0, "git.commitState", json!({}))["ok"], false); + let history = request( + &repo.0, + "git.historyPage", + json!({"reference":"HEAD", "limit":10}), + ); + assert_eq!(history["ok"], false, "{history}"); +} + +#[test] +fn git_workspace_commit_push_checks_submodule_publication_before_updating_the_remote() { + let parent = repository("workspace-commit-push-parent"); + let child = parent.0.join("libs/B"); + init(&child); + let child_remote = Repository(temporary_root("workspace-commit-child-remote")); + let parent_remote = Repository(temporary_root("workspace-commit-parent-remote")); + for remote in [&child_remote, &parent_remote] { + fs::create_dir_all(&remote.0).unwrap(); + git(&remote.0, &["init", "--bare", "-q"]); + } + fs::write(child.join("hello.ts"), "initial").unwrap(); + git(&child, &["add", "hello.ts"]); + git(&child, &["commit", "-qm", "initial child"]); + git(&child, &["branch", "-M", "main"]); + git( + &child, + &["remote", "add", "origin", child_remote.0.to_str().unwrap()], + ); + git(&child, &["push", "-qu", "origin", "main"]); + // Let Git quote native paths; literal Windows backslashes are escapes in + // config syntax and must not be interpolated into .gitmodules directly. + git( + &parent.0, + &[ + "config", + "--file", + ".gitmodules", + "submodule.B.path", + "libs/B", + ], + ); + git( + &parent.0, + &[ + "config", + "--file", + ".gitmodules", + "submodule.B.url", + child_remote.0.to_str().unwrap(), + ], + ); + git(&parent.0, &["add", "."]); + git(&parent.0, &["commit", "-qm", "initial parent"]); + git(&parent.0, &["branch", "-M", "main"]); + git( + &parent.0, + &["remote", "add", "origin", parent_remote.0.to_str().unwrap()], + ); + git(&parent.0, &["push", "-qu", "origin", "main"]); + let remote_before = git(&parent_remote.0, &["rev-parse", "refs/heads/main"]); + fs::write(child.join("hello.ts"), "not yet published").unwrap(); + git(&child, &["commit", "-qam", "child update"]); + git(&parent.0, &["add", "libs/B"]); + git(&parent.0, &["commit", "-qm", "parent pointer"]); + let push = |root: &Path| { + request( + root, + "git.write", + json!({"operation":"push", "reference":"refs/heads/main", + "checkSubmodules":true, "expectedCommitState":state(root)}), + ) + }; + let blocked = push(&parent.0); + assert_ne!(blocked["data"]["exitCode"], 0, "{blocked}"); + assert_eq!( + git(&parent_remote.0, &["rev-parse", "refs/heads/main"]), + remote_before + ); + let pushed_child = push(&child); + assert_eq!(pushed_child["data"]["exitCode"], 0, "{pushed_child}"); + let pushed_parent = push(&parent.0); + assert_eq!(pushed_parent["data"]["exitCode"], 0, "{pushed_parent}"); + assert_eq!( + git(&parent_remote.0, &["rev-parse", "refs/heads/main"]).trim(), + state(&parent.0)["head"].as_str().unwrap() + ); +} + +#[test] +fn git_workspace_commit_rejects_a_removed_child_repository_instead_of_using_its_parent() { + let parent = repository("workspace-commit-removed-child"); + let child = parent.0.join("child"); + init(&child); + // Both repositories have valid staged content; losing the child boundary + // must invalidate its state instead of reading any state from the parent. + for root in [&parent.0, &child] { + fs::write(root.join("selected.txt"), "same staged content").unwrap(); + git(root, &["add", "selected.txt"]); + } + let expected = state(&child); + fs::remove_dir_all(child.join(".git")).unwrap(); + let result = request( + &child, + "git.write", + json!({"operation":"commit", "message":"must not commit parent", "expectedCommitState":expected}), + ); + assert!( + result["ok"] == false || !result["data"]["operationError"].is_null(), + "{result}" + ); + assert!(state(&parent.0)["head"].is_null()); + assert_eq!(request(&child, "git.commitState", json!({}))["ok"], false); +} + +#[test] +fn git_workspace_commit_rejects_a_removed_submodule_before_updating_its_pointer() { + let parent = repository("workspace-commit-removed-submodule"); + let child = parent.0.join("child"); + init(&child); + fs::write(child.join("selected.txt"), "child content").unwrap(); + git(&child, &["add", "."]); + git(&child, &["commit", "-qm", "child initial"]); + git(&parent.0, &["add", "child"]); + git(&parent.0, &["commit", "-qm", "parent initial"]); + let expected = state(&parent.0); + fs::remove_dir_all(child.join(".git")).unwrap(); + // Without boundary validation, rev-parse in the child returns the parent's + // HEAD and incorrectly permits that revision to replace the child pointer. + let result = request( + &parent.0, + "git.write", + json!({"operation":"commit", "message":"must not replace child", + "expectedCommitState":expected, + "gitlinkUpdates":[{"path":"child", "revision":expected["head"]}]}), + ); + assert_eq!( + result["data"]["operationError"]["code"], "invalid_request", + "{result}" + ); + assert_eq!(state(&parent.0), expected); +} + +fn prepare_workspace(root: &Path, repositories: &[(&str, &Path)], push: bool) -> Value { + let repositories: Vec<_> = repositories + .iter() + .map(|(id, path)| json!({"id":id,"root":path})) + .collect(); + let response = request( + root, + "git.workspaceCommitPrepare", + json!({"repositories":repositories,"message":"batch","push":push,"includeParentReferences":true}), + ); + assert_eq!(response["ok"], true, "{response}"); + response["data"].clone() +} + +fn next_workspace(root: &Path, session: Value) -> Value { + let response = request(root, "git.workspaceCommitStep", json!({"session":session})); + assert_eq!(response["ok"], true, "{response}"); + response["data"].clone() +} + +fn staged_file(root: &Path, path: &str, content: &str) { + fs::write(root.join(path), content).unwrap(); + git(root, &["add", path]); +} + +#[test] +fn git_workspace_workflow_commits_independent_repositories_and_rejects_replayed_steps() { + let repo = repository("workspace-workflow-independent"); + let child = repo.0.join("independent"); + init(&child); + staged_file(&repo.0, "parent.txt", "one"); + staged_file(&child, "child.txt", "one"); + let prepared = prepare_workspace(&repo.0, &[(".", &repo.0), ("independent", &child)], false); + assert_eq!(prepared["requiresConfirmation"], false); + let original = prepared["session"].clone(); + let first = next_workspace(&repo.0, original.clone()); + assert_eq!(first["results"]["."]["committed"], true); + assert!(state(&child)["head"].is_null()); + let replay = next_workspace(&repo.0, original); + assert_eq!(replay["results"]["."]["status"], "reviewRequired"); + let finished = next_workspace(&repo.0, first); + assert_eq!(finished["succeeded"], true, "{finished}"); + assert_eq!(git(&repo.0, &["rev-list", "--count", "HEAD"]).trim(), "1"); + assert_eq!(git(&child, &["rev-list", "--count", "HEAD"]).trim(), "1"); +} + +#[test] +fn git_workspace_workflow_updates_clean_parent_without_including_unstaged_parent_files() { + let repo = repository("workspace-workflow-parent"); + let child = repo.0.join("B"); + init(&child); + staged_file(&child, "hello.ts", "one"); + git(&child, &["commit", "-qm", "initial"]); + staged_file(&repo.0, "parent.txt", "one"); + git(&repo.0, &["add", "B"]); + git(&repo.0, &["commit", "-qm", "initial"]); + staged_file(&child, "hello.ts", "two"); + fs::write(repo.0.join("parent.txt"), "unstaged").unwrap(); + let prepared = prepare_workspace(&repo.0, &[(".", &repo.0), ("B", &child)], false); + assert_eq!(prepared["session"]["plan"]["orderedIds"], json!(["B", "."])); + let child_done = next_workspace(&repo.0, prepared["session"].clone()); + let finished = next_workspace(&repo.0, child_done); + assert_eq!(finished["succeeded"], true, "{finished}"); + assert_eq!( + git(&repo.0, &["rev-parse", "HEAD:B"]), + git(&child, &["rev-parse", "HEAD"]) + ); + assert_eq!(git(&repo.0, &["show", "HEAD:parent.txt"]), "one"); +} + +#[test] +fn git_workspace_workflow_failed_push_continues_independent_and_retries_without_duplicate_commits() +{ + let repo = repository("workspace-workflow-retry"); + let child = repo.0.join("B"); + init(&child); + staged_file(&repo.0, "a.txt", "one"); + staged_file(&child, "b.txt", "one"); + // No remote: the first repository's push fails through Git's real target resolver. + let prepared = prepare_workspace(&repo.0, &[(".", &repo.0), ("B", &child)], true); + let mut session = prepared["session"].clone(); + for _ in 0..4 { + session = next_workspace(&repo.0, session); + } + assert_eq!(session["finished"], true); + assert_eq!(session["canRetry"], true); + assert_eq!(session["results"]["."]["committed"], true); + assert_eq!(session["results"]["B"]["committed"], true); + let mut retry = prepared["session"]["plan"].clone(); + retry["previous"] = session; + let response = request(&repo.0, "git.workspaceCommitPrepare", retry); + assert_eq!(response["ok"], true, "{response}"); + let next = next_workspace(&repo.0, response["data"]["session"].clone()); + assert_eq!(next["cursor"], 1); // One push, never another commit. + assert_eq!(git(&repo.0, &["rev-list", "--count", "HEAD"]).trim(), "1"); +} + +#[test] +fn git_workspace_workflow_confirmation_detects_external_staging_without_writing() { + let repo = repository("workspace-workflow-confirm"); + staged_file(&repo.0, "one.txt", "one"); + let prepared = prepare_workspace(&repo.0, &[(".", &repo.0)], false); + staged_file(&repo.0, "two.txt", "two"); + let mut review = prepared["session"]["plan"].clone(); + review["reviewed"] = review.clone(); + let response = request(&repo.0, "git.workspaceCommitPrepare", review); + assert_eq!(response["data"]["reviewChanged"], true, "{response}"); + assert!(state(&repo.0)["head"].is_null()); +} + +#[test] +fn git_workspace_status_excludes_discovered_independent_nested_roots() { + let repo = repository("workspace-status-ownership"); + let child = repo.0.join("B"); + init(&child); + staged_file(&child, "hello.ts", "one"); + git(&child, &["commit", "-qm", "initial"]); + let response = request( + &repo.0, + "git.status", + json!({"repositoryRoots":[repo.0,child]}), + ); + assert_eq!(response["data"]["changes"], json!([]), "{response}"); + assert_eq!( + request(&repo.0, "git.status", json!({}))["data"]["changes"][0]["path"], + "B/" + ); +} + +#[test] +fn git_workspace_workflow_retains_a_completed_commit_when_cancellation_races_its_return() { + use std::sync::{Arc, Mutex}; + let repo = repository("workspace-workflow-cancel"); + let child = repo.0.join("B"); + init(&child); + staged_file(&repo.0, "a.txt", "one"); + staged_file(&child, "b.txt", "one"); + let prepared = prepare_workspace(&repo.0, &[(".", &repo.0), ("B", &child)], false); + let invocation = Arc::new(Mutex::new(None)); + let observed = invocation.clone(); + // Cancel synchronously at the native commit's completion event: the ref has + // advanced, but the JSON response and cleanup inspection have not returned. + let response=crate::execute_json_with_events(&json!({"id":"workspace-cancel-after-commit", + "command":"git.workspaceCommitStep","payload":{"session":prepared["session"]},"timeoutMilliseconds":10000}).to_string(), + Arc::new(move |event| { + let event:Value=serde_json::from_str(event).unwrap(); + if event["type"]=="started" && event["arguments"].as_array().is_some_and(|args| args.iter().any(|arg| arg=="commit")) { + *observed.lock().unwrap()=event["invocationId"].as_u64(); + } + if event["type"]=="finished" && event["invocationId"].as_u64()==*observed.lock().unwrap() { + assert!(crate::cancel_operation("workspace-cancel-after-commit")); + } + })); + let response: Value = serde_json::from_str(&response).unwrap(); + assert!(invocation.lock().unwrap().is_some()); + assert_eq!(response["ok"], true, "{response}"); + assert_eq!( + response["data"]["results"]["."]["committed"], true, + "{response}" + ); + assert!(!crate::cancel_operation("workspace-cancel-after-commit")); + let finished = next_workspace(&repo.0, response["data"].clone()); + assert_eq!(finished["succeeded"], true, "{finished}"); + assert_eq!(git(&repo.0, &["rev-list", "--count", "HEAD"]).trim(), "1"); +} + +#[test] +#[cfg(unix)] +fn git_workspace_status_normalizes_native_repository_binding_aliases() { + let repo = repository("workspace-status-alias"); + let child = repo.0.join("B"); + init(&child); + staged_file(&child, "hello.ts", "one"); + git(&child, &["commit", "-qm", "initial"]); + let alias = repo.0.join("binding-link"); + std::os::unix::fs::symlink(&child, &alias).unwrap(); + let response = request( + &repo.0, + "git.status", + json!({"repositoryRoots":[repo.0,alias]}), + ); + assert_eq!(response["ok"], true, "{response}"); + let paths: Vec<_> = response["data"]["changes"] + .as_array() + .unwrap() + .iter() + .map(|c| c["path"].as_str().unwrap()) + .collect(); + // The parent still owns the symlink itself; only B's independent files are excluded. + assert_eq!(paths, ["binding-link"]); +} diff --git a/rust/lithe-core/src/tests/mod.rs b/rust/lithe-core/src/tests/mod.rs index 1bfe83350..0e1c75ede 100644 --- a/rust/lithe-core/src/tests/mod.rs +++ b/rust/lithe-core/src/tests/mod.rs @@ -5,6 +5,7 @@ mod git_fetch; mod git_history_rewrite; mod git_patch_exchange; mod git_repository_setup; +mod git_workspace_commit; mod github; mod languages; mod mybatis; diff --git a/rust/lithe-git-host/tests/authentication.rs b/rust/lithe-git-host/tests/authentication.rs index 8164c9c9f..fbab62a7a 100644 --- a/rust/lithe-git-host/tests/authentication.rs +++ b/rust/lithe-git-host/tests/authentication.rs @@ -26,9 +26,6 @@ fn prompt_response_is_single_use_and_cancellation_drops_pending_challenges() { assert_eq!(env["SSH_ASKPASS"], env["GIT_ASKPASS"]); assert_eq!(env["LITHE_GIT_ASKPASS_MODE"], "1"); let mut untrusted = TcpStream::connect(&env["LITHE_GIT_ASKPASS_ADDRESS"]).unwrap(); - untrusted - .set_read_timeout(Some(Duration::from_secs(1))) - .unwrap(); untrusted .set_write_timeout(Some(Duration::from_secs(1))) .unwrap(); @@ -38,26 +35,39 @@ fn prompt_response_is_single_use_and_cancellation_drops_pending_challenges() { serde_json::json!({"token": "wrong-fixture-token", "prompt": "untrusted"}) ) .unwrap(); + untrusted.set_nonblocking(true).unwrap(); let mut peer = TcpStream::connect(&env["LITHE_GIT_ASKPASS_ADDRESS"]).unwrap(); peer.set_read_timeout(Some(Duration::from_secs(1))).unwrap(); peer.set_write_timeout(Some(Duration::from_secs(1))) .unwrap(); writeln!(peer, "{}", serde_json::json!({"token": env["LITHE_GIT_ASKPASS_TOKEN"], "prompt": "Password for fixture:"})).unwrap(); - let deadline = Instant::now() + Duration::from_secs(1); - let challenge = loop { - if let Some(challenge) = session.poll().unwrap().pop() { - break challenge; + let deadline = Instant::now() + Duration::from_secs(3); + let mut challenge = None; + let mut rejection_observed = false; + // The two TCP frames may arrive in either order or in fragments. Continue + // pumping the owned session until both the prompt and rejection complete; + // blocking on the rejected peer early would stop the only transport driver. + while challenge.is_none() || !rejection_observed { + for received in session.poll().unwrap() { + assert!(challenge.is_none(), "Unexpected additional AskPass prompt"); + challenge = Some(received); + } + if !rejection_observed { + match untrusted.read(&mut [0u8; 1]) { + Ok(0) => rejection_observed = true, + Ok(_) => panic!("Untrusted peer received a response"), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {} + Err(error) => panic!("Could not observe untrusted peer rejection: {error}"), + } } assert!( Instant::now() < deadline, - "AskPass frame was not received before deadline" + "AskPass prompt and peer rejection did not complete before deadline" ); std::thread::yield_now(); - }; + } + let challenge = challenge.unwrap(); assert!(challenge.secret); - let mut rejected = String::new(); - untrusted.read_to_string(&mut rejected).unwrap(); - assert!(rejected.is_empty()); assert!(!respond( &challenge.request_id, Some("invalid\nresponse".into()) diff --git a/shared/contracts/application-boundary.md b/shared/contracts/application-boundary.md index f24a6e3cf..d0aaccbe1 100644 --- a/shared/contracts/application-boundary.md +++ b/shared/contracts/application-boundary.md @@ -25,10 +25,10 @@ verification scripts are the executable source of boundary checks. | Workspace | visible snapshot, relative paths, file metadata, deterministic ordering | workspace root selection, native dialogs, and watchers | | Documents | relative-path validation, decoded text, selected encoding, dirty/save state, and conflict outcome | native byte conversion, atomic write, and external-change notifications | | Search | query matching, deterministic result ordering, symbols, and replacement preview | workspace lifecycle and optional index persistence | -| Git | changes, commits, branches, diffs, reviewed history actions and recovery, worktree listing and safe management, worktree-aware PR publication context, validation, and mutation results | Git executable discovery, credentials, process environment, opening checkout paths | +| Git | workspace commit plans, dependency ordering, guarded steps and partial-success retry, changes, commits, branches, diffs, reviewed history actions and recovery, worktree listing and safe management, worktree-aware PR publication context, validation, and mutation results | Git executable discovery, credentials, process environment, opening checkout paths | | GitHub | remote parsing, trusted request plans, normalized branch comparisons and pull requests/reviews/comments, deterministic ordering, and stable errors | OAuth configuration, HTTPS, browser opening, and operating-system credential storage | | [AI commit messages](ai-commit.md) | provider configuration parsing, commit rules, bounded diff evidence, request plans, and response text | local configuration discovery, credentials, HTTP, cancellation, and draft UI | -| Agent conversation (ACP) | supported-agent catalog, Node.js/npm detection, adapter install with the user's npm and numbers-only live download progress, CLI provenance and owner-preserving updates, per-agent API-key and model delivery, ACP v1 connection per workspace and agent, agent-owned session history (list/load), session config options, user-selected file references as ACP resource links, streamed tool evidence, permission decisions, acknowledged cancellation with bounded recovery, and process-tree lifecycle in the shared Rust host | provider and agent settings, API-key storage, credential-independent local default-model reading through AI configuration ports, data directory, workspace selection, module enablement, conversation UI, workspace/Agent-scoped local history annotations (favorites, title overrides and recoverable hidden rows), and user-selected Markdown export destinations | +| Agent conversation (ACP) | supported-agent catalog, Node.js/npm detection, adapter install with the user's npm and numbers-only live download progress, CLI provenance and owner-preserving updates, per-agent API-key and model delivery, explicit Codex subscription authentication via the installed CLI, bounded official App Server quota reads with account checks, ACP v1 connection per workspace and agent, agent-owned session history (list/load), session config options, user-selected file references as ACP resource links, streamed tool evidence, permission decisions, acknowledged cancellation with bounded recovery, and process-tree lifecycle in the shared Rust host | provider and agent settings, API-key storage, credential-independent local default-model reading through AI configuration ports, data directory, workspace selection, module enablement, conversation UI and compact subscription quota presentation, workspace/Agent-scoped local history annotations (favorites, title overrides and recoverable hidden rows), and user-selected Markdown export destinations | | Runtime | Java/Maven requirements, normalized candidates, and effective toolchain references | JDK/Maven probing and executable paths | | Language tooling | provider catalog, local fallback results, complete LSP process/session runtime, capabilities, diagnostics, UTF-16 edits, and normalized feature results | executable/environment discovery and UI provider routing | | Java/Maven/Spring | deterministic Maven-root selection, project structure, modules and profiles, bounded dependency-tree normalization; compiler diagnostic parsing; Java source structure, symbols, code vision, run-configuration detection, Spring configuration/bean/endpoint indexing, and JDTLS/Java Debug adapter policy | JDK/Maven discovery, local dependency-repository selection, Java/Maven child processes, and sockets | diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 405f861c1..66182893a 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -45,23 +45,65 @@ The ACP Agent calls use an opaque handle for one agent process and connection per workspace and agent; one connection carries many conversation sessions. `lithe_agent_open_json` accepts `{ "agentId"?: string, "command"?: string, "args": string[], "cwd": absolutePath, "dataDirectory"?: absolutePath, -"provider": { "protocol": "responses" | "chatCompletions" | "anthropicMessages", +"authentication"?: "apiKey" | "codexSubscription", +"provider"?: { "protocol": "responses" | "chatCompletions" | "anthropicMessages", "baseUrl": string, "apiKey": string, "name"?: string, "model"?: string, "allowInsecureHttp"?: bool } }`. With `agentId`, the host starts the adapter -installed by `agent.install` under `dataDirectory`. Every agent signs in through +installed by `agent.install` under `dataDirectory`. Omitted `authentication` +defaults to `apiKey`, which requires `provider` and signs in through the ACP `gateway` method over stdio: Responses providers send `Authorization: Bearer ` and Anthropic providers send `x-api-key`. The user's own CLI is passed as `CODEX_PATH` or `CLAUDE_CODE_EXECUTABLE`, and a non-empty `model` as `CODEX_CONFIG` or `ANTHROPIC_MODEL`. Without `agentId`, `command` runs a user-provided agent that must support gateway sign-in with a Responses provider. Agents start with the executable's directory and the login shell's -`PATH` first. Account logins offered by agents are never used. Invalid settings +`PATH` first. API-key mode never falls back to account login. Invalid settings and launch failures are reported as a `stopped` event with a message. +`codexSubscription` is accepted only for `agentId: "codex-acp"` with no +`provider`. It reuses the locally installed Codex CLI and its own account storage +(including `CODEX_HOME`). The child uses the official `openai` model provider; +inherited API-key, endpoint, token and gateway overrides are removed for this +child only. Session configuration also clears `openai_base_url` and selects the +official `chatgpt_base_url`, preventing saved custom routes from overriding the +subscription selection. The quota probe receives the same route overrides. No Lithe HTTP provider, stored API key or configured provider model +is consulted. Codex owns login, token refresh and session model options. +The pinned ACP adapter's `_auth/status_update` notification confirms the account. +An existing account proceeds to `account` then `ready`; otherwise +`authenticationRequired` waits for the user's `authenticate` command before +`authenticating` opens the upstream ChatGPT browser login. Login is cancellable +by closing the handle, with a five-minute deadline. Account loss or email change +disconnects the connection rather than silently changing its billing identity. +`account` exposes only nullable `email` and `plan`, never credentials. + +`refreshQuota` is ignored outside subscription mode, coalesced while in flight, +and throttled to one read per connection per 60 seconds. The host uses a bounded +20-second, short-lived official `codex app-server` process because codex-acp +1.13.1 does not expose structured rate limits. It only initializes, checks +`account/read`, reads `account/rateLimits/read`, and checks the account again; +it never creates a thread or prompt. The process tree is owned by the connection +and terminated after the query or cancellation. The account email must match +the active ACP account; missing identity cannot prove a match. This is not a +workspace/account-ID verification guarantee: the upstream ACP identity provides +no stable account ID. Lithe reads no credential files for this path. + +`quota.snapshot` has `fetchedAt` (Unix seconds) and deterministic `windows` with +`id`, `name`, `limitSeconds`, nullable `usedPercent` (0–100) and nullable +`resetsAt` (Unix seconds). Actual window durations are preserved; primary does +not imply five hours. Unknown usage remains null. `quotaFailed.code` is +`unavailable`, `timeout`, `unparsable`, `unauthorized`, or `accountChanged`. +Consumers retain the last snapshot as stale for transient errors, clear it on +identity failures/disconnect, and never reinterpret unknown as zero. The macOS +composer shows a small chip to the right of context usage, refreshes while +visible and active, and requests a throttled refresh after turns. Hover details +include all windows and reset times; API-key connections show no quota chip. + `lithe_agent_send_json` queues one command: `newSession`, `loadSession`, -`listSessions`, `setConfigOption`, `prompt`, `cancel`, or `permission`. Results arrive as events: +`listSessions`, `setConfigOption`, `prompt`, `cancel`, `permission`, `authenticate`, +or `refreshQuota`. Results arrive as events: `ready`, `sessionCreated`, `sessionLoaded`, `sessions`, `update`, `permission`, -`sessionConfigured`, `turnCancelling`, `turnFinished`, `requestFailed`, and `stopped`. Commands and events, including +`sessionConfigured`, `turnCancelling`, `turnFinished`, `requestFailed`, `stopped`, +`authenticationRequired`, `authenticating`, `account`, `quota`, and `quotaFailed`. Commands and events, including their camel-case field names, are fixed by `shared/fixtures/agent/acp-events-v1.json`; `token` values are echoed so a caller can correlate concurrent requests. `stopReason` uses ACP wire names such as @@ -335,6 +377,7 @@ package manager owns the download and Lithe does not infer bytes from logs. | `git.initialize` | Initialize a directory outside existing repositories without staging or committing | | `git.configureIdentity` | Save or clear one local/global `user.name` or `user.email` override | | `git.status` | Resolve the repository, current branch, and working-tree changes | +| `git.commitState` | Read exact HEAD, symbolic branch and index preconditions for a workspace commit | | `git.watchContext` | Resolve the repository and absolute Git metadata roots needed by native file watchers | | `git.worktrees` | Return deterministic registered-worktree metadata without scanning each checkout | | `git.pullRequestContext` | Resolve worktree-aware PR branch defaults, publication state, and uncommitted-change state | @@ -2073,3 +2116,87 @@ failure is visible but does not globally block unrelated targets; callers still build the selected target before launching. A successful preparation does not promise compilation success. Shared examples live in `shared/fixtures/lsp/project-preparation-v1.json`. + +### Workspace commit preconditions + +`git.commitState` accepts `{ root }` and returns `{ head, branch, indexEntries, +gitlinks, stagedPaths, conflictedPaths }`. `head` is null only for an unborn branch; `branch` is +null for detached HEAD. `indexEntries` is Git's opaque NUL-delimited staged index +listing, including blob IDs and conflict stages; clients compare it without +parsing it. `gitlinks` lists stage-0 mode-160000 entries as `{ path, revision }`. +Read failures are errors, never an empty relationship list. +The requested root must still be Git's exact working-tree root. Removing a +nested repository's metadata must fail instead of falling back to its parent; +gitlink updates also verify the child boundary before reading its HEAD. + +`git.write` / `commit` optionally accepts `expectedCommitState` and +`gitlinkUpdates: [{ path, revision }]`. Gitlink updates cannot accompany other +operations or path-selected commits. Push also accepts `expectedCommitState` +to reject a changed repository under its writer lease. Workspace pushes set +`checkSubmodules: true`, invoking Git's `--recurse-submodules=check` so missing child +commits block a parent push even when only the parent pointer was selected. Under the existing repository writer lease, +Core verifies HEAD/index, validates all child HEAD revisions, then updates only +those parent index entries in a single `update-index --index-info` transaction +before the regular commit. Any changed precondition returns `invalid_request` +through the existing operation error envelope. Unrelated unstaged parent files +are not added. A failing hook may leave the pointer staged: clients must retain +partial progress and re-read state before retrying. External Git processes do +not participate in Lithe's lease; cross-repository commits are not atomic. + +`git.status.changes[]` additionally carries optional `submodule` with +`commitChanged`, `trackedChanges`, and `untrackedChanges`, normalized from Git +porcelain v2. The existing two-character `status` remains compatible. The optional request flag +`includeIndexOnlyChanges` retains staged additions deleted only from the working +tree (`AD`); both products enable it so every staged file remains visible. Omission +preserves the legacy final-worktree projection. Child dirt +alone is informational in the parent; only a changed commit pointer (or an +already-staged change) is eligible for the parent's staging checkbox. + +`git.status` accepts optional `repositoryRoots` (native bindings of discovered +repositories). Untracked paths owned by a nested root are excluded from the +parent list. Each change returns `canToggleStaging`; platforms render that +eligibility instead of reinterpreting submodule dirt. + +`git.workspaceCommitPrepare` owns the complete multi-repository policy. It accepts +`repositories: [{ id, root }]`, `message`, `amend`, `push`, +`includeParentReferences`, optional `previous` session for retry, and optional +`reviewed` plan for confirmation. `id` is a workspace-relative path with `/` +separators (`..` is allowed for enclosing repositories). Windows manually selected +roots on another volume use a stable `external//` virtual +workspace ID. `root` is the native +execution binding, never a portable identity. The response is +`{ session, reviewChanged, requiresConfirmation }`. Core reads all repositories, +finds real gitlink relationships, includes clean parents when requested, orders +children first, and adds push-only child work where needed. Cycles fail closed. +A changed reviewed plan must be displayed and confirmed again before any step. + +`git.workspaceCommitStep` accepts `{ session }` and returns the next session, +executing at most one commit or push. Session fields are `plan`, last observed +`states`, per-ID `results`, `blocked`, `cursor`, `commandFailed`, `finished`, +`succeeded`, and `canRetry`. They are Core-owned continuations: clients return +them unchanged and must not independently choose roots, reorder work, or infer +completion. `plan` includes bindings, options, `orderedIds`, propagation and +dependency relations, reviewed states, `committedIds`, and `pendingPushIds`. +Each result separates `committed`, `pushed`, stable `status`, and Git `detail`. +Status keys are `pending`, `notIncluded`, `waitingForSubmodule`, `reviewRequired`, +`committed`, `committedPushPending`, `committedAndPushed`, `commitFailed`, +`pushFailed`, `headAdvanced`, and `outcomeUnknown`. + +Each step uses a fresh host operation ID and the existing Git writer lease, +process runner, authentication and event stream. Cancellation blocks dependent +parents while independent roots may continue under subsequent operation IDs. +A read-only cleanup deadline of five seconds reconciles a commit whose HEAD may +have advanced before cancellation. This command preserves the reconciled session +instead of replacing it with a generic late-cancellation envelope. Transport +errors before a continuation is returned must never be treated as success. +Retry re-inspects current state, preserves completed commits, and re-pushes +externally advanced completed branches before updating dependent parents. + +Sessions own no background resources and are retained only for the current +workspace lifetime. Native clients discard old responses after workspace changes, +show confirmation/progress, and drive steps until `finished`. macOS uses this +shared workflow, as does Windows through its workspace-scoped continuation adapter +and real staging checkboxes. The native products must not duplicate planning or retry policy. +See `shared/fixtures/git/workspace-commit-v1.json` for primitive payloads and +`shared/fixtures/git/workspace-commit-workflow-v1.json` for the complete planning +and continuation fixture consumed by Rust, Swift, TypeScript and Tauri adapter tests. diff --git a/shared/fixtures/agent/acp-events-v1.json b/shared/fixtures/agent/acp-events-v1.json index cb4d0f8c8..306f7fb8b 100644 --- a/shared/fixtures/agent/acp-events-v1.json +++ b/shared/fixtures/agent/acp-events-v1.json @@ -1,6 +1,8 @@ { "version": 1, "commands": { + "authenticate": { "kind": "authenticate" }, + "refreshQuota": { "kind": "refreshQuota" }, "newSession": { "kind": "newSession", "token": "token-1" }, "loadSession": { "kind": "loadSession", "token": "token-2", "sessionId": "session-1" }, "listSessions": { "kind": "listSessions", "token": "token-3" }, @@ -13,6 +15,11 @@ "denyPermission": { "kind": "permission", "requestId": "permission-1", "optionId": null } }, "events": { + "authenticationRequired": {"kind": "authenticationRequired"}, + "authenticating": {"kind": "authenticating"}, + "account": {"kind": "account", "account": {"email": "person@example.test", "plan": "plus"}}, + "quota": {"kind": "quota", "snapshot": {"windows": [{"id": "codex:primary", "name": "codex", "limitSeconds": 18000, "usedPercent": 68, "resetsAt": 1800003600}], "fetchedAt": 1800000000}}, + "quotaFailed": {"kind": "quotaFailed", "code": "timeout"}, "ready": { "kind": "ready", "agentName": "example-agent", diff --git a/shared/fixtures/git/workspace-commit-v1.json b/shared/fixtures/git/workspace-commit-v1.json new file mode 100644 index 000000000..3e1809709 --- /dev/null +++ b/shared/fixtures/git/workspace-commit-v1.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "commitState": { + "head": "1111111111111111111111111111111111111111", + "branch": "refs/heads/main", + "indexEntries": "160000 2222222222222222222222222222222222222222 0\tlibs/B\u0000", + "gitlinks": [{ "path": "libs/B", "revision": "2222222222222222222222222222222222222222" }], + "stagedPaths": [], + "conflictedPaths": [] + }, + "dirtySubmodule": { "commitChanged": false, "trackedChanges": true, "untrackedChanges": false }, + "advancedSubmodule": { "commitChanged": true, "trackedChanges": false, "untrackedChanges": false } +} diff --git a/shared/fixtures/git/workspace-commit-workflow-v1.json b/shared/fixtures/git/workspace-commit-workflow-v1.json new file mode 100644 index 000000000..11a89dce6 --- /dev/null +++ b/shared/fixtures/git/workspace-commit-workflow-v1.json @@ -0,0 +1,156 @@ +{ + "request": { + "repositories": [ + { + "id": "A", + "root": "/workspace/A" + }, + { + "id": "A/B", + "root": "/workspace/A/B" + } + ], + "message": "commit", + "amend": false, + "push": true, + "includeParentReferences": true + }, + "states": { + "A": { + "head": "initial", + "branch": "refs/heads/main", + "indexEntries": "", + "gitlinks": [ + { + "path": "B", + "revision": "initial" + } + ], + "stagedPaths": [], + "conflictedPaths": [] + }, + "A/B": { + "head": "initial", + "branch": "refs/heads/main", + "indexEntries": "hello.ts", + "gitlinks": [], + "stagedPaths": [ + "hello.ts" + ], + "conflictedPaths": [] + } + }, + "preparation": { + "session": { + "plan": { + "repositories": [ + { + "id": "A", + "root": "/workspace/A" + }, + { + "id": "A/B", + "root": "/workspace/A/B" + } + ], + "message": "commit", + "amend": false, + "push": true, + "includeParentReferences": true, + "isRetry": false, + "orderedIds": [ + "A/B", + "A" + ], + "propagatedRelations": [ + { + "parent": "A", + "child": "A/B", + "path": "B" + } + ], + "dependencyRelations": [ + { + "parent": "A", + "child": "A/B", + "path": "B" + } + ], + "states": { + "A": { + "head": "initial", + "branch": "refs/heads/main", + "indexEntries": "", + "gitlinks": [ + { + "path": "B", + "revision": "initial" + } + ], + "stagedPaths": [], + "conflictedPaths": [] + }, + "A/B": { + "head": "initial", + "branch": "refs/heads/main", + "indexEntries": "hello.ts", + "gitlinks": [], + "stagedPaths": [ + "hello.ts" + ], + "conflictedPaths": [] + } + }, + "committedIds": [], + "pendingPushIds": [] + }, + "states": { + "A": { + "head": "initial", + "branch": "refs/heads/main", + "indexEntries": "", + "gitlinks": [ + { + "path": "B", + "revision": "initial" + } + ], + "stagedPaths": [], + "conflictedPaths": [] + }, + "A/B": { + "head": "initial", + "branch": "refs/heads/main", + "indexEntries": "hello.ts", + "gitlinks": [], + "stagedPaths": [ + "hello.ts" + ], + "conflictedPaths": [] + } + }, + "results": { + "A": { + "committed": false, + "pushed": false, + "status": "pending", + "detail": "" + }, + "A/B": { + "committed": false, + "pushed": false, + "status": "pending", + "detail": "" + } + }, + "blocked": [], + "cursor": 0, + "commandFailed": false, + "finished": false, + "succeeded": false, + "canRetry": true + }, + "reviewChanged": false, + "requiresConfirmation": true + } +} \ No newline at end of file diff --git a/shared/platform-feature-matrix.json b/shared/platform-feature-matrix.json index f8308ed12..df4308d1d 100644 --- a/shared/platform-feature-matrix.json +++ b/shared/platform-feature-matrix.json @@ -1,6 +1,6 @@ { "schemaVersion": 3, - "lastReviewed": "2026-09-25", + "lastReviewed": "2026-09-27", "review": { "status": "initial-static-inventory", "method": "根据 macOS Views/Application/Services、Windows features/extensions 和共享契约的代码入口进行初版盘点;未替代真实运行验收。", @@ -67,7 +67,9 @@ "windows": { "implementationStatus": "missing", "verificationStatus": "not-applicable", - "evidence": ["windows/tauri/src/features"] + "evidence": [ + "windows/tauri/src/features" + ] }, "owner": "Agent", "verification": "使用无本机模块配置的 macOS 测试账户启动并打开项目,确认 Agent 入口可见、默认开关关闭、面板显示设置指引;开启再关闭后入口和面板保留且不重复。确认读取入口不调用模块 factory、关闭状态无 Agent 连接或进程。Windows 尚无 Agent 对话 UI。" @@ -232,6 +234,59 @@ "owner": "Agent", "verification": "macOS:验证本机 Codex/Claude 配置读取、刷新与取消关联;添加自定义 TOML/auth JSON 或 Claude settings JSON,检查格式化、输入直引号不被自动替换、弯引号 TOML 错误在保存按钮上方始终可见、非法配置和缺失密钥、取消不保存、编辑保持 ID、删除确认及安全存储失败不丢失配置。在一个窗口保存并等待连接关闭期间,由另一窗口删除或修改同一供应商,确认旧保存失败且不恢复/覆盖供应商、密钥或 Agent 关联。检查窄宽面板、深浅主题、中英文本。空闲切换后确认请求到所选地址和模型;未发送空会话切换或意外退出后重新创建,不向旧 ID 加载,首条消息与文件只发送一次;已有消息、上游列出或成功加载的会话继续恢复,历史加载失败保留记录;正在响应、加载、权限待处理或配置确认时拒绝切换。提交服务商选择不被添加/启用改变。CLI 文件与 bundle 不被写入。Windows 编辑器及 Claude 真实请求待验证。" }, + { + "id": "agent-codex-subscription", + "area": "AI", + "group": "Agent 对话", + "capability": "Codex 供应商选择官方订阅:复用本机账号、显式浏览器登录与取消、API Key 模式切换及旧配置兼容", + "macos": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "macos/Sources/Lithe/Views/Agent/AgentProviderConfigurationView.swift", + "macos/Sources/Lithe/Models/AppModel/AppModel+AgentProviders.swift", + "macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift", + "macos/Tests/LitheTests/AgentProviderConfigurationTests.swift", + "rust/lithe-agent-host/src/subscription.rs" + ] + }, + "windows": { + "implementationStatus": "partial", + "verificationStatus": "pending", + "evidence": [ + "rust/lithe-agent-host/src/lib.rs", + "shared/fixtures/agent/acp-events-v1.json" + ] + }, + "owner": "Agent", + "verification": "macOS:已有 Codex ChatGPT 登录直接对话;未登录只显示登录按钮,点击才打开浏览器,取消与超时清理进程;API Key 与订阅空闲切换后验证实际计费来源,不携带旧 key/URL/模型、不改提交服务商;忙碌时拒绝切换,账号变化后断开;老配置仍走 API Key,Claude 不显示订阅选项。真实账号与 macOS UI 待验证;Windows 仅共享 host 已实现,UI 待接入。" + }, + { + "id": "agent-subscription-quota", + "area": "AI", + "group": "Agent 对话", + "capability": "Codex 订阅额度:上下文右侧紧凑显示、可见时定时刷新、悬停多窗口与重置时间、未知与过期状态及账号隔离", + "macos": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "macos/Sources/Lithe/Views/Agent/AgentSubscriptionQuotaView.swift", + "macos/Sources/LitheAgentConversationModule/Application/AgentSubscriptionQuota.swift", + "macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift", + "rust/lithe-agent-host/src/subscription/tests.rs" + ] + }, + "windows": { + "implementationStatus": "partial", + "verificationStatus": "pending", + "evidence": [ + "rust/lithe-agent-host/src/subscription.rs", + "shared/fixtures/agent/acp-events-v1.json" + ] + }, + "owner": "Agent", + "verification": "macOS:订阅会话上下文右侧显示额度,悬停查看窗口时长/已用百分比/重置和更新时间;真实账号验证可见且活跃时每分钟刷新、隐藏不轮询、单连接不并发探测,查询不创建会话或模型请求;周窗口作为 primary 仍显示 7d,缺失不报 0%;网络失败保留并灰显旧值,账号不匹配清除;API Key 模式不查询。窄宽面板、深浅主题、关闭面板/项目和取消时确认探测进程清理。Windows UI 与真实账号验证待完成。" + }, { "id": "workspace-open-switch", "area": "工作区", @@ -352,6 +407,31 @@ "owner": "Editor", "verification": "打开、编辑、保存、关闭和恢复多个文本文件,确认光标、脏状态和标签状态;在 macOS 打开无扩展名文本文件,确认标签与项目树图标一致;从外部改为二进制后折叠并展开项目树,确认两处图标更新一致。" }, + { + "id": "editor-pointer-selection", + "area": "编辑器", + "group": "文本编辑", + "capability": "鼠标与触控板点击、选词和拖选", + "macos": { + "evidence": [ + "macos/EditorFrontend/main.ts", + "macos/EditorFrontend/mouse-input.ts", + "macos/Experiments/Monaco/mouse-input.integration.ts" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "windows": { + "evidence": [ + "windows/tauri/src/features/editor/components/monaco-editor.tsx" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "owner": "Editor", + "notes": "macOS 在宿主修正 WebKit 零按钮按下事件,保留 Monaco 选择语义。Linux Chromium 真实 Monaco 控制事件回放通过,仍待 macOS 微信输入法与 Windows WebView2 实机验证。", + "verification": "macOS 分别启用微信输入法和系统 ABC,快速轻点不同位置后松手移动,确认不扩展选区、不移动文字;验证同位置双击选词、Shift 点击扩选、按住拖选、选中文字拖放、主编辑器和分屏及 diff。运行 scripts/probe-macos-monaco.sh --workbench-tests;Windows 验证同样的正常鼠标交互。" + }, { "id": "editor-language-basics", "area": "编辑器", @@ -547,6 +627,39 @@ "owner": "Git", "verification": "修改、暂存、取消暂存并提交文件,确认状态、提交消息和错误回显。 Windows 另验证原生 UNC/verbatim 输入、中文/空格/长路径仓库往返及 linked worktree;末尾点/空格必须明确拒绝,外部提交/切换须触发元数据刷新。" }, + { + "id": "git-multi-repository-change-groups", + "area": "版本控制", + "group": "Git", + "capability": "多仓库变更折叠分组", + "macos": { + "evidence": [ + "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" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "windows": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "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" + ] + }, + "owner": "Git", + "verification": "在同一工作区打开多个 Git 仓库,确认变更按仓库折叠分组;只剩一个仓库有变更时仍显示仓库名。仓库级和文件级勾选与 Git 暂存区同步;子模块只有未提交文件时显示提示,不能勾选未变化的引用。", + "notes": "两端直接消费共享 Rust Core 的计划、依赖排序、执行结果与重试。平台负责真实暂存勾选、确认与结果界面、认证取消和项目生命周期。原生界面实测待平台验收。" + }, { "id": "git-branches-remotes", "area": "版本控制", @@ -2330,6 +2443,123 @@ }, "owner": "PHP Support", "verification": "macOS 运行 PHP 文件和单条/整套 PHPUnit;Windows 运行字符串/数组 Composer script 和整套 PHPUnit。禁用后菜单消失、运行进程退出,特殊文件名保持原样。Windows 暂不支持单方法发现。" + }, + { + "id": "git-workspace-staged-commit", + "area": "版本控制", + "group": "Git", + "capability": "按文件所属仓库批量提交及推送,保留每仓库结果", + "macos": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "macos/Sources/LitheGitModule/Application/GitFeatureModel+WorkspaceCommit.swift", + "macos/Sources/Lithe/Views/Git/CommitAreaView.swift", + "macos/Tests/LitheGitModuleTests/GitModuleTests.swift", + "rust/lithe-core/src/git/commit_state.rs", + "rust/lithe-core/src/tests/git_workspace_commit.rs", + "rust/lithe-core/src/git/workspace_commit.rs", + "rust/lithe-core/src/git/workspace_commit/tests.rs", + "shared/fixtures/git/workspace-commit-workflow-v1.json" + ] + }, + "windows": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "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", + "windows/tauri/src/features/git/runtime/git-workspace-commit-host.tsx", + "windows/tauri/src/features/git/runtime/git-workspace-commit-host.test.tsx" + ] + }, + "owner": "Git", + "verification": "勾选两个独立仓库文件,一次提交并推送,验证各自 HEAD 和远程;停止一个操作后其余独立仓库继续,已成功仓库不会回滚。", + "notes": "两端直接消费共享 Rust Core 的计划、依赖排序、执行结果与重试。平台负责真实暂存勾选、确认与结果界面、认证取消和项目生命周期。原生界面实测待平台验收。" + }, + { + "id": "git-submodule-commit-plan", + "area": "版本控制", + "group": "Git", + "capability": "子模块先提交与推送、自动更新父引用、计划变化再次确认", + "macos": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "macos/Sources/LitheGitModule/Application/GitFeatureModel+WorkspaceCommit.swift", + "macos/Sources/Lithe/Views/Git/CommitAreaView.swift", + "macos/Tests/LitheGitModuleTests/GitModuleTests.swift", + "rust/lithe-core/src/git/commit_state.rs", + "rust/lithe-core/src/tests/git_workspace_commit.rs", + "rust/lithe-core/src/git/workspace_commit.rs", + "rust/lithe-core/src/git/workspace_commit/tests.rs", + "shared/fixtures/git/workspace-commit-workflow-v1.json" + ] + }, + "windows": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "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", + "windows/tauri/src/features/git/runtime/git-workspace-commit-host.tsx", + "windows/tauri/src/features/git/runtime/git-workspace-commit-host.test.tsx" + ] + }, + "owner": "Git", + "verification": "只勾选孙仓库文件,确认计划自动列出父祖仓库的引用更新且可关闭;确认期间改变暂存内容或分支,必须显示新计划再次确认。父仓库未暂存文件不能被带入;只勾选父引用时先推送子仓库现有提交,Git 发布检查应拒绝未发布的子引用。子仓库元数据被外部删除后必须拒绝读取和写入,不能向上回退父仓库。", + "notes": "两端直接消费共享 Rust Core 的计划、依赖排序、执行结果与重试。平台负责真实暂存勾选、确认与结果界面、认证取消和项目生命周期。原生界面实测待平台验收。" + }, + { + "id": "git-workspace-commit-retry", + "area": "版本控制", + "group": "Git", + "capability": "失败后只重试未完成的提交或推送步骤", + "macos": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "macos/Sources/LitheGitModule/Application/GitFeatureModel+WorkspaceCommit.swift", + "macos/Sources/Lithe/Views/Git/CommitAreaView.swift", + "macos/Tests/LitheGitModuleTests/GitModuleTests.swift", + "rust/lithe-core/src/git/commit_state.rs", + "rust/lithe-core/src/tests/git_workspace_commit.rs", + "rust/lithe-core/src/git/workspace_commit.rs", + "rust/lithe-core/src/git/workspace_commit/tests.rs", + "shared/fixtures/git/workspace-commit-workflow-v1.json" + ] + }, + "windows": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "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", + "windows/tauri/src/features/git/runtime/git-workspace-commit-host.tsx", + "windows/tauri/src/features/git/runtime/git-workspace-commit-host.test.tsx" + ] + }, + "owner": "Git", + "verification": "让子仓库推送失败,确认父仓库被阻塞、独立仓库成功;重试计划应显示子仓库只推送,验证子仓库提交数不增加,随后才提交和推送父仓库。", + "notes": "两端直接消费共享 Rust Core 的计划、依赖排序、执行结果与重试。平台负责真实暂存勾选、确认与结果界面、认证取消和项目生命周期。原生界面实测待平台验收。" } ] } diff --git a/windows/tauri/src-tauri/src/platform.rs b/windows/tauri/src-tauri/src/platform.rs index fbb962815..eec98c44e 100644 --- a/windows/tauri/src-tauri/src/platform.rs +++ b/windows/tauri/src-tauri/src/platform.rs @@ -30,25 +30,7 @@ pub async fn platform_invoke( .map(ToString::to_string) .unwrap_or_else(|| format!("windows-{}", REQUEST_ID.fetch_add(1, Ordering::Relaxed))); let (core_command, payload) = translate(&command, args)?; - let interactive_git = matches!( - core_command.as_str(), - "git.write" - | "git.commit" - | "git.apply" - | "git.patchApply" - | "git.rebaseStart" - | "git.rebaseControl" - | "git.executionConfigure" - | "git.initialize" - | "git.configureIdentity" - ) || matches!( - command.as_str(), - "git_add_remote" - | "git_remove_remote" - | "git_create_tag" - | "git_delete_tag" - | "git.command" - ); + let interactive_git = is_interactive_git(&core_command, &command); // Observe every Git request at the shared boundary. Core suppresses its // parser-only probes, so new operation entry points cannot miss the console. let observe_git = observes_git_execution(&core_command); @@ -100,6 +82,29 @@ pub async fn platform_invoke( core_response(&envelope, preserve_history_rewrite, preserve_stash_restore) } +fn is_interactive_git(core_command: &str, command: &str) -> bool { + matches!( + core_command, + "git.write" + | "git.workspaceCommitStep" + | "git.commit" + | "git.apply" + | "git.patchApply" + | "git.rebaseStart" + | "git.rebaseControl" + | "git.executionConfigure" + | "git.initialize" + | "git.configureIdentity" + ) || matches!( + command, + "git_add_remote" + | "git_remove_remote" + | "git_create_tag" + | "git_delete_tag" + | "git.command" + ) +} + fn observes_git_execution(command: &str) -> bool { command.starts_with("git.") && command != "git.authRespond" @@ -752,6 +757,8 @@ mod tests { "git.executionConfigure", "git.snapshot", "git.futureOperation", + "git.workspaceCommitPrepare", + "git.workspaceCommitStep", ] { assert!(super::observes_git_execution(command), "{command}"); } @@ -760,6 +767,46 @@ mod tests { assert!(!super::observes_git_execution("workspace.scan")); } + #[test] + fn workspace_commit_steps_use_interactive_authentication_and_write_timeout() { + assert!(super::is_interactive_git( + "git.workspaceCommitStep", + "git.workspaceCommitStep" + )); + assert!(!super::is_interactive_git( + "git.workspaceCommitPrepare", + "git.workspaceCommitPrepare" + )); + } + + #[test] + fn workspace_commit_continuations_and_partial_outcomes_cross_the_native_boundary_unchanged() { + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/git/workspace-commit-workflow-v1.json" + )) + .unwrap(); + let mut session = fixture["preparation"]["session"].clone(); + session["commandFailed"] = serde_json::json!(true); + let (command, payload) = super::translate( + "git.workspaceCommitStep", + serde_json::json!({ + "operationId": "step", "session": session + }), + ) + .unwrap(); + assert_eq!(command, "git.workspaceCommitStep"); + assert_eq!(payload, serde_json::json!({ "session": session })); + assert_eq!( + super::core_response( + &serde_json::json!({ "ok": true, "data": session }), + false, + false + ) + .unwrap(), + session + ); + } + use super::{ command_data_error, core_response, is_reviewed_history_rewrite, local_branch_reference, translate, diff --git a/windows/tauri/src/features/git/api/git-remotes-api.test.ts b/windows/tauri/src/features/git/api/git-remotes-api.test.ts index d145a8fa0..29358287f 100644 --- a/windows/tauri/src/features/git/api/git-remotes-api.test.ts +++ b/windows/tauri/src/features/git/api/git-remotes-api.test.ts @@ -93,7 +93,10 @@ describe("Git remote Pull API", () => { pullResult: { status: "cancelled" }, }); expect(invoke).not.toHaveBeenCalledWith("git_pull", expect.anything()); - expect(invoke).toHaveBeenCalledWith("git_status", { repoPath: "C:/repo" }); + expect(invoke).toHaveBeenCalledWith( + "git_status", + expect.objectContaining({ repoPath: "C:/repo" }), + ); expect(invoke).toHaveBeenCalledWith("git_log", { repoPath: "C:/repo", limit: 50 }); expect(invoke).toHaveBeenCalledWith("git_branches", { repoPath: "C:/repo" }); expect(invoke).toHaveBeenCalledWith("git_get_remotes", { repoPath: "C:/repo" }); 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 f8ba942f5..bcb49ac8e 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 @@ -56,18 +56,12 @@ afterEach(() => invokeSpy.mockRestore()); describe("Git status batch mutations", () => { const expectSingleGitWrite = () => { - expect( - invoke.mock.calls.filter(([command]) => command === "git.write"), - ).toHaveLength(1); + expect(invoke.mock.calls.filter(([command]) => command === "git.write")).toHaveLength(1); }; test("stages a directory selection with one shared Core invocation", async () => { await expect( - setFilesStaged( - "C:/repo", - ["src/first.ts", "src/second.ts", "src/first.ts"], - true, - ), + setFilesStaged("C:/repo", ["src/first.ts", "src/second.ts", "src/first.ts"], true), ).resolves.toBe(true); expectSingleGitWrite(); @@ -79,9 +73,9 @@ describe("Git status batch mutations", () => { }); test("unstages every selected path with one shared Core invocation", async () => { - await expect( - setFilesStaged("C:/repo", ["src/first.ts", "src/second.ts"], false), - ).resolves.toBe(true); + await expect(setFilesStaged("C:/repo", ["src/first.ts", "src/second.ts"], false)).resolves.toBe( + true, + ); expectSingleGitWrite(); expect(invoke).toHaveBeenLastCalledWith("git.write", { @@ -105,9 +99,7 @@ describe("Git status batch mutations", () => { }); test("adds selected paths to the repository gitignore", async () => { - await expect( - addPathsToGitignore("C:/repo", ["generated/", "local.env"]), - ).resolves.toBe(true); + await expect(addPathsToGitignore("C:/repo", ["generated/", "local.env"])).resolves.toBe(true); expectSingleGitWrite(); expect(invoke).toHaveBeenLastCalledWith("git.write", { @@ -118,9 +110,7 @@ describe("Git status batch mutations", () => { }); test("adds selected paths to the local Git exclude file", async () => { - await expect( - addPathsToLocalGitExclude("C:/repo", ["generated/"]), - ).resolves.toBe(true); + await expect(addPathsToLocalGitExclude("C:/repo", ["generated/"])).resolves.toBe(true); expectSingleGitWrite(); expect(invoke).toHaveBeenLastCalledWith("git.write", { @@ -134,7 +124,10 @@ describe("Git status batch mutations", () => { describe("Workspace Git status", () => { test("aggregates changed files from every discovered repository", async () => { await expect( - getWorkspaceGitStatus(["C:/workspace/service-a", "C:/workspace/service-b"], "C:/workspace/service-b"), + getWorkspaceGitStatus( + ["C:/workspace/service-a", "C:/workspace/service-b"], + "C:/workspace/service-b", + ), ).resolves.toEqual({ branch: "develop", ahead: 0, @@ -161,11 +154,18 @@ describe("Workspace Git status", () => { }); }); +test("workspace status sends all roots to Core for file ownership and preserves the index", async () => { + const roots = ["C:/workspace/service-a", "C:/workspace/service-b"]; + await getWorkspaceGitStatus(roots); + const queries = invoke.mock.calls.filter(([command]) => command === "git_status"); + expect(queries).toHaveLength(2); + for (const [, args] of queries) + expect(args).toMatchObject({ repositoryRoots: roots, includeIndexOnlyChanges: true }); +}); + describe("Git status review diffs", () => { test("reviews a partially staged path against HEAD before selected-path commit", async () => { - await expect( - getWorkingTreePathDiff("C:/repo", "src/partially-staged.ts"), - ).resolves.toBeNull(); + await expect(getWorkingTreePathDiff("C:/repo", "src/partially-staged.ts")).resolves.toBeNull(); expect(invoke).toHaveBeenLastCalledWith("git_diff_file", { repoPath: "C:/repo", @@ -177,7 +177,9 @@ describe("Git status review diffs", () => { function deferred() { let resolve!: () => void; - const promise = new Promise((done) => { resolve = done; }); + const promise = new Promise((done) => { + resolve = done; + }); return { promise, resolve }; } @@ -268,22 +270,26 @@ describe("Git staging write coordination", () => { describe("Git status query failures", () => { test("bootstrap preserves root-relative paths while reading every discovered repository", async () => { const snapshot = await getWorkspaceRootGitStatus("C:/repo", [ - "C:/repo", "C:/workspace/service-a", "C:/workspace/service-b", + "C:/repo", + "C:/workspace/service-a", + "C:/workspace/service-b", ]); expect(snapshot?.files.map((file) => file.path)).toEqual(["src/App.tsx"]); const queriedRepos = invoke.mock.calls .filter(([command]) => command === "git_status") .map(([, args]) => args?.repoPath); expect(queriedRepos.sort()).toEqual([ - "C:/repo", "C:/workspace/service-a", "C:/workspace/service-b", + "C:/repo", + "C:/workspace/service-a", + "C:/workspace/service-b", ]); }); test("bootstrap reports a child repository failure even when the root status succeeds", async () => { unavailableRepo = "C:/workspace/service-b"; - await expect(getWorkspaceRootGitStatus("C:/repo", [ - "C:/repo", "C:/workspace/service-b", - ])).rejects.toThrow("no snapshot"); + await expect( + getWorkspaceRootGitStatus("C:/repo", ["C:/repo", "C:/workspace/service-b"]), + ).rejects.toThrow("no snapshot"); }); test("rejects an empty snapshot for a selected repository and recovers on retry", async () => { @@ -295,9 +301,9 @@ describe("Git status query failures", () => { test("does not return a partial workspace when one repository is unavailable", async () => { unavailableRepo = "C:/workspace/service-b"; - await expect(getWorkspaceGitStatus([ - "C:/workspace/service-a", "C:/workspace/service-b", - ])).rejects.toThrow("no snapshot"); + await expect( + getWorkspaceGitStatus(["C:/workspace/service-a", "C:/workspace/service-b"]), + ).rejects.toThrow("no snapshot"); }); test("propagates native query failures to workspace refresh", async () => { 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 d27f9a90d..2ca4cb5d8 100644 --- a/windows/tauri/src/features/git/api/git-status-api.ts +++ b/windows/tauri/src/features/git/api/git-status-api.ts @@ -42,14 +42,18 @@ export const getGitStatus = async (repoPath: string): Promise // Keep failures distinct from a missing repository for workspace refreshes. // Optional status consumers retain the nullable getGitStatus API. -const queryGitStatus = async (repoPath: string, source: GitExecutionSource = "unknown"): Promise => { +const queryGitStatus = async ( + repoPath: string, + source: GitExecutionSource = "unknown", + repositoryRoots: readonly string[] = [], +): Promise => { const resolvedRepoPath = await resolveRepositoryPath(repoPath); if (!resolvedRepoPath) { return null; } - const requestKey = `${resolvedRepoPath}\0${source}`; + const requestKey = `${resolvedRepoPath}\0${source}\0${JSON.stringify(repositoryRoots)}`; const existingRequest = inFlightGitStatusRequests.get(requestKey); if (existingRequest) { return existingRequest; @@ -59,12 +63,22 @@ const queryGitStatus = async (repoPath: string, source: GitExecutionSource = "un if (!gitStatusGenerations.has(resolvedRepoPath)) { gitStatusGenerations.set(resolvedRepoPath, generation); } - const request = (source === "unknown" - ? tauriInvoke("git_status", { repoPath: resolvedRepoPath }) - : tauriInvoke("git_status", { repoPath: resolvedRepoPath }, { gitExecutionSource: source })) + const request = ( + source === "unknown" + ? tauriInvoke("git_status", { + repoPath: resolvedRepoPath, + includeIndexOnlyChanges: true, + repositoryRoots, + }) + : tauriInvoke( + "git_status", + { repoPath: resolvedRepoPath, includeIndexOnlyChanges: true, repositoryRoots }, + { gitExecutionSource: source }, + ) + ) .then((status) => { if (generation !== (gitStatusGenerations.get(resolvedRepoPath) ?? 0)) { - return queryGitStatus(resolvedRepoPath, source); + return queryGitStatus(resolvedRepoPath, source, repositoryRoots); } return status; }) @@ -124,7 +138,7 @@ export const getWorkspaceGitStatus = async ( // These paths are already discovered/selected repositories. A null response // is an unavailable snapshot, not evidence that the workspace has no changes. const readStatus = async (repoPath: string): Promise => { - const status = await queryGitStatus(repoPath, source); + const status = await queryGitStatus(repoPath, source, normalizedRepoPaths); if (!status) throw new Error("Git status query returned no snapshot"); return status; }; @@ -151,8 +165,7 @@ export const getWorkspaceGitStatus = async ( }); const activeStatus = - statuses.find((entry) => entry.repoPath === activeRepoPath)?.status ?? - statuses[0]!.status; + statuses.find((entry) => entry.repoPath === activeRepoPath)?.status ?? statuses[0]!.status; return { branch: activeStatus.branch, ahead: activeStatus.ahead, diff --git a/windows/tauri/src/features/git/api/git-workspace-commit-api.test.ts b/windows/tauri/src/features/git/api/git-workspace-commit-api.test.ts new file mode 100644 index 000000000..aff76c9fd --- /dev/null +++ b/windows/tauri/src/features/git/api/git-workspace-commit-api.test.ts @@ -0,0 +1,39 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import * as native from "@/platform/tauri-core"; +import fixture from "../../../../../../shared/fixtures/git/workspace-commit-workflow-v1.json"; +import { + cancelWorkspaceCommit, + prepareWorkspaceCommit, + stepWorkspaceCommit, +} from "./git-workspace-commit-api"; + +let invoke: ReturnType>; +beforeEach(() => { + invoke = spyOn(native, "invoke").mockResolvedValue(fixture.preparation); +}); +afterEach(() => invoke.mockRestore()); +test("workspace prepare forwards native bindings and user execution provenance", async () => { + expect(await prepareWorkspaceCommit(fixture.request, "prepare")).toEqual(fixture.preparation); + expect(invoke).toHaveBeenCalledWith( + "git.workspaceCommitPrepare", + { ...fixture.request, operationId: "prepare" }, + { gitExecutionSource: "user" }, + ); +}); +test("workspace step preserves Core partial result rather than converting it to an error", async () => { + const session = { + ...fixture.preparation.session, + commandFailed: true, + finished: true, + canRetry: true, + }; + invoke.mockResolvedValueOnce(session); + expect(await stepWorkspaceCommit(fixture.preparation.session, "step")).toEqual(session); + expect(invoke).toHaveBeenCalledWith( + "git.workspaceCommitStep", + { session: fixture.preparation.session, operationId: "step" }, + { gitExecutionSource: "user" }, + ); + await cancelWorkspaceCommit("step"); + expect(invoke).toHaveBeenLastCalledWith("core_cancel", { operationId: "step" }); +}); diff --git a/windows/tauri/src/features/git/api/git-workspace-commit-api.ts b/windows/tauri/src/features/git/api/git-workspace-commit-api.ts new file mode 100644 index 000000000..9e825dd89 --- /dev/null +++ b/windows/tauri/src/features/git/api/git-workspace-commit-api.ts @@ -0,0 +1,35 @@ +import { invoke } from "@/platform/tauri-core"; +import { emitGitChanged } from "../events/git-events"; +import type { + WorkspaceCommitPreparation, + WorkspaceCommitRequest, + WorkspaceCommitSession, +} from "../types/git-workspace-commit.types"; + +export const prepareWorkspaceCommit = (request: WorkspaceCommitRequest, operationId: string) => + invoke( + "git.workspaceCommitPrepare", + { ...request, operationId }, + { gitExecutionSource: "user" }, + ); + +export async function stepWorkspaceCommit(session: WorkspaceCommitSession, operationId: string) { + try { + return await invoke( + "git.workspaceCommitStep", + { session, operationId }, + { gitExecutionSource: "user" }, + ); + } finally { + for (const { root } of session.plan.repositories) { + emitGitChanged({ + repoPath: root, + scopes: ["working-tree", "history", "refs"], + source: "workspace-commit", + }); + } + } +} + +export const cancelWorkspaceCommit = (operationId: string) => + invoke("core_cancel", { operationId }); diff --git a/windows/tauri/src/features/git/components/git-commit-panel.tsx b/windows/tauri/src/features/git/components/git-commit-panel.tsx index db5b65a99..81864fb35 100644 --- a/windows/tauri/src/features/git/components/git-commit-panel.tsx +++ b/windows/tauri/src/features/git/components/git-commit-panel.tsx @@ -7,8 +7,12 @@ import { GearSixIcon as SettingsIcon, } from "@/ui/icons"; import type React from "react"; -import { useEffect, useLayoutEffect, useRef, useState } from "react"; -import { toast } from "sonner"; +import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react"; +import { useWorkspaceCommitStore } from "../stores/git-workspace-commit.store"; +import { workspaceCommitBindings } from "../utils/git-workspace-commit-bindings"; +import { GitWorkspaceCommitReview } from "./git-workspace-commit-review"; +import { workspaceCommitEnglish } from "@/i18n/git-workspace-commit"; +import type { TranslationKey } from "@/i18n/locale"; import { useSettingsStore } from "@/features/settings/stores/settings.store"; import { useTranslation } from "@/i18n/locale-provider"; import { Button } from "@/ui/button"; @@ -26,25 +30,25 @@ import { import { generateCommitDraft } from "../services/ai-commit-workflow"; import { showConfirmDialog } from "@/ui/dialog"; import { useUIState } from "@/features/window/stores/ui-state.store"; -import { commitSelectedChanges } from "../api/git-commits-api"; import { showGitPushDialog } from "../services/git-push-dialog-service"; -import { useGitBlameStore } from "../stores/git-blame.store"; -import { useGitStore } from "../stores/git.store"; -import type { GitFile } from "../types/git.types"; import { - getGitFileRepositoryPath, - resolveGitFileMutationPaths, -} from "../utils/git-status-selection"; + useActiveWorkspaceId, + useWorkspaceReady, + useWorkspaceStoreScopeId, +} from "@/features/workspace/stores/create-workspace-scoped-store"; +import type { GitFile } from "../types/git.types"; interface GitCommitPanelProps { selectedFiles: GitFile[]; + workspacePath: string; + repositoryPaths: string[]; + isStaging?: boolean; commitMessage: string; onCommitMessageChange: (message: string) => void; currentBranch?: string; repoPath?: string; ahead?: number; behind?: number; - onCommitSuccess?: () => void; onPull?: () => Promise | void; isPulling?: boolean; isPullLocked?: boolean; @@ -56,13 +60,15 @@ const COMMIT_TEXTAREA_MAX_HEIGHT = 128; const GitCommitPanel = ({ selectedFiles, + workspacePath, + repositoryPaths, + isStaging = false, commitMessage, onCommitMessageChange, currentBranch, repoPath, ahead = 0, behind = 0, - onCommitSuccess, onPull, isPulling = false, isPullLocked = false, @@ -84,7 +90,14 @@ const GitCommitPanel = ({ generationRef.current = null; }; }, [selection]); - const [isCommitting, setIsCommitting] = useState(false); + const workflow = useWorkspaceCommitStore((state) => state.workflow); + const batch = useSyncExternalStore(workflow.subscribe, workflow.getState, workflow.getState); + const isCommitting = batch.busy; + const activeWorkspaceId = useActiveWorkspaceId(); + const workspaceId = useWorkspaceStoreScopeId() ?? activeWorkspaceId; + const workspaceReady = useWorkspaceReady(workspaceId); + const isCurrentWorkspace = workspaceReady && workspaceId === activeWorkspaceId; + const setDraftOwner = useWorkspaceCommitStore((state) => state.setDraftOwner); const [isGenerating, setIsGenerating] = useState(false); const [isCommitActionMenuOpen, setIsCommitActionMenuOpen] = useState(false); const [remoteAction, setRemoteAction] = useState<"push" | null>(null); @@ -92,7 +105,6 @@ const GitCommitPanel = ({ const commitMenuAnchorRef = useRef(null); const commitTextareaRef = useRef(null); const selectedFilesCount = selectedFiles.length; - const operationState = useGitStore((state) => state.operationState); useEffect(() => { if (focusRequest <= 0) return; @@ -145,76 +157,46 @@ const GitCommitPanel = ({ }; const handleCommit = async (pushAfterCommit = false) => { + if ( + !isCurrentWorkspace || + isStaging || + batch.busy || + batch.review || + (batch.session && !batch.session.succeeded) + ) + return; if (selectedFilesCount === 0) { setError(t("git.selectFilesToCommit")); return; } if (!repoPath || !commitMessage.trim()) return; - const selectedRepoPaths = new Set( - selectedFiles.map((file) => getGitFileRepositoryPath(file, repoPath) ?? repoPath), - ); - if (selectedRepoPaths.size > 1 || !selectedRepoPaths.has(repoPath)) { - setError(t("git.selectSingleRepositoryForCommit")); - return; - } - - // A conflicted merge/rebase must be resolved before the merge commit can - // be finalized; guard here so Git's raw refusal never reaches the user. - const activeOperation = useGitStore.getState().operationState; - const conflictedPaths = activeOperation?.conflictedPaths ?? []; - if (activeOperation && conflictedPaths.length > 0) { - setError(t("git.resolveConflictsFirst", { paths: conflictedPaths.join(", ") })); - return; - } - if (activeOperation) { - setError(t("git.finishOperationBeforeCommit")); - return; - } - - setIsCommitting(true); + setDraftOwner(repoPath); setError(null); + await workflow.prepare({ + repositories: workspaceCommitBindings(workspacePath, repositoryPaths), + message: commitMessage.trim(), + amend: false, + push: pushAfterCommit, + includeParentReferences: true, + }); + }; - try { - const warnings = await commitSelectedChanges( - repoPath, - commitMessage.trim(), - resolveGitFileMutationPaths(selectedFiles), - ); - useGitBlameStore.getState().actions.clearAllBlame(); - onCommitMessageChange(""); - for (const warning of warnings) { - toast.warning( - warning.code === "git_index_reconcile_failed" - ? t("git.commitIndexReconcileFailed") - : warning.message, - ); - } - if (pushAfterCommit) { - setRemoteAction("push"); - try { - await showGitPushDialog(repoPath); - } catch (pushError) { - setError(pushError instanceof Error ? pushError.message : t("git.pushFailed")); - } finally { - setRemoteAction(null); - } - } - onCommitSuccess?.(); - } catch (error) { - setError( - error instanceof Error - ? error.message - : typeof error === "string" - ? error - : t("ai.unknownError"), - ); - } finally { - setIsCommitting(false); - } + const handleRetry = () => { + const previous = batch.session; + if (!previous?.canRetry || isStaging || !isCurrentWorkspace) return; + setError(null); + return workflow.prepare({ + repositories: workspaceCommitBindings(workspacePath, repositoryPaths), + message: previous.plan.message, + amend: previous.plan.amend, + push: previous.plan.push, + includeParentReferences: previous.plan.includeParentReferences, + previous, + }); }; const handlePush = async () => { - if (!repoPath) return; + if (!repoPath || isCommitting) return; setRemoteAction("push"); setError(null); @@ -234,9 +216,12 @@ const GitCommitPanel = ({ }; const isCommitDisabled = + !isCurrentWorkspace || + isStaging || selectedFilesCount === 0 || !commitMessage.trim() || - Boolean(operationState) || + Boolean(batch.review) || + Boolean(batch.session && !batch.session.succeeded) || isCommitting || isGenerating; const isGenerateDisabled = @@ -261,7 +246,7 @@ const GitCommitPanel = ({ return ( <> - {error && ( + {(error || batch.error) && (
- {error} + {error || batch.error} +
+ )} + + {batch.session && ( +
+ {Object.entries(batch.session.results).map(([id, result]) => { + const key = `git.workspaceCommit.${result.status}`; + const label = + key in workspaceCommitEnglish + ? (key as TranslationKey) + : "git.workspaceCommit.attention"; + return ( +
+ {id}: {t(label)} + {result.detail &&

{result.detail}

} +
+ ); + })} + {!isCommitting && ( +
+ {batch.session.canRetry && ( + + )} + +
+ )}
)} + {isCommitting && ( + + )} + {batch.review && isCurrentWorkspace && ( + + void workflow.confirm(workspaceCommitBindings(workspacePath, repositoryPaths)) + } + onClose={workflow.closeReview} + onIncludeParents={(include) => + void workflow.setIncludeParentReferences( + include, + workspaceCommitBindings(workspacePath, repositoryPaths), + ) + } + /> + )}