diff --git a/.agents/notes/implemented/architecture/2026-09-18-php-support-plugin-ownership-and-plans.md b/.agents/notes/implemented/architecture/2026-09-18-php-support-plugin-ownership-and-plans.md index 995e4e5c3..e7de6bdfe 100644 --- a/.agents/notes/implemented/architecture/2026-09-18-php-support-plugin-ownership-and-plans.md +++ b/.agents/notes/implemented/architecture/2026-09-18-php-support-plugin-ownership-and-plans.md @@ -25,7 +25,7 @@ PHP 支持由用户选择安装和启用,主程序不携带 PHP 插件包、No ### 能力与生命周期 - PHP 的 LSP 使用现有 Rust Core 会话,以 `intelephense --stdio` 启动。符号、类型和诊断仍由上游服务拥有;主机不实现第二套 PHP 语义分析。 -- macOS 插件管理页是 PHP 包和 Intelephense 的生命周期唯一入口:构建阶段按 `language-server.json` 下载并校验 npm tarball,把 launcher 和运行包放入插件 bundle;下载器再下载完整插件 zip,`MacPluginPackageStore` 同时验证插件 manifest、签名和语言服务器 launcher。安装、重装、回滚和卸载都针对同一个插件版本目录执行,因此不会留下脱离插件的 LSP。LSP 控制中心只显示当前项目的 PHP 语言服务器开关和运行状态,发现未安装、未启用或待重启时引导回插件管理页,不提供包操作按钮。 +- macOS 插件管理页是 PHP 包和 Intelephense 的生命周期唯一入口:构建阶段按 `language-server.json` 下载并校验 npm tarball,把 launcher 和运行包放入插件 bundle;下载器再下载完整插件 zip,`MacPluginPackageStore` 先按宿主内置 `OfficialPluginCatalog` 固定官方插件的签名策略,拒绝包内 manifest 将 `publisherPackage` 降级为 `sameTeamAsHost`,随后验证原生 bundle,并用内置 publisher Ed25519 公钥验证完整包清单、插件 ID、版本和文件 SHA-256,最后验证语言服务器 launcher。安装、重装、回滚和卸载都针对同一个插件版本目录执行,因此不会留下脱离插件的 LSP。LSP 控制中心只显示当前项目的 PHP 语言服务器开关和运行状态,发现未安装、未启用或待重启时引导回插件管理页,不提供包操作按钮。 - macOS 运行和测试使用插件模块持有的执行 session。相对文件名不做 trim,以 `-` 开头时加 `./`,避免把文件名当作命令选项。 - Windows 只有已安装且启用 PHP 扩展时才读取 Composer/PHPUnit 清单、展示运行入口;执行前再次检查开关。Composer 的字符串和字符串数组均交给 `composer run -- ` 执行,不在主机模拟脚本语义。 - Windows PHP 运行复用 Run 的输出面板和 native 进程启动能力,通用宿主服务在首个 await 之前按插件 ID 和工作区登记会话。禁用或关闭工作区时等待在途启动,再停止其拥有的 execution ID;自然结束释放所有权。不得通过普通终端事件绕过这个流程。 @@ -57,7 +57,7 @@ PHP 支持由用户选择安装和启用,主程序不携带 PHP 插件包、No ## 后果 -不使用 PHP 的用户不承担语言服务器下载、索引和进程成本。代价是首次使用需要显式安装插件和 Node.js;macOS 在线包必须使用与宿主一致的签名,未配置 Developer ID 的调试或预览构建仍只能使用本地导入进行测试。Windows 目前提供 Composer 脚本及整套 PHPUnit,未声明支持 macOS 已有的单方法测试发现。Windows 本地包暂不提供在线分发、自动更新或签名身份验证,替换版本需先卸载再导入;包格式仅用于小型 Worker 语言插件。目标平台运行验证未完成前,功能矩阵保持 pending。 +不使用 PHP 的用户不承担语言服务器下载、索引和进程成本。代价是首次使用需要显式安装插件和 Node.js;macOS 在线包需要 Lithe publisher Ed25519 签名,但不要求发布者持有 Developer ID;未配置 publisher secret 的 debug 包不会生成可安装的 PHP 包。Windows 目前提供 Composer 脚本及整套 PHPUnit,未声明支持 macOS 已有的单方法测试发现。Windows 本地包暂不提供在线分发、自动更新或签名身份验证,替换版本需先卸载再导入;包格式仅用于小型 Worker 语言插件。目标平台运行验证未完成前,功能矩阵保持 pending。 插件构建产物、PHPUnit 的 vendor 和应用语言工具缓存没有可靠的跨工作树身份标记,均在 `scripts/worktree-resources.json` 的 excludedResources 中排除。不得把它们共享为可变缓存。 @@ -73,7 +73,7 @@ PHP 支持由用户选择安装和启用,主程序不携带 PHP 插件包、No - `MacRuntimeToolDiscoveryTests`:验证启用的 PHP 插件版本目录优先提供 Intelephense launcher。 - `PluginPackageStoreTests/reinstallCanReplaceTheActiveVersionOnlyAfterValidation`:验证重装不会绕过签名校验,并在校验完成后替换当前版本。 - `prepare-php-language-server.sh`:按 JSON 清单下载、校验并组装 Intelephense 运行包;插件版本目录删除时一并删除 launcher 和缓存文件。 -- `.github/workflows/release-macos.yml`:Developer ID 构建额外发布架构对应的 PHP 插件 zip;未配置 Developer ID 时不发布可在线安装的独立包。 +- `.github/workflows/release-macos.yml` 和 `.github/workflows/release-preview-macos.yml`:每个架构都发布 PHP 插件 zip;`LITHE_PLUGIN_PACKAGE_PRIVATE_KEY` 缺失或不匹配时工作流失败,避免发布无法被客户端识别的包。 - `./scripts/test-macos.sh --filter LithePhpSupportModuleTests`:模块、路径与禁用清理测试。 - `LITHE_RUN_PHP_INTEGRATION=1 ./scripts/test-macos.sh --filter RealPhpIntegrationTests`:真实工具测试;先在 `shared/fixtures/phpunit-project` 执行 `composer install`,并提供 Node.js 与插件组装出的 Intelephense launcher。 - Windows 前端测试包含禁用时不扫描、Composer 数组、下载取消、在途启动后禁用及跨工作区进程隔离。Windows native 测试与实际应用启动必须在 Windows 环境执行;Linux 交叉编译不等于运行验收。 @@ -92,4 +92,4 @@ PHP 支持由用户选择安装和启用,主程序不携带 PHP 插件包、No - `windows/tauri/src/features/run-actions/` - `windows/tauri/src-tauri/src/language_tools.rs` - `shared/contracts/application-boundary.md` -- `shared/platform-feature-matrix.json` +- `shared/platform-feature-matrix/features/` 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 19dd4d4dc..62b2f7df5 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 @@ -17,21 +17,28 @@ Agent 对话默认关闭,打开某个项目的 Agent 面板时才启动本机 ## 决策 - +- **思考强度名称**:强度选项使用上游英文名称,例如 Low、Medium、High、Xhigh、Max、Ultra,不经过通用本地化。子菜单、设置行当前值和模型底栏复用同一个呈现入口,避免同一强度在不同位置显示中英文混合。菜单标题“思考强度”和其他配置仍按界面语言显示;不改上游选项 ID、可选范围或确认流程,也不手工补齐当前 Agent 未提供的强度。 +- **权限模式呈现**:菜单和底栏保留 Agent 上游名称,例如 Claude 的 Manual、Accept edits、Plan、Auto、Bypass permissions;模型和思考强度也使用上游名称。名称不能进入全局翻译表,否则 `Plan` 会碰到其他界面的“计划”翻译,导致个别选项与其余名称混用语言。Claude 与 Codex 按已知权限 ID 共享对应图标,Claude 继续显示上游实际提供的编辑、计划等额外选项。依据 [Claude 权限模式说明](https://code.claude.com/docs/en/permissions),说明可按界面语言呈现,但保留实际 Agent 的审批与环境约束,不把 Codex 的沙箱说明直接套到 Claude。只改变显示,不修改选项 ID、数量、顺序或权限命令;上游确认继续决定当前值。未知选项保留其原文和默认图标,不按列表位置猜测权限,也不把 Claude 的五项折叠成三项。不新增状态、资源或运行时写入。 +- **Agent 菜单标志与勾选**:输入框的 Agent 下拉菜单为每个选项显示 16 点品牌图标,复用底栏同一套只读资源与内存染色。原生单选控件(Picker)将当前选项的勾选状态与品牌图像分别呈现,避免选中项用勾选图标替换品牌。控件直接绑定面板已有的选择值与切换动作,不在 View 里再存一份当前 Agent;图标和勾选以实际选择为准,不改变连接、会话或权限语义。 - **侧栏入口图标**:Agent 使用 Lithe 原创的“对话气泡 + 星光”线性 SVG,由工作台 renderer 显式绑定,避免落入通用模块图标而让用户误认成插件管理。图标作为固定源资源随应用打包,复用现有模板着色与选中态;不下载、不生成运行时文件,不影响安装包的只读边界。面板内 Codex/Claude 标志仍表示具体供应商。 - **安装包图标解析**:Agent 品牌图标从应用的 `Contents/Resources/Lithe_Lithe.bundle` 只读加载;资源包或图标缺失时使用默认标志。不要在已安装的 `.app` 中直接调用 SwiftPM(Swift 包管理器)生成的 `Bundle.module`:它只查应用旁的资源包和编译目录,找不到会直接终止进程,开发机器残留的构建资源还会掩盖问题。开发和测试入口保留 SwiftPM 回退;安装版不使用编译目录,也不通过启动时复制资源来修补发行包,避免改变代码签名和 Sparkle 增量更新基线。 +- **底栏品牌标志**:参考 [CC GUI 的紧凑入口](https://github.com/zhukunpenglinyutong/jetbrains-cc-gui/blob/7bcd6c3803ec44944cef90f5bff0aa666cab99a2/webview/src/components/ChatInputBox/selectors/ProviderSelect.tsx) 与[品牌组件](https://github.com/zhukunpenglinyutong/jetbrains-cc-gui/blob/7bcd6c3803ec44944cef90f5bff0aa666cab99a2/webview/src/components/shared/ProviderModelIcon.tsx),Claude 使用彩色星芒,Codex 使用主题单色结形标志。上游 Web 入口为 16 像素;Lithe 原生工具栏使用 18 点视觉尺寸、28 点点击区域,模型入口保留较小的同品牌标志。Claude 的 `#D97757` 与既有 `@lobehub/icons 5.8.0` 彩色组件一致。轮廓复用既有 SVG,欢迎区和历史页继续使用模板着色。SwiftUI 的前景色在原生 Menu 转换图像时可能丢失,因此底栏通过只读 SVG 的透明度蒙版在内存中生成带颜色的原始图像,不依赖原生控件替模板补色。图像缓存身份包含资源包、尺寸和颜色,深浅主题切换不会拿到旧色,也不修改其他视图共用的模板。正确做法是按品牌和主题选择颜色;不要复制 SVG 路径、改写源图或把染色图写进 bundle。该派生图像只在应用内存中存活,不新增磁盘缓存、下载或跨工作树复用资源,不影响代码签名和 Sparkle delta。 - **共享实现**:`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` 登录时直接报错,不会退而使用它的账号登录。Codex 使用 Responses,Claude 使用 Anthropic Messages;二者均不静默回退到账户订阅。 +- **API Key 按上游公开接口传递**:Lithe 通过 ACP 的标准输入输出(stdio)通道把凭据传给适配器,不进入 Lithe 启动适配器的命令行参数、环境变量或文件,也不设置 `APP_SERVER_LOGS`。Codex 使用 Responses,在初始化时声明 `auth._meta.gateway = true`,通过 `gateway` 登录传服务商地址和 `Authorization: Bearer `;没有该登录能力就报错。Claude 使用 Anthropic Messages,通过上游公开的 `_meta.claudeCode.options` 在 `session/new` 与 `session/load` 同时传入地址、API Key 和模型;由上游适配器与 Claude SDK 管理其内部原生凭据转换。SDK 的环境配置和程序化 settings 两层都显式指定同一路由,并清除旧 Bearer、OAuth、自定义请求头、云平台路由与 `apiKeyHelper`,防止用户或项目设置覆盖所选服务商。缺少凭据时直接报错,二者都不静默回退到账户订阅。 +- **Claude SDK 子进程的凭据边界**:上面的保证只覆盖 Lithe 启动 ACP 适配器这一层,不覆盖整个子进程树。当前固定版本的 Claude SDK 会把对象形式的 `options.settings` 序列化为 Claude Code CLI 的 `--settings` 命令行参数,因此 `settings.env.ANTHROPIC_API_KEY` 会出现在该 CLI 子进程的参数里;开启 SDK debug 还可能把完整参数写进调试文件。同用户进程和诊断工具可以读取这些参数,不能把这一实现描述为全进程树都不暴露 Key。原 gateway 路径也把带 Key 的自定义请求头放进程序化 settings,本次路由修复没有消除这项上游暴露。后续独立评估把 Key 仅放在 `options.env`、settings 仅保留非敏感路由;采用前必须验证用户和项目 settings 中旧 Key 的覆盖优先级,以及新建、历史恢复和切换服务商都使用所选凭据,不在当前修复中直接删去凭据覆盖层。 - **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。 +- **配置菜单间距**:模型和设置选项使用较小的最小行高,权限选项保留标题与最多两行说明需要的空间;行内容较高时允许自然增高,不用固定行高裁剪文字。权限面板按实际内容高度收紧,测量值在最大可视高度处截断并只更新弹出面板的局部状态,避免滚动时反复调整面板大小;长列表继续由原生滚动容器承载,不能把行距变小却保留旧面板高度,或为了紧凑隐藏上游选项。名称和权限语义由既有呈现规则及 ACP 确认流程决定,布局不改写选项 ID 或当前值。 - **上下文用量以上游为准**:输入框顶部参考 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 自己的模型目录。仅刷新本机配置仍不够:codex-acp 1.13.1 会把不在实际模型目录中的配置值临时补进选项。共享 host 保留上游同时返回的旧式模型目录,并协商其版本化推荐值扩展(`jetbrains.air.recommendedValue`);只有当前值确实不在目录中、推荐值同时存在于目录和选项中时,新会话才通过标准 ACP 配置请求切到该推荐值。等待上游确认后再发布会话,两次请求共用创建会话的有界超时。配置中有效的模型、历史会话和用户文件不被自动改写;扩展或目录缺失时保留标准 ACP 行为,不猜默认模型、不按版本名或描述筛选。ACP 模型菜单使用该 Agent 已有的品牌 SVG,不使用通用 CPU 图标;显示标签仍由上游确认的当前值决定。请求完成前禁止重复配置和发送,失败保留之前确认的值并显示错误。适配器没暴露的配置不画假控件,也不替用户改变网关地址或全局 CLI 配置。 +- **会话配置准备期间的显示**:本地供应商默认模型只是启动输入,不是当前会话的已确认模型。切换到尚未初始化的 Agent 时,工具栏从连接启动、准备空会话到默认模型修正完成,连续显示“正在加载会话设置”;加载历史也等待该会话自己的配置,不先闪现本地默认模型或另一个会话的选项。进程报告 ready 只表示连接可用,不能当成模型目录已就绪。已有连接和已确认会话在来回切换时直接复用,不额外加载;创建或加载失败、需要登录、连接失败以及上游不提供配置时退出加载状态,保留真实错误和既有默认模型回退。发送按钮和回车发送都遵循准备状态。这是原生界面根据现有连接事件推导的显示行为,不新增 ACP 状态、模型缓存、资源目录或运行时 bundle 写入。 +- **关闭最后一个标签后准备新会话**:连接已 ready 时,关闭最后一个会话由连接模型立即调用既有 `prepareConversation()`,发起一次新建请求,不等待视图重新出现或连接状态再变化。准备中的创建请求复用已有 token,重复关闭或准备不会重复创建;旧确认不能重新选中已关闭的会话。保留“正在加载会话设置”的显示与发送限制,收到新会话确认后才恢复模型选择和发送。还有其他标签时直接选中最后一个标签,断连时不创建;这只移除本地标签,不删除上游历史。不要仅为解除阻塞取消准备状态判定,否则会重新闪现未确认的本地默认模型。 - **工具证据与文档保护**:工具详情合并上游的部分更新,展示类型、输入、输出、文件位置和修改前后文本;权限卡复用已收到的工具证据,区分允许和拒绝。显示文本每段限制 32 Ki 字符,内容和位置各限制 100 条,避免大工具输出堵住界面;截断处标记 `[...]`。点击项目内文件交给现有编辑器导航,不在视图中直接读写文件。磁盘刷新、脏缓冲区保护和 Git diff 沿用文档/Git 模块。Agent 从项目目录启动,不需要先附加当前文件才能读写磁盘;未保存的编辑器快照是后续独立能力。 - **未发送的空会话不能当成历史恢复**:为了提前显示配置,界面会先创建空会话,但 Codex 在首条消息前还没有把会话内容写入磁盘(rollout),旧进程关闭后用原 ID 加载会报 `no rollout found`。连接模型只跟踪本连接新建、未成功提交消息且未出现在上游历史中的会话;断开时移除这些会话的本地标签和临时记录,重连后通过原有准备流程新建。已有消息、上游列出或成功加载的会话继续恢复,不根据“当前消息列表为空”推断历史不存在,也不按错误字符串给真实历史自动新建替身。这个操作不删除用户的上游历史文件;代价是未发送会话的临时配置选择需要在新连接重新确认。 - **历史加载失败不清空记录**:回放时保留原会话快照,成功后使用上游回放,失败或连接退出时恢复之前记录并丢弃不完整的回放片段。旧连接事件任务被取消后不再消费缓冲事件。 @@ -46,7 +53,7 @@ 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 和模型的传法**:API 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 stdio 传给适配器。Responses 使用 `gateway` 的 `Authorization: Bearer`;Claude 使用公开会话参数中的原生 API Key 配置,由 SDK 发出 `x-api-key`。Codex 的模型用 `CODEX_CONFIG`,Claude 的模型用 `ANTHROPIC_MODEL` 并在会话 options 中显式提供。服务商配置里的"模型"必须传给 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 避免重复;关闭后保留面板和入口。不要把默认开关改为启用来修复发现入口的问题,否则会改变所有用户的后台启动行为。 @@ -54,13 +61,27 @@ Agent 对话默认关闭,打开某个项目的 Agent 面板时才启动本机 - **面板始终显示完整布局**:功能关闭或没有配置 Agent 时,面板照样显示会话标题、消息区和输入框,用户可以输入;发送时先校验(功能是否开启、是否有已配置的 Agent、模块是否启动完成),不通过就在输入框上方给出提示并提供进入设置的按钮,不会启动任何进程。 - **文件拖入对话框**:参考 CC GUI 普通文件的路径引用交互,原生输入框接受 Finder 和项目树的文件 URL,显示可移除标签,并支持文件选择器。一次最多 32 个引用,按拖入顺序去重;切换会话清除旧文件草稿,发送失败保留文字和引用。功能模型把引用与文字一起排队,Rust Host 用上游 ACP SDK 的 `resource_link` 发送,Agent 自己读取文件并遵守现有权限。文件 URL 是这次消息的原生上下文,不作为跨平台项目标识持久化。正确做法是发送“说明这两个文件”加两个引用;不要在 View 里读文件、复制进缓存或拼装隐式文件内容提示词。图片目前同样作为文件引用,没有缩略图或多模态字节上传;这避免了与本次需求无关的内存、大小和格式策略,后续需要图片输入时再按上游能力协商。 - **对话面板**:停靠在编辑器右侧,与 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 时,解析当前连接的视图必须直接观察 `AgentConversationFeatureModel`,连接内的模型与权限则由 `AgentConnectionModel` 发布。不能只观察外层 AppModel 再读取可选模块,否则选择已经改变,品牌、模型列表与发送目标仍可能停留在旧连接。正确做法是让 `AgentConfiguredConversationView` 持有观察关系,并按 Agent ID 重建连接视图;每个 Agent 的原连接和会话继续保留。不要靠通知整个工作台或重启进程来刷新选择,这会增加无关重绘或中断后台会话。代价是视图明确分为项目选择与连接内容两层;新增菜单必须继续使用同一选择源。该修复只调整内存观察,不新增文件、缓存或下载资源,也不写发行 bundle。 +- **对话区的视觉与交互**:按 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 会比外层进程晚几秒退出。API Key 模式下 Agent 意外退出时,报错里附上 stderr 最后 20 行,其中的 Key 会被替换掉;订阅模式仅显示受控错误,不附加账号工具的原始输出。 正确做法:新平台的界面通过平台适配器把 fixture 里的命令交给 `lithe-agent-host`,并把工具权限选择交给用户。 不要这样做:在 Windows React 层重新实现 JSON-RPC 协议;在打开 IDE 时就启动 Agent;取消尚未确认就解锁发送;把取消超时伪装成成功而不提示用户重连;把 API Key 通过环境变量或命令行传给 Agent;替用户下载一份他本机已有的 Agent CLI。 +### 思考、计划、斜杠命令与模式通知 + +共享 Host 一直把 ACP 会话更新(`SessionUpdate`)原样转发,以前 Swift 只消费消息、工具、标题、配置和用量,其余直接丢弃。参考 CC GUI 的可折叠思考块、计划面板与 `/` 命令补全后,macOS 现在消费下面四类标准更新;Host、C ABI 和 fixture 形状都不变,只在 fixture 中补样例并由 Rust 测试确认它们能被官方 SDK 原样往返。 + +- **思考(`agent_thought_chunk`)**:存为独立的 `thought` 角色消息,不并进 Agent 回复。流式缓冲按角色记录,角色切换先刷新上一段,所以“思考 → 回复 → 工具 → 思考”保持原顺序。只有正在流式输出的最后一段思考默认展开,后续回复或工具到达后自动收起,用户手动展开的状态保留;搜索时临时展开匹配的思考,优先于已有的手动折叠选择;搜索期间不允许再次收起命中文字,清空搜索后恢复手动选择,不把搜索结果写回折叠偏好。手动偏好由 `AgentTranscriptView` 在视图局部内存中按会话 ID 与消息 ID 保存,再以绑定传给思考行;搜索先过滤消息,会移除并重建行,因此不能让行内状态成为偏好的所有者。切换会话保留各自选择,关闭会话或移除实际消息时释放对应偏好,销毁对话视图时一并释放;不写入功能模型、共享 Host、历史文件或安装目录,不影响代码签名和 Sparkle delta。`AgentThoughtTranscriptTests` 直接挂载实际对话容器,通过原生鼠标事件和完整渲染快照覆盖搜索移除/重建、命中时临时展开和会话切换。导出 Markdown 用引用块写出思考,便于和回复区分;历史消息数仍只统计用户与 Agent 回复。活跃思考块的标题显示“思考中…”,底部等待行改为“回复中…”并保留本轮计时,避免重复提示;取消时仍显示“正在停止…”。 +- **计划(`plan`)**:ACP 规定每次都发完整计划,所以整体替换,不做合并;空列表清除计划,格式错误的更新忽略,最多保留 100 条。计划条状态只认 `pending`、`in_progress`、`completed`,未知状态的条目丢弃而不是猜测。计划栏固定在活动统计栏上方,折叠时显示进度和当前步骤。`session/load` 回放会自然恢复计划。 +- **斜杠命令(`available_commands_update`)**:按会话保存上游列表,最多读取前 200 条;同名命令只保留首条定义,保证命令行身份唯一。断连时清空每个会话的旧命令,重连加载后等待新进程重新上报,避免显示已经失效的能力。输入框以 `/` 开头且命令名中还没有空白时弹出过滤结果,前缀匹配排在包含匹配之前;选择后只把 `/name ` 填入草稿,命令文本仍作为普通 prompt 原样发送,由 Agent 自己解释。实测 codex-acp 1.13.1 把 Skill 以 `$name` 形式放进同一个命令列表,而它的命令解析会跳过 `/$name`,Skill 需要以 `$name` 提及。因此名字以 `$` 开头的命令补全为 `$name `;输入 `$` 只列 Skill,Agent 没有上报 Skill 时 `$` 仍是普通文字。Lithe 不扫描 SKILL.md 或命令目录,也不内置命令表,避免和 Agent 的真实能力不一致。方向键和 Tab 导航依赖 macOS 14 的 `onKeyPress`;macOS 13 保留鼠标选择和 Return 补全。Return、Esc、方向键与 Tab 共用视图局部的补全状态,Return 在命令名未完整时只补全、完整时发送;Esc 先关闭补全,再次 Esc 才停止回复,编辑草稿后重新显示匹配。补全高度由输入区内部的局部布局容器按剩余空间限制,先保留一行可编辑文字;最小输入区放不下一整行命令时,列表浮在输入框上方,通过锚点偏好在整个对话布局中绘制,让鼠标点击范围不被较小的输入面板限制;沿用工作台悬停提示的局部 overlay 模式,不新增全局位置状态或鼠标监听。输入区的最小高度包含上下栏、可编辑行和外边距,附带文件时再预留附件行;这个值只在附件有无改变时通过视图偏好更新,不跟随拖动逐帧发布。不要让固定高度的命令列表参与分隔条的最小尺寸计算,也不要把逐帧几何尺寸发布到会话模型。 +- **模式通知(`current_mode_update`)**:Agent 自己切换模式(例如退出计划模式)时,只更新 `category == "mode"` 且包含该值的配置选项,不向上游回发设置请求;选项中不存在的模式 ID 忽略,不显示原始 ID。 + +正确做法:新的更新类型先在 fixture 中加入样例,让 Rust 往返测试证明它是合法 ACP,再在平台功能模型中解析。不要这样做:在视图中直接解析原始更新;为了显示命令列表而自己维护一份 Claude 或 Codex 命令表。 + +这些状态只保存在会话内存中,不新增日志、下载、缓存或可复用工作树资源;安装目录和 bundle 仍只读,不影响代码签名和 Sparkle delta。 + ### 每轮耗时与上报 token 发送后用单调时钟(不受系统日期调整影响的计时源)开始测量,包含建会话、加载历史、工具执行和权限等待。界面“思考中”的秒数表示本轮已经过的时间,不声称是模型内部推理时间。只让可见等待行每秒重绘,不每秒发布整个会话。正常完成、请求失败和断连固定耗时;取消仍等上游确认才结束。每轮末尾保留统计,标签切换不会重置,失败发送和历史回放不编造记录。 @@ -101,6 +122,16 @@ Zed 就是这么做的。但会装 Agent 的用户本机通常已经有 Node。 定时重发取消针对单个适配器缺陷,不能成为通用协议策略,因此不采用。单纯超时结束并清空会话会丢上下文,也不采用。当前选择保留记录、显式报错并重连加载的有界恢复:代价是重启连接,但能保证下一轮不与未结束的旧轮混在一起。 +### Claude 使用通用 gateway、手工修改适配器或另建 HTTP 代理 + +Claude ACP 0.81.2 的 gateway 模式会将 `ANTHROPIC_AUTH_TOKEN` 设成占位值 `acp-proxy`;0.84.0 仍使用同样逻辑。原生 CLI 请求头实验显示它与真实 `x-api-key` 同时发送,而部分服务商优先校验 Bearer,结果是有效密钥也收到 401。额外自定义 Authorization 未能覆盖占位值,单纯升级同样无法消除此原因。 + +上游已经公开 `NewSessionMeta.claudeCode.options`,且恢复会话同样接受它。实测不走 gateway、通过这条接口传 SDK 原生 API Key 配置时,同一固定适配器能够完成回复,只发正确的 x-api-key。因此采用此接口完整复用上游 Agent、权限、工具、取消和历史;不修改下载后的第三方文件、不维护供应商专用分支、不增加本地 HTTP 代理。代价是凭据路由需要在每次新建和恢复会话时携带,升级适配器时必须验证这条公开扩展。 + +正确做法:在共享 Host 的 `session_routing` 中生成两层一致的会话配置,并让新建、历史恢复共用它。不要只清理启动环境或只给 session/new 配置;用户 settings 后加载会覆盖环境,历史恢复也会重新创建 SDK Query。手工修改用户级 npm 包会被重装覆盖,新增代理则增加本可避免的监听端口和生命周期,不采用。 + +路由仅在连接内存中保存,不产生下载、缓存或生成资源,工作树复用清单无需新增资源;不写发行 bundle 或安装目录,不影响代码签名和 Sparkle delta。 + ### 环境变量传 Key、或把 `CODEX_HOME` 指到 Lithe 目录 `OPENAI_API_KEY` 这类环境变量只适用于官方地址,也更容易泄露。隔离 `CODEX_HOME` 会让 Agent 读不到用户自己的 MCP 服务器、Skills 和全局指令;而一旦设置了网关,请求本来就不会用到用户的账号登录,所以没有必要隔离。 @@ -127,14 +158,19 @@ npm 的进度选项只面向终端,HTTP 日志通常在请求完成后才输 ## 验证 +- `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter AgentThoughtTranscriptTests`:在生产对话容器中通过鼠标展开/收起思考,搜索隐藏行后进入空会话使列表实际销毁,再返回并清空搜索,确认手动选择保留;同时验证命中时保持文字可见、清空后恢复折叠、会话切换独立和关闭后重新加载的默认状态。渲染只等待可观察的原生画面,每次等待最多两秒;比较同一宿主的完整画面,不依赖系统字体快照基线。窗口和功能模型在成功、失败路径都清理。 +- `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter AgentConversationSelectionTests`:原生 SwiftUI 宿主不替换根视图,直接改变模块选择,验证 Agent、模型与连接立即同步;配置确认前保留旧值、确认后刷新,来回切换保持各自配置,删除当前 Agent 时正确回退,关闭全部测试窗口和连接。另对 Claude/Codex 显式驱动最后一个标签关闭→唯一新建请求→过期响应→新会话确认→发送,确认连接不变、准备期间不闪现模型、确认后底栏恢复且消息只交给新会话。显式控制切换→连接→ready→创建确认及历史加载的事件顺序,比较原生工具栏,验证准备期间改变本地默认模型不影响显示、过期响应不能解除等待、已确认连接复用、失败和无配置能力不会一直加载。2026-10-01 修复版真实窗口已验证 Codex 初始化加载提示到 Astra/审批控件、Astra→Luna→Astra 模型切换和重新展开的勾选、模型名称搜索与空结果、Claude/Codex 来回切换保留配置,Codex 最小消息收到预期回复。关闭测试项目后确认无 Agent 子进程,990 个 bundle 文件哈希不变;Windows 对话 UI 与运行验收仍待完成。 + - `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'AgentConversationFeatureModelTests|AgentConversationPresentationTests'`:注入可推进的单调时钟验证排队、后台会话、取消确认、失败和断连;共享 fixture 校验用量、零值与缺失值,统计行的分组边界与历史回放不伪造。真实 Agent 的 token 口径和连续计时仍需人工验收,不能以合成数据截图代替供应商运行验证。 -- `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter AgentBrandIconResourceTests`:临时安装包布局覆盖 Codex/Claude 图标加载、资源包和图标缺失时安全回退、缓存隔离,比较读取前后的文件清单与内容,确认不改写发行资源。 +- `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter AgentBrandIconResourceTests`:临时安装包布局覆盖 Codex/Claude 图标加载、资源包和图标缺失时安全回退、缓存隔离,比较读取前后的文件清单与内容,确认不改写发行资源;原生输入框 Menu 的像素验证同时覆盖 Claude/Codex 和深浅主题,确保橙色/灰色标志实际可见,染色不改变共用模板。 - 订阅新增测试覆盖旧配置兼容、显式登录、已有账号、认证通知顺序、登录取消和超时、账号变更、额度真实窗口/缺失值/多 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`) +- Claude 路由回归使用 `shared/fixtures/agent/acp-events-v1.json` 的 `upstream.claudeSessionRouting`,覆盖凭据缺失不回退、冲突鉴权清理、新建和历史恢复都带路由、Codex gateway 不变;真实验收通过 `LITHE_ACP_E2E_AGENT_ID=claude-acp` 选择已安装的 Claude 适配器,验证回复、读文件、修改、执行测试与追问。 +- 真实模型切换集成测试必须选择不同的上游模型值,并在确认后发送最小消息,不能仅对当前值做一次成功往返。后续选项取自最新确认的目录;例如 Claude 切到 Haiku 后不再提供思考强度,应验证该能力消失,不向它发送旧目录里的 effort 选项。 - 真实 Agent 端到端测试默认忽略,需要设置 `LITHE_ACP_E2E_*` 环境变量后运行:`cargo test -p lithe-agent-host --test real_agent -- --ignored`。设置 `LITHE_ACP_E2E_DATA_DIR` 时,会先用 npm 安装适配器,再从 Lithe 数据目录启动。 - `shared/fixtures/agent/acp-events-v1.json` 同时由 Rust 序列化测试和 Swift 功能模型测试读取。 - `macos/Tests/LitheTests/AgentConversationEntryPolicyTests.swift` 使用真实模块运行时覆盖默认关闭、启用和再次关闭,验证静态入口及 action/renderer 绑定可用、不重复、不提前调用 factory,其他关闭模块不会跟着暴露。 diff --git a/.agents/notes/implemented/feature/2026-09-30-windows-explorer-git-context-menu.md b/.agents/notes/implemented/feature/2026-09-30-windows-explorer-git-context-menu.md new file mode 100644 index 000000000..5410f64d1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-30-windows-explorer-git-context-menu.md @@ -0,0 +1,44 @@ +# Agent 笔记:Windows 文件树右键 Git 菜单 + +状态:已实现 + +关联 Issue #975。 + +## 先说结论 + +Issue #975 要右键直接比对和提交单个文件。Windows 端 Diff、暂存、提交的底层链路都已存在,本次只加文件树右键入口(文件:Git 子菜单 Show Diff / Add / Stage and Open Commit…;目录:Show Diff),不新增 Tauri command。issue 的搜索诉求由既有 Ctrl+Shift+F 满足,未扩展。 + +## 问题 + +此前比对或提交单个文件只能打开源代码管理面板、在变更列表里找;文件树右键没有任何单文件 Git 动作。 + +## 决策 + +- 菜单纯逻辑放 `windows/tauri/src/features/file-explorer/lib/file-context-menu-git-file-items.ts`(能力判定 + 构建 + 虚拟路径契约),单测同目录。 +- 右键时异步解析所属仓库与状态(`resolveRepositoryForFile` + `getGitStatus`),支持嵌套仓库;干净文件不显示 Git 菜单,只有已全部暂存且无剩余工作区修改时隐藏 Add;MM/AM 部分暂存仍显示 Add。显式暂存整文件会包含剩余编辑,原有复选框保留部分暂存的语义不变。 +- Show Diff 以 `createSingleFileWorkingTreeDiff` 载荷打开 `diff://working-tree/all-files`,`workingTreeTargets` 显式携带所属仓库,首次打开与后续刷新都比较 HEAD 到完整工作区内容,并保留 hasStagedChanges 以限制不安全的块丢弃;后续刷新/切换按 target.repoPath 重读,不再拿工作区根猜仓库(多仓库安全,PR #989 review 意见③)。Stage and Open Commit…(暂存并打开提交)先暂存该文件再打开源代码管理提交面板,复用现有提交流程;面板提交的是当前暂存区全部内容而非仅该文件,动作按此语义命名(PR #989 review 意见①)。 +- 只准调 `@/features/git/api/*`;禁止为这些动作新增 Tauri command 或绕过 API 层直接 invoke。 + +## 考虑过的备选方案 + +- 同步读 `workspaceGitStatus`:只覆盖工作区根仓库,嵌套仓库下菜单会静默消失,弃用。 +- 复用 `useGitDiffActions.viewFileDiff`:需要 Git 视图内部的大量输入,拖进文件树过重,弃用。 +- 新做单文件提交对话框:与既有提交面板重复,弃用。 + +## 后果 + +- 收益:单文件比对/暂存一次右键完成,并可直达提交流程,多仓库可用。 +- macOS 端已按 reviewer 邀请对齐(Git 子菜单装 Add 与 Stage and Open Commit…,Show Diff 复用既有独立菜单项不重复;可见性判定在 `GitFileContextMenuPlan`,多参考了既有 `canToggleStaging` 门禁)。 +- 代价:Git 子菜单在菜单弹出后约一次 IPC 往返才出现(Windows)。 + +## 验证 + +- `cd windows/tauri && bun test src/features/file-explorer/lib/file-context-menu-git-file-items.test.ts` +- `cd windows/tauri && bun run typecheck`;`./scripts/verify-windows-boundaries.ps1` +- `node scripts/generate-platform-feature-matrix.mjs && ./scripts/verify-platform-feature-matrix.sh` +- 实机点检项见矩阵 `git-explorer-context-actions` 条目的 verification 字段。 + +## 适用范围 + +- `windows/tauri/src/features/file-explorer/hooks/use-file-explorer-context-menu.tsx` +- `windows/tauri/src/features/file-explorer/lib/file-context-menu-git-file-items.ts` diff --git a/.agents/notes/implemented/process/2026-09-13-ci-build-cache-and-artifact-strategy.md b/.agents/notes/implemented/process/2026-09-13-ci-build-cache-and-artifact-strategy.md index ebc1a2269..a42a26720 100644 --- a/.agents/notes/implemented/process/2026-09-13-ci-build-cache-and-artifact-strategy.md +++ b/.agents/notes/implemented/process/2026-09-13-ci-build-cache-and-artifact-strategy.md @@ -50,6 +50,16 @@ Git 性能测试增加等待时间。推送到 `main` 或手动运行时,再 组装通用 DMG。Windows PR 把前端验证与 Rust 测试放在两个独立 job 中并行 执行;Windows x64 NSIS 安装包只由 preview 和稳定版发布工作流生成。 +Git 图形的离屏帧采样保留完整 1,000 行历史,但每次只绘制 40 行的可见区域, +分别覆盖开头、中间和末尾。绘制前把该区域的坐标映射到小位图,绘制后检查 +实际有图形像素,避免中段被裁剪为空时得到虚假的快速结果。完整 1,000/5,000 +行图结构、顺序和签名仍由独立用例与 Release 验证器检查;单帧中位数和整个 +测试的时间上限保持不变。 + +正确做法:模拟滚动窗口的一帧,把可见区域作为绘制输入。不要把全部历史 +重复绘制 220 次当作单帧采样:这会把画面之外的工作混进测试总耗时,在共享 +runner 上可能出现断言都通过、计时工具仍因超过十秒而失败的情况。 + CI 缓存 Cargo fingerprints、build script outputs 和依赖 outputs,不缓存 最终可执行文件。缓存覆盖 `rust/target/macos` 的 Rust Core 和 `rust/target` 的数据库辅助 crate;缓存键必须包含运行器架构、编译器、Xcode/SDK/macOS @@ -69,6 +79,22 @@ PR 的测试合并提交必须在构建摘要中可追溯。被分类器选中 并发缓存主要缩短串行等待和反馈时间,不承诺减少总 runner 分钟;队列等待 和可用 runner 数量属于 CI 基础设施因素,不能与编译优化混为一谈。 +Windows 安装器失败时,Bun 可能已经退出,但并行的生命周期脚本仍在运行, +继续占用依赖目录;直接删除目录会让原本可以重试的下载故障变成文件锁错误。 +每次安装现在由独立 PowerShell worker 拥有:启动 Bun 前把 worker 加入 +Job Object(Windows 用于管理整棵子进程树的对象),设置最后一个句柄关闭时 +终止成员进程。句柄不继承给子进程,由 worker 的进程生命周期持有;worker +正常结束、失败或被取消时,系统释放句柄并终止残留脚本;每次安装还有默认 +300 秒的 worker 内部期限,超时退出码为 124,不能只依赖 CI 总超时。父安装器再清理 +部分缓存和依赖。文件系统释放锁可能稍晚,删除重试有单调计时的 10 秒期限, +超时明确失败,不无限等待。缓存清理后撤销旧 verified 标记,冷安装成功才 +重新生成完整性清单。只重试一次,不把永久安装错误隐藏成成功。 + +正确做法:worker 拥有 Bun 及其脚本,结束后再清理当前工作树生成目录; +不要按进程名结束所有 Node/Bun,因为用户其他工作树或应用可能正在使用它们。 +这些句柄和安装目录属于本次安装,不新增可复用资源;只有经过锁文件、Bun +版本与完整性清单校验的下载缓存可以跨工作树复制,安装包仍只读。 + ## 考虑过的备选方案 ### 在旧 runner 上用 Swiftly 安装独立编译器 @@ -88,6 +114,13 @@ PR 的测试合并提交必须在构建摘要中可追溯。被分类器选中 能减少网络等待,但无法覆盖 Rust Core 和数据库辅助 crate 的主要编译成本, 因此扩展为缓存 Cargo 的中间输出和 build script 结果。 +### 失败后仅限制安装并发或等待固定时间 + +不采用。相同 Bun 版本的本地生命周期探针表明,串行选项也不能保证失败后 +没有残留脚本;固定等待则无法证明进程结束。直接结束全部 Node 还会影响 +其他任务。Windows Job Object 提供系统级所有权和取消清理,代价是每次安装 +多启动一个 PowerShell worker,安装路径的回归测试需要在 Windows 上运行。 + ### 只构建一个 macOS 架构 可以降低 CI 成本,但无法发现另一架构上的编译、链接和打包问题。macOS 产品 @@ -110,11 +143,19 @@ Swift 测试已经编译完整 Lithe 目标。再生成两个 DMG 会在普通 任务结束。双架构验证因此保留给真正影响包内容和构建边界的改动;开发者需要 临时安装包时使用手动工作流。 +### 提高 Git 帧采样的总时间上限 + +这能容纳重复绘制完整历史的耗时,却保留了与可见窗口不符的工作量,也削弱 +其他测试的时间门禁。因此修正采样范围与位图坐标,继续使用原有上限;完整 +图的正确性检查不减少,批量绘制不能替代可见区域的帧成本测量。 + ## 后果 - 普通 macOS Swift PR 更快得到必需检查结果;被选中的打包改动仍获得两个架构 的真实安装物。 - Git 专项验证不会再延长无关 Swift 改动的反馈时间。 +- 帧采样只绘制可见区域,减少与屏幕无关的测试工作;它不衡量完整历史的一次 + 批量导出,也不等同于需要显式开启的真实窗口帧率采样。 - Windows 前端失败与 Rust 失败可以独立、并行反馈,不再等待 NSIS 安装包。 - 缓存命中时可减少 Rust 相关重复编译,同时通过完整缓存键避免跨环境误复用。 - artifact、校验和、合并提交与 gate 结果共同提供可追溯的测试交付物。 @@ -126,12 +167,14 @@ Swift 测试已经编译完整 Lithe 目标。再生成两个 DMG 会在普通 - `actionlint .github/workflows/ci-macos.yml .github/workflows/ci-windows.yml` - `./scripts/test-macos.sh` +- `./scripts/test-git-performance-baseline.sh` - `./scripts/build-macos.sh --configuration debug --triple arm64-apple-macosx` - `./scripts/build-macos.sh --configuration debug --triple x86_64-apple-macosx` - `./scripts/build-official-plugins.sh --configuration debug --triple arm64-apple-macosx` - `./scripts/build-official-plugins.sh --configuration debug --triple x86_64-apple-macosx` - `./scripts/verify-rust-core.sh` - `./scripts/verify-windows-boundaries.sh` +- `node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --working-directory . --max-ms 30000 --report .artifacts/test-stability/windows-dependency-install.json -- scripts/windows-frontend-install.test.ts`:Windows 无网络夹具用 IPC 确认子进程占用目录,再让安装失败,验证清理后冷重试成功、永久失败仍报错、成功退出也不留子进程、超时触发本地期限,且不清除有效缓存。 - `gh run download --repo 1lck/Lithe-IDEA --pattern 'Lithe-macos-*'` - `gh workflow run release-preview-windows.yml -f source_branch=` @@ -144,6 +187,9 @@ Swift 测试已经编译完整 Lithe 目标。再生成两个 DMG 会在普通 - `.github/workflows/ci-macos.yml` - `.github/workflows/ci-windows.yml` - `scripts/classify-ci-changes.sh` +- `scripts/install-windows-frontend-dependencies.ps1` +- `scripts/invoke-windows-bun-install.ps1` +- `scripts/windows-frontend-install.test.ts` - `scripts/test-classify-ci-changes.sh` - `scripts/build-macos.sh` - `scripts/build-official-plugins.sh` diff --git a/.agents/notes/implemented/process/2026-09-13-macos-update-signing-and-release-strategy.md b/.agents/notes/implemented/process/2026-09-13-macos-update-signing-and-release-strategy.md index 5a22774f9..ca07a388f 100644 --- a/.agents/notes/implemented/process/2026-09-13-macos-update-signing-and-release-strategy.md +++ b/.agents/notes/implemented/process/2026-09-13-macos-update-signing-and-release-strategy.md @@ -4,7 +4,7 @@ ## 先说结论 -更新包的真实性由 Sparkle 的 EdDSA 签名保证,Developer ID 签名是可选的分发能力,不是更新信任的唯一依据。stable 和 preview 完全隔离,回滚只接受内置公钥信任的更新,避免测试版本或伪造清单影响正式用户。 +更新包和独立官方插件包的真实性分别由 Sparkle EdDSA 与 Lithe Ed25519 签名保证,Developer ID 签名是可选的分发能力,不是这两类内容信任的唯一依据。stable 和 preview 完全隔离,回滚和插件安装只接受内置公钥信任的内容,避免测试版本或伪造清单影响正式用户。 ## 问题 @@ -31,6 +31,21 @@ runner keychain,用后即删。Developer ID 签名不等于公证,也不保 Gatekeeper 首次运行警告消失——这些是独立的发布关注点,更新器不自动 清除 quarantine。 +### 独立官方插件包 + +PHP Support 作为独立 GitHub Release asset 发布,不再把“能否使用 Developer ID +证书”当成是否生成插件包的条件。构建阶段先完成原生 bundle 的代码签名,再由 +`LithePluginPackageSigner` 使用 `LITHE_PLUGIN_PACKAGE_PRIVATE_KEY` 为包内每个文件 +生成 SHA-256 清单和 Ed25519 签名;签名文档本身不进入被签名文件列表,避免验证时 +出现自引用。Lithe 内置 `lithe-official-plugins-v1` 公钥,安装时先验证原生 bundle, +再验证完整包清单、插件 ID 和版本,任一文件被替换都会拒绝安装。 + +stable 和 preview 工作流都必须配置同一 repository secret +`LITHE_PLUGIN_PACKAGE_PRIVATE_KEY`。它是与客户端内置公钥匹配的 base64 编码 32 字节 +Ed25519 私钥;可用 `gh secret set LITHE_PLUGIN_PACKAGE_PRIVATE_KEY --repo 1lck/Lithe-IDEA < /secure/path/lithe-plugin-package-private-key.base64` 写入 GitHub,私钥只在构建 runner 的标准输入中使用,不能写入 +仓库、release asset 或日志。公钥轮换需要先发布能识别新 key ID 的客户端,再更新 +secret 并重新生成包,不能只替换 GitHub secret。 + ### 全量与差分双轨发布 stable 工作流继续发布 DMG + SHA-256 + `latest-macos.json` 供旧客户端 @@ -41,6 +56,20 @@ stable 工作流继续发布 DMG + SHA-256 + `latest-macos.json` 供旧客户端 feed,Sparkle 生成不出有效 patch 时也会省略该 delta 并回退到全量 归档。GitHub Release 的 zip 是差分基线的持久化来源,发布后必须保留。 +选中的基线下载失败不能当作“没有基线”跳过。`download-sparkle-baseline.mjs` +为每个归档最多尝试三次,每次 `gh` 下载限时 120 秒,重试前分别等待 2 秒和 +4 秒;只重试超时、暂时性网络错误和 HTTP 408/429/5xx,永久错误或耗尽重试 +都终止发布。每次尝试独占系统临时目录中 `archives/.baseline-/`, +只有下载成功且非空才移入同次发布的归档输入;失败或取消先结束所属进程, +再删除 staging(未完成下载的暂存目录),外层脚本退出时清理整个临时目录。 +这些资源绑定选定仓库、tag、渠道和架构,没有可靠的工作树复用身份 stamp, +任何阶段都不得跨工作树复制,也不写已安装 app。成功下载仍须通过原有 +Sparkle 处理和发布校验,不改变签名、哈希或 appcast 的接受条件。 + +稳定版 macOS build job 总期限为 75 分钟,打包步骤为 35 分钟,Sparkle +步骤为 30 分钟,为双架构打包和有界下载重试留出预算。Preview 工作流的 +期限不变;上述下载保护位于两个渠道共用的发布脚本内。 + ### Stable 与 Preview 完全隔离 Preview 使用独立的 `LitheUpdateChannel=preview`、独立的 @@ -94,6 +123,9 @@ bundle identifier、展示版本、可执行文件架构、渠道和最低系统 但发布 job 需要一个和 build job 一致的身份(build 号绑定 workflow run number/attempt),只重跑发布 job 会让身份和实际构建 内容不一致,因此要求重跑全部 job 才分配新身份。 +- **基线下载失败就发布不含该 delta 的全量 feed**:可提高表面成功率, + 但会把网络故障误判为基线不存在,悄悄减少可用差分更新;改用有界的 + 暂时性错误重试,永久失败仍阻止发布。 - **回滚客户端信任下载 manifest 里携带的公钥**:实现更灵活,manifest format 升级时不需要客户端预置新 key。但这等于让一次网络请求决定 信任锚点,一旦 manifest 分发被劫持就可以伪造回滚目标,因此回滚 @@ -102,11 +134,17 @@ bundle identifier、展示版本、可执行文件架构、渠道和最低系统 少点一次 Gatekeeper 提示。但这本质上是绕过 macOS 的下载来源追踪 机制,属于安全职责之外的东西,因此明确不做,交由既有的可信来源 恢复步骤处理。 +- **要求插件使用 Developer ID 或与宿主同 Team ID 签名**:能复用 Apple + 的代码签名身份,但会让没有证书的发布环境无法生成独立包,也无法表达“包由 + Lithe 发布者签发”的信任关系,因此改用包级 Ed25519 清单,同时保留 bundle + 的原生代码签名校验。 ## 后果 - 贡献者不需要 Apple Developer 账号就能产出可验证来源的差分更新, Developer ID 签名和公证仍可作为独立环节按需加入。 +- 暂时性基线下载故障可在本次运行恢复,代价是失败归档最多占用三次 + 下载期限和两次退避等待;持续故障仍需修复或重跑,不能用超长等待掩盖。 - Stable 和 Preview 用户互不干扰对方的更新节奏和偏好,代价是用户 从 Preview 切换回 Stable 需要一次显式操作,且可能需要重新设置 更新检查偏好。 @@ -116,6 +154,11 @@ bundle identifier、展示版本、可执行文件架构、渠道和最低系统 - 回滚路径的安全性依赖客户端内置公钥永不改变;如果需要轮换 `SPARKLE_PRIVATE_KEY`,替换 repository secret 本身不够,必须单独 规划迁移(重签或双签过渡期),否则旧客户端会拒绝新签名的更新。 +- 独立插件包的安装安全性同样依赖客户端内置的 publisher 公钥;没有匹配 secret + 的 release 构建会失败,普通 debug 构建会跳过需要 publisher 签名的 PHP 包, + 指定单个 PHP 包构建时也会失败。这样构建目录中不会留下缺少 + `lithe-plugin-signature.json` 的伪成功包;只有提供私钥的构建才会生成可交给 + 包级验签路径的产物。 - 需要重新评估的触发条件:如果差分更新的资产数量随架构或渠道增多 逼近 900 的清理阈值,或者需要支持两个以上的更新渠道,当前基于 "30 个 build + 3 个 zip 基线"的保留规则需要重新设计。 @@ -127,8 +170,11 @@ bundle identifier、展示版本、可执行文件架构、渠道和最低系统 ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh --max-seconds 60 -- --filter StableRollbackDiskImageIntegrationTests ./scripts/test-macos.sh ./scripts/verify-macos-package.sh +./scripts/verify-official-plugins.sh sparkle_tools=$(zsh scripts/prepare-sparkle-tools.sh) ruby scripts/test-sparkle-update.rb "$sparkle_tools" +node scripts/test-download-sparkle-baseline.mjs +node scripts/test-reuse-worktree-resources.mjs actionlint .github/workflows/release-macos.yml .github/workflows/release-preview-macos.yml .github/workflows/ci-macos.yml ``` @@ -145,9 +191,15 @@ Sparkle 版本间升级、缺失/损坏的 delta、下载中断、权限不足 ## 适用范围 - `scripts/prepare-sparkle-tools.sh` +- `scripts/create-sparkle-update.sh` +- `scripts/download-sparkle-baseline.mjs` +- `scripts/test-download-sparkle-baseline.mjs` - `scripts/verify-macos-package.sh` - `scripts/test-sparkle-update.rb` - `scripts/create-macos-update-manifest.rb` - `.github/workflows/release-macos.yml` - `.github/workflows/release-preview-macos.yml` +- `scripts/build-official-plugins.sh` +- `macos/Sources/LithePluginPackageSigning/` +- `macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift` - `docs/architecture/macos-updates.md` diff --git a/.agents/notes/implemented/process/2026-09-24-cross-platform-feature-parity-matrix.md b/.agents/notes/implemented/process/2026-09-24-cross-platform-feature-parity-matrix.md index c7237b1e5..b4c947a6e 100644 --- a/.agents/notes/implemented/process/2026-09-24-cross-platform-feature-parity-matrix.md +++ b/.agents/notes/implemented/process/2026-09-24-cross-platform-feature-parity-matrix.md @@ -4,7 +4,7 @@ ## 先说结论 -macOS 与 Windows 的功能对齐状态由 `shared/platform-feature-matrix.json` 维护,Markdown 表格只是生成结果,不再作为第二份事实源。每个功能都必须分别记录两端的实现状态和运行验证状态;代码入口存在但没有真实跨平台验证时,保留实现程度并将验证状态标为 `pending`。 +macOS 与 Windows 的功能对齐状态由 `shared/platform-feature-matrix/features/.json` 分文件维护,Markdown 表格只是生成结果,不再作为第二份事实源。每个功能都必须分别记录两端的实现状态和运行验证状态;代码入口存在但没有真实跨平台验证时,保留实现程度并将验证状态标为 `pending`。 ## 问题 @@ -14,18 +14,31 @@ macOS 是当前参考产品,Windows 是独立实现。两端可以共享 Rust 采用“源数据 + 生成视图 + 轻量校验”的流程: -- `shared/platform-feature-matrix.json` 是唯一源数据,使用稳定的功能 ID,并分别保存 `macos`、`windows` 的 `implementationStatus`、`verificationStatus` 和证据路径。状态的 label、icon、description 也由 JSON 提供;已知缺口或外部 Issue 写在可选的 `notes` 字段中。 -- `scripts/generate-platform-feature-matrix.mjs` 校验两套状态定义、ID 和证据路径,并生成 `docs/development/platform-parity-matrix.md` 和 `docs/development/platform-parity-matrix.csv`。 -- `scripts/verify-platform-feature-matrix.sh` 重新生成后检查生成文件没有漂移,适合作为本地和 PR gate。 +- `shared/platform-feature-matrix/features/.json` 是唯一源数据,使用稳定的功能 ID,并分别保存 `macos`、`windows` 的 `implementationStatus`、`verificationStatus` 和证据路径。状态的 label、icon、description 由 `shared/platform-feature-matrix/metadata.json` 提供;已知缺口或外部 Issue 写在可选的 `notes` 字段中。 +- `scripts/generate-platform-feature-matrix.mjs` 校验两套状态定义、ID 和证据路径,并在 `.artifacts/platform-feature-matrix/` 生成 HTML、Markdown、CSV 和汇总 JSON,生成物不提交到 Git。 +- `scripts/verify-platform-feature-matrix.sh` 只校验源数据及证据路径,适合作为本地和 PR gate。 - `docs/development/platform-parity.md` 说明矩阵的使用规则;仓库根目录 `AGENTS.md` 只负责把贡献者引到 `develop-lithe` Skill,避免规则正文出现第二份副本。功能 PR 必须更新源数据,不得直接手改生成视图。 -- `.github/workflows/verify-platform-feature-matrix.yml` 在每个 PR、`main` 推送和手动运行时执行校验,并上传 JSON、Markdown、CSV 视图,方便在线查看和下载。 +- `.github/workflows/verify-platform-feature-matrix.yml` 在每个 PR、`preview` / `main` 推送和手动运行时执行校验,并上传 JSON、Markdown、CSV 视图,方便在线查看和下载。 - `scripts/verify-platform-feature-matrix-change.sh` 检查平台实现路径变更是否同时更新矩阵;纯重构可通过 reviewer 添加 `matrix-exempt` label 显式豁免。 - 当前矩阵先按两端代码入口和共享契约完成初版静态盘点;实现状态不是实机验收结论,能力点的 `verificationStatus` 仍需后续跨平台验证推进。 实现状态分为 `implemented`、`partial`、`missing` 和 `platform-specific`,验证状态分为 `verified`、`pending` 和 `not-applicable`。矩阵行按“一个可单独验收的用户能力”拆分,`area` 和 `group` 只用于导航,不作为状态统计单位。只有 `implemented` 与 `verified` 同时成立才表示已经验收,避免把“有文件”误报成“跨平台可用”。 +同一能力使用稳定 ID 作为文件名,生成器按文件名排序;公共复核日期描述初版盘点,不要求功能 PR 更新。PR 门禁比较能力文件的 JSON 内容,忽略格式、键顺序及顶层 `lastReviewed`;修改其他字段是否满足需求仍由 reviewer 审查。正确做法是 PHP PR 只更新对应 PHP 能力文件;不要靠改公共日期满足门禁,也不要自动选择一边来掩盖同一能力的真实分歧。 + +线上矩阵由现有 Pages 工作流发布到看板站点的子目录,不能独立部署第二份站点覆盖首页。PR 只上传附件、不执行公开部署。生成器转义 HTML 中来自能力记录的文本,页面不加载脚本。生成物依赖当前 checkout,工作树资源清单明确排除复用,始终在目标工作树重新校验生成。 + ## 考虑过的备选方案 +### 总 JSON 加上提交生成文件 + +原方案让不同能力的 PR 共同修改全局数量和状态统计,制造无关冲突。按能力拆文件并将汇总移出 Git 后,不同能力可独立合并;同一能力发生真实分歧仍交给人工解决。 + +### 自动合并或机器人回写汇总 + +直接选一边可能丢掉状态和证据,机器人回写又会持续改变基线。因此 CI 只产生可下载视图,合并后统一发布网页,不回写源分支。 + + ### 只维护 Markdown 表格 阅读方便,但状态值、证据路径和表格内容都依赖人工维护,代码移动后很容易产生过期链接。因此 Markdown 只作为生成视图保留。 @@ -52,6 +65,8 @@ CSV 方便筛选,Excel 对非开发者更友好,但二进制 Excel 不利于 ## 后果 +迁移已有 PR 时,需要把相对原基线的条目改动移到新文件,不能恢复旧总表。在线页面展示 preview 最新发布状态,PR 附件有保留期限;历史版本可在对应 checkout 重新生成。 + 后续 macOS 功能不会只出现在 macOS 代码和发布说明里,Windows 的缺口会在同一个 PR 中显式出现。矩阵不会阻止平台差异,也不会把平台专属能力强行抽成共享实现;它只要求差异有名称、有证据、有负责人和验证动作。 ## 验证 diff --git a/.agents/skills/develop-lithe/SKILL.md b/.agents/skills/develop-lithe/SKILL.md index 2bc96eb8b..cd210995e 100644 --- a/.agents/skills/develop-lithe/SKILL.md +++ b/.agents/skills/develop-lithe/SKILL.md @@ -133,7 +133,8 @@ implementation and must not import Swift source or depend on macOS types. ## Keep the platform feature matrix current For every new user-visible capability or cross-platform behavior change, update -`shared/platform-feature-matrix.json` in the same pull request. Each row is one +the relevant `shared/platform-feature-matrix/features/.json` in the same pull +request. Each file is one independently verifiable user capability and must include both platforms' `implementationStatus`, `verificationStatus`, evidence paths, owner, and a concrete verification action. Platform-only capabilities still get a row with @@ -145,8 +146,12 @@ Code presence without runtime evidence keeps its implementation status and uses target platform permits `verified`. The status labels, icons, and descriptions in the JSON are the generator's only status source of truth. -Run `node scripts/generate-platform-feature-matrix.mjs` after changing the source -and commit both generated views under `docs/development/`. Run +Run `node scripts/generate-platform-feature-matrix.mjs` after changing the source. +Review the generated HTML/Markdown/CSV/JSON under `.artifacts/platform-feature-matrix/`; +do not commit generated views. Public views are deployed alongside the Agent Notes +board, and PR CI uploads revision-specific artifacts. Keep common status definitions +in `shared/platform-feature-matrix/metadata.json`; dates or metadata-only changes +do not satisfy the capability update gate. Run `./scripts/verify-platform-feature-matrix.sh` before handoff. Pull requests that change platform implementation paths are required by CI to update the JSON. A reviewer may add the `matrix-exempt` label only for a reviewed refactor with no @@ -172,8 +177,8 @@ When any platform implementation path listed above changed, run both checks: Use the exact pull-request base and head SHA instead of `origin/preview` and `HEAD` when those SHAs are available. If the change gate reports platform paths -without `shared/platform-feature-matrix.json`, stop and update the matrix source -and its generated Markdown/CSV views before proceeding. Do not use +without a substantive capability record change, stop and update the relevant +`shared/platform-feature-matrix/features/.json` before proceeding. Do not use `matrix-exempt` to bypass a user-visible behavior change. A user request to skip optional tests does not waive this contract gate when the work is being prepared for a pull request. diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml index 3bc3cf131..fb72bf510 100644 --- a/.github/workflows/ci-macos.yml +++ b/.github/workflows/ci-macos.yml @@ -36,6 +36,36 @@ jobs: shell: bash run: ./scripts/test-classify-ci-changes.sh + - name: Test Sparkle baseline download reliability + timeout-minutes: 2 + run: | + mkdir -p .artifacts/test-stability + node --test --test-reporter=spec --test-reporter-destination=stdout \ + --test-reporter=junit --test-reporter-destination=.artifacts/test-stability/sparkle-baseline.junit.xml \ + scripts/test-download-sparkle-baseline.mjs scripts/test-reuse-worktree-resources.mjs + + - name: Generate Sparkle download timing report + if: always() + run: | + node --input-type=module <<'NODE' + import fs from 'node:fs'; + import { parseJUnitCases } from './.agents/skills/write-stable-tests/scripts/parse-junit-cases.mjs'; + import { writeTestReportArtifacts } from './.agents/skills/write-stable-tests/scripts/generate-test-report.mjs'; + const stem = '.artifacts/test-stability/sparkle-baseline'; + const tests = parseJUnitCases(fs.readFileSync(`${stem}.junit.xml`, 'utf8')); + if (tests.length === 0) throw new Error('Missing Sparkle download test results'); + fs.writeFileSync(`${stem}.json`, JSON.stringify({ runner: 'node', warnMs: 1000, maxMs: 15000, tests })); + writeTestReportArtifacts(`${stem}.json`); + NODE + + - name: Upload Sparkle download timing report + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-stability-sparkle-baseline + path: .artifacts/test-stability/ + if-no-files-found: error + - name: Resolve comparison base id: base env: diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index 553b4d40f..1bf60ab21 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -123,6 +123,10 @@ jobs: shell: pwsh run: node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --working-directory frontend/editor --report .artifacts/test-stability/shared-editor.json + - name: Test Windows dependency install recovery + shell: pwsh + run: node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --working-directory . --max-ms 30000 --report .artifacts/test-stability/windows-dependency-install.json -- scripts/windows-frontend-install.test.ts + - name: Configure isolated dependency caches shell: pwsh run: | @@ -228,6 +232,10 @@ jobs: bun test src/features/file-explorer/lib/java-clipboard-class.test.ts src/features/file-explorer/lib/paste-java-class-from-clipboard.test.ts node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-file-watcher.json -- src/features/file-system/services/file-watcher-listener.test.ts src/features/file-system/stores/file-watcher.store.test.ts + - name: Test Git gutter changes and inline review lifecycle + shell: pwsh + run: node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-git-gutter.json -- src/features/git/utils/git-gutter-changes.test.ts src/features/editor/engines/monaco/git-gutter-peek-session.test.ts + - name: Test editor tab scrolling and lifecycle shell: pwsh run: node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-tabs.json -- src/features/tabs @@ -293,11 +301,15 @@ jobs: node $runner --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-git-log-layout.json -- src/features/git/hooks/use-git-log-column-resize.test.tsx src/features/git/components/log/git-log-date-cell.test.tsx src/features/git/components/log/git-commit-file-tree.test.ts src/features/git/utils/git-log-columns.test.ts src/features/git/utils/git-log-date.test.ts src/features/git/utils/multi-file-diff.test.ts src/utils/document-resize-session.test.ts src/features/layout/components/plugin-activity-rail.test.ts src/features/layout/components/workbench-panel-frame.test.ts src/features/layout/config/item-order.test.ts src/features/keymaps/defaults/default-keymaps.git-log.test.ts src/features/terminal/stores/terminal.store.test.ts if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Test Commit panel layout and history picker + shell: pwsh + run: node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-commit-panel.json -- src/features/git/hooks/use-git-commit-picker.test.tsx src/features/git/hooks/use-git-commit-area-resize.test.tsx src/features/git/components/git-commit-panel.test.tsx + - 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 + 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 file-context-menu-git-file-items.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 } diff --git a/.github/workflows/deploy-agent-notes-board.yml b/.github/workflows/deploy-agent-notes-board.yml index 040b358b1..fcb75a937 100644 --- a/.github/workflows/deploy-agent-notes-board.yml +++ b/.github/workflows/deploy-agent-notes-board.yml @@ -44,6 +44,9 @@ jobs: "Lithe 工程决策看板" touch "$RUNNER_TEMP/lithe-agent-notes-site/.nojekyll" + - name: Build platform feature matrix + run: node scripts/generate-platform-feature-matrix.mjs --output-dir "$RUNNER_TEMP/lithe-agent-notes-site/platform-feature-matrix" + - name: Configure GitHub Pages uses: actions/configure-pages@v5 diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml index 9d94a6558..387e6a4f6 100644 --- a/.github/workflows/release-macos.yml +++ b/.github/workflows/release-macos.yml @@ -68,7 +68,9 @@ jobs: name: Build ${{ matrix.architecture }} release needs: prepare runs-on: macos-26 - timeout-minutes: 30 + # Cold packaging took ~24 minutes in v0.5.11, leaving too little time for + # three baseline downloads and Sparkle delta generation under the old cap. + timeout-minutes: 75 strategy: fail-fast: false matrix: @@ -115,31 +117,30 @@ jobs: echo "Incomplete Developer ID configuration: provide the certificate or remove the optional identity and password." exit 1 else - echo "Developer ID is not configured; using ad-hoc app signing and Sparkle EdDSA update signatures." + echo "Developer ID is not configured; using ad-hoc app signing. Official plugin packages use Lithe Ed25519 signatures." fi - name: Build and package the App + timeout-minutes: 35 env: LITHE_SPARKLE_PUBLIC_KEY: ${{ vars.SPARKLE_PUBLIC_KEY }} + LITHE_PLUGIN_PACKAGE_PRIVATE_KEY: ${{ secrets.LITHE_PLUGIN_PACKAGE_PRIVATE_KEY }} LITHE_ARCH: ${{ matrix.architecture }} LITHE_VERSION: ${{ needs.prepare.outputs.version }} LITHE_BUILD_NUMBER: ${{ needs.prepare.outputs.build_number }} run: | test -n "$LITHE_SPARKLE_PUBLIC_KEY" || { echo "Configure SPARKLE_PUBLIC_KEY before releasing"; exit 1; } + test -n "$LITHE_PLUGIN_PACKAGE_PRIVATE_KEY" || { echo "Configure LITHE_PLUGIN_PACKAGE_PRIVATE_KEY before releasing"; exit 1; } ./scripts/package-app.sh - if [[ -n "${LITHE_CODESIGN_IDENTITY:-}" ]]; then - plugin_root=$(LITHE_CODESIGN_IDENTITY="$LITHE_CODESIGN_IDENTITY" \ - scripts/build-official-plugins.sh \ - --configuration release \ - --triple "${LITHE_ARCH}-apple-macosx" \ - --plugin-id dev.lithe.plugin.php-support \ - --output "dist/official-plugins/${LITHE_ARCH}") - ditto -c -k --sequesterRsrc --keepParent \ - "$plugin_root/dev.lithe.plugin.php-support" \ - "dist/Lithe-PHP-Support-${LITHE_VERSION}-${LITHE_ARCH}.zip" - else - echo "Developer ID is not configured; skipping externally installable PHP plugin archive." - fi + plugin_root=$(LITHE_CODESIGN_IDENTITY="${LITHE_CODESIGN_IDENTITY:--}" \ + scripts/build-official-plugins.sh \ + --configuration release \ + --triple "${LITHE_ARCH}-apple-macosx" \ + --plugin-id dev.lithe.plugin.php-support \ + --output "dist/official-plugins/${LITHE_ARCH}") + ditto -c -k --sequesterRsrc --keepParent \ + "$plugin_root/dev.lithe.plugin.php-support" \ + "dist/Lithe-PHP-Support-${LITHE_VERSION}-${LITHE_ARCH}.zip" ./scripts/create-dmg.sh dmg_name="Lithe-${LITHE_VERSION}-${LITHE_ARCH}.dmg" (cd dist && shasum -a 256 "$dmg_name" > "$dmg_name.sha256") @@ -168,15 +169,14 @@ jobs: "$app_path/Contents/Info.plist" done plugin_archive="dist/Lithe-PHP-Support-${LITHE_VERSION}-${LITHE_ARCH}.zip" - if [[ -e "$plugin_archive" ]]; then - test -s "$plugin_archive" - fi + test -s "$plugin_archive" lipo "$app_path/Contents/MacOS/Lithe" -verify_arch "$LITHE_ARCH" hdiutil imageinfo "$dmg_path" > /dev/null test -s "$dmg_path" (cd dist && shasum -a 256 -c "Lithe-${LITHE_VERSION}-${LITHE_ARCH}.dmg.sha256") - name: Generate signed Sparkle updates + timeout-minutes: 30 env: GH_TOKEN: ${{ github.token }} LITHE_ARCH: ${{ matrix.architecture }} @@ -197,8 +197,7 @@ jobs: if-no-files-found: error retention-days: 7 - - name: Upload optional PHP plugin archive - if: ${{ hashFiles(format('dist/Lithe-PHP-Support-{0}-{1}.zip', needs.prepare.outputs.version, matrix.architecture)) != '' }} + - name: Upload PHP plugin archive uses: actions/upload-artifact@v7 with: name: macos-php-plugin-${{ matrix.architecture }} @@ -288,9 +287,8 @@ jobs: for architecture in arm64 x86_64; do upload_args+=(dist/sparkle-"$architecture"/*) plugin_archive="dist/Lithe-PHP-Support-${LITHE_VERSION}-${architecture}.zip" - if [[ -e "$plugin_archive" ]]; then - upload_args+=("$plugin_archive") - fi + test -s "$plugin_archive" + upload_args+=("$plugin_archive") done create_args+=(--notes-file "$notes_file") diff --git a/.github/workflows/release-preview-macos.yml b/.github/workflows/release-preview-macos.yml index 3598a111b..ad4cd7117 100644 --- a/.github/workflows/release-preview-macos.yml +++ b/.github/workflows/release-preview-macos.yml @@ -106,7 +106,7 @@ jobs: echo "Incomplete Developer ID configuration: provide the certificate or remove the optional identity and password." exit 1 else - echo "Developer ID is not configured; the preview app will be ad-hoc signed and no externally installable PHP plugin archive will be published." + echo "Developer ID is not configured; the preview app will be ad-hoc signed. Official plugin packages use Lithe Ed25519 signatures." fi - name: Build and package the preview app @@ -114,6 +114,7 @@ jobs: LITHE_ARCH: ${{ matrix.architecture }} LITHE_VERSION: ${{ env.PREVIEW_VERSION }} LITHE_BUILD_NUMBER: ${{ needs.prepare.outputs.build }} + LITHE_PLUGIN_PACKAGE_PRIVATE_KEY: ${{ secrets.LITHE_PLUGIN_PACKAGE_PRIVATE_KEY }} LITHE_BUILD_TIMESTAMP: ${{ needs.prepare.outputs.timestamp }} LITHE_BUILD_GIT_BRANCH: ${{ github.event_name == 'workflow_dispatch' && inputs.source_branch || env.PREVIEW_BRANCH }} LITHE_UPDATE_CHANNEL: preview @@ -121,20 +122,17 @@ jobs: LITHE_SPARKLE_PUBLIC_KEY: ${{ vars.SPARKLE_PUBLIC_KEY }} run: | test -n "$LITHE_SPARKLE_PUBLIC_KEY" || { echo "Configure SPARKLE_PUBLIC_KEY before publishing preview updates"; exit 1; } + test -n "$LITHE_PLUGIN_PACKAGE_PRIVATE_KEY" || { echo "Configure LITHE_PLUGIN_PACKAGE_PRIVATE_KEY before publishing preview updates"; exit 1; } ./scripts/package-app.sh - if [[ -n "${LITHE_CODESIGN_IDENTITY:-}" ]]; then - plugin_root=$(LITHE_CODESIGN_IDENTITY="$LITHE_CODESIGN_IDENTITY" \ - scripts/build-official-plugins.sh \ - --configuration release \ - --triple "${LITHE_ARCH}-apple-macosx" \ - --plugin-id dev.lithe.plugin.php-support \ - --output "dist/official-plugins/${LITHE_ARCH}") - ditto -c -k --sequesterRsrc --keepParent \ - "$plugin_root/dev.lithe.plugin.php-support" \ - "dist/Lithe-PHP-Support-${LITHE_VERSION}-${LITHE_ARCH}.zip" - else - echo "Developer ID is not configured; skipping externally installable PHP plugin archive." - fi + plugin_root=$(LITHE_CODESIGN_IDENTITY="${LITHE_CODESIGN_IDENTITY:--}" \ + scripts/build-official-plugins.sh \ + --configuration release \ + --triple "${LITHE_ARCH}-apple-macosx" \ + --plugin-id dev.lithe.plugin.php-support \ + --output "dist/official-plugins/${LITHE_ARCH}") + ditto -c -k --sequesterRsrc --keepParent \ + "$plugin_root/dev.lithe.plugin.php-support" \ + "dist/Lithe-PHP-Support-${LITHE_VERSION}-${LITHE_ARCH}.zip" ./scripts/create-dmg.sh dmg_name="Lithe-${LITHE_VERSION}-${LITHE_ARCH}.dmg" (cd dist && shasum -a 256 "$dmg_name" > "$dmg_name.sha256") @@ -179,8 +177,7 @@ jobs: compression-level: 0 retention-days: 14 - - name: Upload optional PHP plugin archive - if: ${{ hashFiles(format('dist/Lithe-PHP-Support-{0}-{1}.zip', env.PREVIEW_VERSION, matrix.architecture)) != '' }} + - name: Upload PHP plugin archive uses: actions/upload-artifact@v7 with: name: macos-php-plugin-${{ matrix.architecture }} @@ -282,10 +279,9 @@ jobs: for architecture in arm64 x86_64; do plugin_archive="dist/Lithe-PHP-Support-${LITHE_VERSION}-${architecture}.zip" - if [[ -e "$plugin_archive" ]]; then - gh release upload "$RELEASE_TAG" "$plugin_archive" \ - --repo "$GITHUB_REPOSITORY" --clobber - fi + test -s "$plugin_archive" + gh release upload "$RELEASE_TAG" "$plugin_archive" \ + --repo "$GITHUB_REPOSITORY" --clobber done # Publish every archive before replacing either feed. Retention protects diff --git a/.github/workflows/verify-platform-feature-matrix.yml b/.github/workflows/verify-platform-feature-matrix.yml index ebb647e06..de29953a9 100644 --- a/.github/workflows/verify-platform-feature-matrix.yml +++ b/.github/workflows/verify-platform-feature-matrix.yml @@ -2,9 +2,11 @@ name: Verify Platform Feature Matrix on: pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] push: branches: - main + - preview workflow_dispatch: permissions: @@ -18,6 +20,7 @@ jobs: verify: name: Verify and publish matrix views runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Check out source uses: actions/checkout@v7 @@ -29,7 +32,10 @@ jobs: with: node-version: 20 - - name: Verify source data and generated views + - name: Test matrix tooling + run: node --test scripts/test-platform-feature-matrix.mjs + + - name: Verify source data run: ./scripts/verify-platform-feature-matrix.sh - name: Require matrix updates for platform changes @@ -40,30 +46,20 @@ jobs: MATRIX_UPDATE_EXEMPT: ${{ contains(github.event.pull_request.labels.*.name, 'matrix-exempt') }} run: ./scripts/verify-platform-feature-matrix-change.sh - - name: Add matrix links to job summary - env: - REPOSITORY: ${{ github.repository }} - REVISION: ${{ github.sha }} - SERVER_URL: ${{ github.server_url }} - shell: bash - run: | - { - echo "### Platform feature matrix" - echo "" - echo "The committed views are available from this revision:" - echo "- [Markdown view](${SERVER_URL}/${REPOSITORY}/blob/${REVISION}/docs/development/platform-parity-matrix.md)" - echo "- [CSV view / download](${SERVER_URL}/${REPOSITORY}/raw/${REVISION}/docs/development/platform-parity-matrix.csv)" - echo "" - echo "The same files are attached as a downloadable workflow artifact." - } >> "$GITHUB_STEP_SUMMARY" + - name: Generate matrix views + run: node scripts/generate-platform-feature-matrix.mjs - name: Upload matrix views + id: matrix uses: actions/upload-artifact@v4 with: name: platform-feature-matrix-${{ github.run_id }} - path: | - shared/platform-feature-matrix.json - docs/development/platform-parity-matrix.md - docs/development/platform-parity-matrix.csv + path: .artifacts/platform-feature-matrix/ if-no-files-found: error retention-days: 14 + + - name: Add matrix links to job summary + env: + ARTIFACT_URL: ${{ steps.matrix.outputs.artifact-url }} + run: | + echo "Download the matrix for this revision: [HTML, Markdown, CSV and JSON](${ARTIFACT_URL})" >> "$GITHUB_STEP_SUMMARY" diff --git a/Package.swift b/Package.swift index 234edc6c7..5f5fcd9cb 100644 --- a/Package.swift +++ b/Package.swift @@ -25,10 +25,12 @@ let package = Package( .library(name: "LitheWorkspaceModule", targets: ["LitheWorkspaceModule"]), .library(name: "LitheGoSupportModule", targets: ["LitheGoSupportModule"]), .library(name: "LithePhpSupportModule", targets: ["LithePhpSupportModule"]), + .library(name: "LithePluginPackageSigning", targets: ["LithePluginPackageSigning"]), .executable(name: "LitheCoreVerifier", targets: ["LitheCoreVerifier"]), .executable(name: "LitheGitGraphVerifier", targets: ["LitheGitGraphVerifier"]), .executable(name: "LitheGitPerformanceVerifier", targets: ["LitheGitPerformanceVerifier"]), - .executable(name: "LitheOfficialPluginVerifier", targets: ["LitheOfficialPluginVerifier"]) + .executable(name: "LitheOfficialPluginVerifier", targets: ["LitheOfficialPluginVerifier"]), + .executable(name: "LithePluginPackageSigner", targets: ["LithePluginPackageSigner"]) ], dependencies: [ .package(url: "https://github.com/sparkle-project/Sparkle.git", exact: "2.10.0"), @@ -89,6 +91,11 @@ let package = Package( .target(name: "LitheWorkspaceModule", dependencies: ["LitheModuleAPI", "LitheCoreContracts"], path: "macos/Sources/LitheWorkspaceModule", swiftSettings: [.swiftLanguageMode(.v6)]), .target(name: "LitheGoSupportModule", dependencies: ["LitheModuleAPI", "LitheCoreContracts"], path: "Plugins/mac/Official/GoSupport/Sources/LitheGoSupportModule", swiftSettings: [.swiftLanguageMode(.v6)]), .target(name: "LithePhpSupportModule", dependencies: ["LitheModuleAPI", "LitheCoreContracts"], path: "Plugins/mac/Official/PhpSupport/Sources/LithePhpSupportModule", swiftSettings: [.swiftLanguageMode(.v6)]), + .target( + name: "LithePluginPackageSigning", + path: "macos/Sources/LithePluginPackageSigning", + swiftSettings: [.swiftLanguageMode(.v6)] + ), .target( name: "LitheRustCore", path: "macos/Sources/LitheRustCore", @@ -111,6 +118,7 @@ let package = Package( "LitheDebugModule", "LitheLanguageIntelligenceModule", "LitheWorkspaceModule", + "LithePluginPackageSigning", "LitheRustCore", .product(name: "SwiftTerm", package: "SwiftTerm"), .product(name: "Sparkle", package: "Sparkle") @@ -131,7 +139,7 @@ let package = Package( ), .testTarget( name: "LitheTests", - dependencies: ["Lithe", "LitheModuleAPI", "LitheApplicationKernel", "LitheCoreContracts", "LitheGitModule", "LitheDatabaseModule", "LitheAIAssistanceModule", "LitheAgentConversationModule", "LitheLanguageIntelligenceModule", "LitheGoSupportModule", "LithePhpSupportModule", .product(name: "Testing", package: "swift-testing")], + dependencies: ["Lithe", "LitheModuleAPI", "LitheApplicationKernel", "LitheCoreContracts", "LitheGitModule", "LitheDatabaseModule", "LitheAIAssistanceModule", "LitheAgentConversationModule", "LitheLanguageIntelligenceModule", "LitheGoSupportModule", "LithePhpSupportModule", "LithePluginPackageSigning", .product(name: "Testing", package: "swift-testing")], path: "macos/Tests/LitheTests", resources: [ .copy("Fixtures") @@ -271,6 +279,12 @@ let package = Package( dependencies: ["LitheModuleAPI", "LitheApplicationKernel", "LitheCoreContracts"], path: "macos/Tests/LitheOfficialPluginVerifier", swiftSettings: [.swiftLanguageMode(.v6)] + ), + .executableTarget( + name: "LithePluginPackageSigner", + dependencies: ["LithePluginPackageSigning"], + path: "macos/Tools/LithePluginPackageSigner", + swiftSettings: [.swiftLanguageMode(.v6)] ) ] ) diff --git a/Plugins/mac/Official/PhpSupport/plugin.json b/Plugins/mac/Official/PhpSupport/plugin.json index 3aad8713a..d8527d201 100644 --- a/Plugins/mac/Official/PhpSupport/plugin.json +++ b/Plugins/mac/Official/PhpSupport/plugin.json @@ -11,7 +11,7 @@ "vendor": { "id": "dev.lithe", "displayName": "Lithe", - "signatureRequirement": "sameTeamAsHost" + "signatureRequirement": "publisherPackage" }, "entrypoint": { "kind": "nativeBundle", diff --git a/Plugins/mac/README.md b/Plugins/mac/README.md index 0fba03e24..fb1784132 100644 --- a/Plugins/mac/README.md +++ b/Plugins/mac/README.md @@ -11,6 +11,7 @@ building the host API for the same configuration and architecture: ```sh LITHE_CODESIGN_IDENTITY="" \ +LITHE_PLUGIN_PACKAGE_PRIVATE_KEY="$(cat /secure/path/lithe-plugin-package-private-key.base64)" \ scripts/build-official-plugins.sh --configuration release \ --triple arm64-apple-macosx --plugin-id dev.lithe.plugin.php-support ``` @@ -20,8 +21,12 @@ Use `x86_64-apple-macosx` for Intel. Keep the resulting users download, install, reinstall, and uninstall PHP Support from Plugin Management. After installation and restart, the LSP settings page only controls the current project's PHP language server. Native package verification still -requires the host's signing team. Debug/ad-hoc CI packages are for local testing -and are not production distribution artifacts. +requires the host's signing team. A PHP package also requires +`LITHE_PLUGIN_PACKAGE_PRIVATE_KEY`, a base64-encoded 32-byte Ed25519 private key +whose public key matches the embedded publisher key; debug builds skip PHP when +the key is absent, and a direct PHP package build fails instead of producing an +unsigned package. Keep the key in a protected file or environment variable and +never commit it. The PHP plugin archive carries a pinned Intelephense package described by `language-server.json`. Plugin Management downloads and verifies that tool as diff --git a/docs/assets/contact/wechat-group-qr-2026-09.png b/docs/assets/contact/wechat-group-qr-2026-09.png index 81dc3d270..ff9f71b51 100644 Binary files a/docs/assets/contact/wechat-group-qr-2026-09.png and b/docs/assets/contact/wechat-group-qr-2026-09.png differ diff --git a/docs/assets/screenshots/project-tree-multi-selection.png b/docs/assets/screenshots/project-tree-multi-selection.png new file mode 100644 index 000000000..9b8d77a77 Binary files /dev/null and b/docs/assets/screenshots/project-tree-multi-selection.png differ diff --git a/docs/ci-builds.md b/docs/ci-builds.md index 5eec402cb..f5a0d4232 100644 --- a/docs/ci-builds.md +++ b/docs/ci-builds.md @@ -97,7 +97,10 @@ change should be evaluated separately if queueing continues to dominate. 普通 `./scripts/test-macos.sh` 和 `./scripts/test-git-performance-baseline.sh` 默认跳过两个 WindowServer/display-link 真实窗口采样用例,继续运行 Git 图布局、 -离屏绘制和其他性能回归验证。真实窗口采样需要 macOS 14+ 和可用的桌面显示; +离屏绘制和其他性能回归验证。图形离屏帧采样保留完整 1,000 行历史,分别在 +开头、中间、末尾采样 40 行可见区域,并检查位图确实绘制了图形。完整图的 +结构和 Release 基线仍覆盖 1,000/5,000 行;单帧与测试总耗时上限保持不变。 +真实窗口采样需要 macOS 14+ 和可用的桌面显示; 只在专门测量滚动帧率时显式开启: ```bash @@ -120,6 +123,13 @@ Swift 单元、插件和数据库 CI 通道复用已有 Cargo 下载缓存;包 ### 独立工作树的本地编译 +功能矩阵生成物 `.artifacts/platform-feature-matrix/`(CI Pages 使用 +`/lithe-agent-notes-site/platform-feature-matrix/`)不允许跨工作树复用。 +它依赖当前 checkout 的能力记录、证据路径及提交信息,没有可靠的版本、平台、 +架构或工具链 identity stamp;任何复制阶段都应排除。资源清单的 +`excludedResources.platform-feature-matrix` 由复用脚本直接拒绝。目标工作树运行 +`node scripts/generate-platform-feature-matrix.mjs`,校验源数据后重新生成。 + IDE MCP helper 由 `scripts/build-ide-mcp.sh`(macOS)和 `scripts/build-windows-ide-mcp.mjs`(Windows Tauri 构建前)从当前源码与 `rust/Cargo.lock` 构建。`dist/ide-mcp/`、`rust/target/windows-ide-mcp/` 和 @@ -168,6 +178,13 @@ SHA-256;Cargo、SwiftPM 和 Bun 使用各自的 lockfile、版本与完整性 `.swift-version` 和 `.lithe-integrity.json` 校验。 - `.artifacts/bun-cache/`:Bun 下载缓存;按 `bun.lock`、Bun 版本和缓存完整性 清单校验。 + Windows 的每次依赖安装在独立 worker 中执行;worker 在启动 Bun 前加入 + Job Object(Windows 用于管理整棵子进程树的对象),退出时终止残留安装脚本。 + 每次安装默认有 300 秒本地期限,超时终止 worker 及其子进程; + 安装失败后先释放子进程,再清理部分依赖与缓存,并只进行一次冷安装重试; + 文件锁释放有 10 秒本地期限。`node_modules`、两个 workspace 的依赖目录和 + `.artifacts/bun-tmp` 是安装过程的可变状态,不跨 worktree 复制;进程句柄只在 + worker 内存中存活,不增加下载目录,不写发行资源,也不影响签名或增量更新。 - `.artifacts/jdtls-downloads/`:JDTLS、Lombok、Java Debug/Test 和 license。 - `.artifacts/jdk-downloads/`:各平台与架构的 bundled JDK 下载归档。 - `.artifacts/php-language-server-downloads/`:按 @@ -176,6 +193,16 @@ SHA-256;Cargo、SwiftPM 和 Bun 使用各自的 lockfile、版本与完整性 以下目录不应直接复制或跨工作树共享: +- Sparkle 差分基线的单次发布临时目录: + `//archives/.baseline-/` 保存每次 `gh` + 下载尝试,只有命令成功且文件非空才移入同次发布的 `archives/.zip`。 + 输入由 GitHub Release 中选定的仓库、tag、stable/preview 渠道和 macOS 架构决定; + 成功下载不是可复用的版本、平台、架构或工具链 identity stamp,也不替代后续 + Sparkle 归档、签名与 appcast 校验。失败或取消时先结束所属下载进程,再删除 + 该次 staging;发布脚本退出时删除整个系统临时目录,不写已安装 app。 + `excludedResources.sparkle-baseline-staging` 经排除路由直接拒绝,任何复制阶段 + 都不得跨工作树复用;每次发布仍从选定的 GitHub Release 下载基线。 + - Java 启动临时文件:`/lithe-run/launch--.argfile` 和同目录的 `.classpath.jar` 由平台启动 adapter 为单次执行独占创建,包含该次 执行的绝对类路径、工作目录、JDK 版本及编码语义,没有可复用的版本、平台、架构 @@ -203,7 +230,7 @@ SHA-256;Cargo、SwiftPM 和 Bun 使用各自的 lockfile、版本与完整性 identity stamp,任何复制阶段都禁止共享。资源清单 `jdt-maven-settings` 显式排除, 复用脚本直接拒绝该资源,不进入下载或生成物校验路由。 -PHP 插件包在 `.build///OfficialPlugins` 中独立构建,绑定宿主 API、Swift 工具链、架构和签名,通过 `LitheOfficialPluginVerifier` 验证;无可靠 identity stamp,不跨工作树复制。插件安装后的 Intelephense 位于 +PHP 插件包在 `.build///OfficialPlugins` 中独立构建,绑定宿主 API、Swift 工具链、架构和签名,通过 `LitheOfficialPluginVerifier` 验证;发布配置还要求 repository secret `LITHE_PLUGIN_PACKAGE_PRIVATE_KEY`,由 `LithePluginPackageSigner` 对完整包生成 `lithe-plugin-signature.json`,客户端按宿主内置官方插件策略和 publisher 公钥验证后才允许安装。该 secret 的值是与客户端内置公钥匹配的 base64 编码 32 字节 Ed25519 私钥,可用 `gh secret set LITHE_PLUGIN_PACKAGE_PRIVATE_KEY --repo 1lck/Lithe-IDEA < /secure/path/lithe-plugin-package-private-key.base64` 配置;私钥文件和 shell 历史都不得进入仓库或日志。没有该 key 时,普通 debug 全量构建会跳过 PHP 包,指定 PHP 包 ID 的构建会失败,不会留下缺少签名文档的目录。无可靠 identity stamp,不跨工作树复制。插件安装后的 Intelephense 位于 `/Lithe/Plugins//versions//PhpSupport.bundle/Contents/Resources/LanguageServers/php`,由插件版本目录拥有,重装、回滚和卸载随插件一起处理,不是工作树构建缓存。PHPUnit 测试夹具的 `shared/fixtures/phpunit-project/vendor` 也由当前工作树独立安装。以上项目在资源清单 `excludedResources` 中明确排除,复用脚本会拒绝显式复制请求。 如果后续新增可复用资源,必须同步更新注册表、校验器、脚本测试和本节说明。 diff --git a/docs/development/document-encoding.md b/docs/development/document-encoding.md index 56178128f..51eaf6493 100644 --- a/docs/development/document-encoding.md +++ b/docs/development/document-encoding.md @@ -31,7 +31,7 @@ Lithe 把“如何解码当前编辑器文本”和“下一次写入磁盘使 3. 在 Windows `DocumentEncoding::codec` 和 `parse` 中添加 `encoding_rs` 映射及别名。 4. 按能力过滤读取和保存菜单;只读或只写编码不能出现在不支持的操作中。 5. 增加原始字节 fixture(见 `shared/fixtures/documents/encoding-explicit-reopen-v1.json`),覆盖显式错码读取、保存后字节、BOM 和不可表示字符错误。 -6. 增加 macOS、Windows 的目录一致性测试,并更新 `docs/development/platform-parity-matrix.md` 的验证场景。 +6. 增加 macOS、Windows 的目录一致性测试,并更新 `shared/platform-feature-matrix/features/` 中对应能力的验证场景。 7. 如果编码会进入工作区会话,保持新旧字段的解码兼容;不要把读写状态重新合并成一个字段。 平台适配器不得把编码转换复制到 SwiftUI、React 或 Monaco 层。编辑器只处理 Unicode 文本和编码目录描述,原生适配器负责字节边界、大小限制、身份指纹和原子写入。自动识别仍然严格拒绝无法验证的字节,避免把 GBK 误判成 UTF-8;文件监听先比较原始字节指纹,再使用当前读取编码解码;用户主动重新打开必须执行显式解码,并允许替换字符表达选错编码的结果。 diff --git a/docs/development/platform-parity-matrix.csv b/docs/development/platform-parity-matrix.csv deleted file mode 100644 index 1a5bd70e4..000000000 --- a/docs/development/platform-parity-matrix.csv +++ /dev/null @@ -1,118 +0,0 @@ -id,area,feature group,capability,macOS implementation,macOS verification,Windows implementation,Windows verification,owner,verification,notes,macOS evidence,Windows evidence -ide-api-run-output,AI,IDE MCP,插件与 MCP 列举运行配置、启动重启,按执行 ID 读取增量输出和停止,已实现,待验证,已实现,待验证,Agent,运行服务、读增量输出、清空日志验证 reset、重启后拒绝旧 ID,停止正确进程,检查退出码及资源清理。 运行前后对比安装目录文件清单与哈希;当前 Linux 仅执行共享协议及前端验证,原生运行待验证。,,macos/Sources/Lithe/Application/Features/IdeCapabilitiesFeatureModel.swift; macos/Sources/Lithe/Views/App/McpSettingsView.swift; macos/Tests/LitheTests/IdeCapabilitiesTests.swift; rust/lithe-ide-host/src/lib.rs,windows/tauri/src/features/host-api/ide-capabilities.ts; windows/tauri/src/features/host-api/mcp-settings.tsx; windows/tauri/src/features/host-api/ide-capabilities.test.ts; rust/lithe-ide-host/src/lib.rs -ide-api-maven,AI,IDE MCP,插件与 MCP 读取 Maven 模块和 Profile,保存配置、重载并执行既有生命周期和插件目标,已实现,待验证,已实现,待验证,Agent,双端对同一示例项目设置 Profile/skipTests,重载、执行 clean verify,核对按钮与 MCP 的命令、输出、失败和取消。 运行前后对比安装目录文件清单与哈希;当前 Linux 仅执行共享协议及前端验证,原生运行待验证。,,macos/Sources/Lithe/Application/Features/IdeCapabilitiesFeatureModel.swift; macos/Sources/Lithe/Views/App/McpSettingsView.swift; macos/Tests/LitheTests/IdeCapabilitiesTests.swift; rust/lithe-ide-host/src/lib.rs,windows/tauri/src/features/host-api/ide-capabilities.ts; windows/tauri/src/features/host-api/mcp-settings.tsx; windows/tauri/src/features/host-api/ide-capabilities.test.ts; rust/lithe-ide-host/src/lib.rs -ide-api-environment,AI,IDE MCP,插件与 MCP 查询和修改项目 Java/Maven 默认环境,并返回实际选择结果,已实现,待验证,已实现,待验证,Agent,读取环境、修改一个默认值,检查其他字段保留;对照设置页与实际启动路径,验证保存失败及项目切换。 运行前后对比安装目录文件清单与哈希;当前 Linux 仅执行共享协议及前端验证,原生运行待验证。,,macos/Sources/Lithe/Application/Features/IdeCapabilitiesFeatureModel.swift; macos/Sources/Lithe/Views/App/McpSettingsView.swift; macos/Tests/LitheTests/IdeCapabilitiesTests.swift; rust/lithe-ide-host/src/lib.rs,windows/tauri/src/features/host-api/ide-capabilities.ts; windows/tauri/src/features/host-api/mcp-settings.tsx; windows/tauri/src/features/host-api/ide-capabilities.test.ts; rust/lithe-ide-host/src/lib.rs -ide-mcp-connection,AI,IDE MCP,从设置侧栏独立的 MCP 配置页按项目开启连接,分别授权配置与执行,复制 stdio 客户端配置;关闭项目撤销访问,已实现,待验证,已实现,待验证,Agent,检查设置侧栏 MCP 配置入口与搜索跳转;未打开项目时显示提示,切换项目后展示对应授权。双端打包启动后开启连接,配置真实 Agent,验证工具发现、默认拒绝写入、启用权限、禁用、项目关闭和多窗口隔离。 运行前后对比安装目录文件清单与哈希;当前 Linux 仅执行共享协议及前端验证,原生运行待验证。,,macos/Sources/Lithe/Application/Features/IdeCapabilitiesFeatureModel.swift; macos/Sources/Lithe/Views/App/McpSettingsView.swift; macos/Tests/LitheTests/IdeCapabilitiesTests.swift; rust/lithe-ide-host/src/lib.rs; macos/Sources/Lithe/Views/App/SettingsView.swift,windows/tauri/src/features/host-api/ide-capabilities.ts; windows/tauri/src/features/host-api/mcp-settings.tsx; windows/tauri/src/features/host-api/ide-capabilities.test.ts; rust/lithe-ide-host/src/lib.rs; windows/tauri/src/features/settings/components/mcp-configuration-settings.tsx -agent-entry-discoverability,AI,Agent 对话,新安装及模块关闭时仍显示 Agent 侧栏入口,面板立即提示关闭或未配置原因并提供设置入口,启停后入口不重复或消失,已实现,待验证,未实现,不适用,Agent,使用无本机模块配置的 macOS 测试账户启动并打开项目,确认 Agent 入口可见、默认开关关闭、面板显示设置指引;开启再关闭后入口和面板保留且不重复。确认读取入口不调用模块 factory、关闭状态无 Agent 连接或进程。Windows 尚无 Agent 对话 UI。 Agent 侧栏入口使用对话气泡加星光的专用线性图标;在深浅主题、选中与未选中状态下检查可辨识性。,,macos/Sources/Lithe/Application/Features/AgentConversationEntryPolicy.swift; macos/Sources/Lithe/Views/Agent/AgentConversationView.swift; macos/Tests/LitheTests/AgentConversationEntryPolicyTests.swift; macos/Resources/IDEAIcons/toolwindows/toolWindowAgent.svg; macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift,windows/tauri/src/features -agent-conversation-history,AI,Agent 对话,CC GUI 风格历史页:标题与会话 ID 搜索、更新时间、复制 ID、刷新与恢复、收藏与本地重命名、单条与批量移除确认及恢复、筛选、多选批量管理及 Markdown 导出,已实现,待验证,未实现,不适用,Agent,macOS:在深浅主题与窄宽面板查看历史页,验证标题/ID 搜索、空结果、返回保留草稿、刷新失败不丢列表、打开历史恢复会话;验证收藏/重命名重启后保留且不同项目/Agent 隔离;筛选和全选只操作可见项,单条与批量移除先确认,取消不修改记录,确认只移除提示时选中的会话,移除后可恢复且不改上游文件;单个与批量 Markdown 导出等待历史回放完成、保持原选中标签,取消/失败不写部分记录;部分回放后失败再导出必须重新加载并等待完成,断连后的未完整回放不可导出,完整快照断连后仍可导出;关闭项目释放导出等待。消息数量只显示已加载的用户与 Agent 消息,未知总数不伪造。Windows 页面待实现。,,macos/Sources/Lithe/Views/Agent/AgentHistoryView.swift; macos/Sources/LitheAgentConversationModule/Application/AgentHistoryFeatureModel.swift; macos/Sources/Lithe/Platform/MacOS/Agent/MacAgentHistoryAdapters.swift; macos/Tests/LitheTests/AgentHistoryTests.swift,rust/lithe-agent-host/src/lib.rs -agent-context-usage,AI,Agent 对话,Agent 上下文用量:输入框圆环百分比、悬停已用/容量 token,未使用时零占位、会话隔离与压缩后更新,已实现,待验证,部分实现,待验证,Agent,macOS:真实 Agent 对话后检查输入框顶部圆环百分比;分别悬停圆环与百分比,立即在上方显示已用/容量 token,移开或切换面板后关闭浮层,窄面板浮层不越界;首次、Agent 未上报或已上报零使用量时显示 0%,悬停仅显示“上下文: 0.0%”,内部缺失状态不伪造 token 或容量;切换会话/Agent、新建会话不串用量,压缩后用量可降低,不累加计费用量;模型确认切换和断连后清除旧容量,重新加载或上报后更新;窄宽面板及深浅主题检查布局和辅助功能。Windows 共享 host 可转发 usage_update,页面待实现。,,macos/Sources/LitheAgentConversationModule/Application/AgentContextUsage.swift; macos/Sources/Lithe/Views/Agent/AgentContextUsageView.swift; macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift; macos/Tests/LitheTests/AgentConversationPresentationTests.swift,rust/lithe-agent-host/src/lib.rs; shared/fixtures/agent/acp-events-v1.json -agent-acp-conversation,AI,Agent 对话,可选 ACP Agent 对话:API Key 网关登录、项目会话与历史恢复、模型/权限/思考配置、不依赖凭据的本机默认模型同步、旧默认模型按上游推荐修复与品牌模型图标、CC GUI 风格可搜索模型与权限弹出面板、工具详情与权限、停止确认和进程清理,已实现,待验证,部分实现,待验证,Agent,macOS + Codex + API Key:验证发消息前的上游配置选项和确认;无自定义服务商或 API Key 时确认仍显示本机顶层默认模型、保留密钥缺失错误;修改本机默认模型后重连,连续新建空会话确认不再使用旧导入模型;配置不在上游目录中的旧模型时确认新会话使用目录内的上游推荐值、拒绝与超时不显示假成功且 Codex 模型菜单显示 OpenAI 标志;验证工具输入/输出/文件/变更、权限拒绝、停止确认及超时重连、历史加载失败保留记录;临时项目跑通读文件→修改→执行测试→追问,复用文档观察和脏缓冲区保存保护。检查模型搜索(名称/ID/分组、空结果)、品牌图标/选中状态、无模型说明、权限说明、思考/速度子菜单、确认/失败与会话切换关闭弹出面板;检查项目切换、退出清理,以及 CC GUI 风格空态、搜索、标签、可调整输入区和配置菜单在窄宽面板及深浅主题下的布局。Windows 与 Claude 端到端仍待验证。 安装包环境下打开 Agent 面板,确认 Codex/Claude 图标从 Contents/Resources 加载且不依赖构建目录;资源包或图标缺失时显示默认图标而不闪退,读取前后包内文件清单和内容不变。,,macos/Sources/LitheAgentConversationModule; macos/Sources/Lithe/Views/Agent; rust/lithe-agent-host; macos/Tests/LitheTests/AgentBrandIconResourceTests.swift,rust/lithe-agent-host -agent-tool-activity-timeline,AI,Agent 对话,连续 Agent 工具调用(执行命令、列文件、读文件与编辑等)合并为默认折叠的时间线卡片,汇总总数与失败/中断/完成进度,并逐条展开输入、输出与文件位置,已实现,待验证,未实现,不适用,Agent,macOS:让真实 Agent 连续列文件、读取文件、执行命令和编辑,确认相邻工具调用只显示一张默认折叠的时间线卡片;展开后每条状态、完整输入、输出和文件位置仍可查看,进行中、全部完成、失败和中断时汇总正确。用 Agent 文字说明和新一轮用户消息隔开工具调用,确认保留文字顺序且不会跨边界合并;搜索工具输入、输出或文件路径时自动展开匹配组;覆盖文件路径仅由 diff 内容上报、未出现在工具标题、输入输出或 locations 中的情况,历史恢复结果与实时流一致。在窄宽面板及深浅主题检查长标题截断、悬停全文、列表滚动和详情可读性。Windows Agent 对话 UI 尚未实现。,,macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift; macos/Sources/Lithe/Views/Agent/AgentToolGroupView.swift; macos/Tests/LitheTests/AgentConversationPresentationTests.swift,windows/tauri/src/features -agent-turn-statistics,AI,Agent 对话,发送后实时计时、每轮结束固定耗时、Agent 上报的输入/输出 token 与缓存/推理用量详情,已实现,待验证,部分实现,待验证,Agent,macOS:在真实 Agent 会话中确认发送后计时包含建会话、加载历史、工具和权限等待;正常完成、取消确认、请求失败和断连后停止计时,下一轮仍可看到上一轮统计。并发会话切换验证耗时与用量隔离;上报 usage 时显示准确输入/输出并悬停查看总量、缓存和推理值,缺失时只显示耗时。历史回放不编造统计,上下文占用与订阅额度不作为输入/输出 token。检查深浅主题和窄宽面板。Windows 共享 Host 已透传可选 usage,对话 UI 尚未接入。,,macos/Sources/Lithe/Views/Agent/AgentTurnStatisticsView.swift; macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift; macos/Sources/LitheAgentConversationModule/Application/AgentTurnStatistics.swift; macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift; macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift; macos/Tests/LitheTests/AgentConversationPresentationTests.swift,rust/lithe-agent-host/src/prompt.rs; rust/lithe-agent-host/src/tests.rs; shared/fixtures/agent/acp-events-v1.json -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; windows/tauri/src/features/tabs/components/tab-context-menu.tsx; windows/tauri/src/features/tabs/components/tab-context-menu.test.ts -editor-pointer-selection,编辑器,文本编辑,鼠标与触控板点击、选词和拖选,已实现,待验证,已实现,待验证,Editor,macOS 分别启用微信输入法和系统 ABC,快速轻点不同位置后松手移动,确认不扩展选区、不移动文字;验证同位置双击选词、Shift 点击扩选、按住拖选、选中文字拖放、主编辑器和分屏及 diff。运行 scripts/probe-macos-monaco.sh --workbench-tests;Windows 验证同样的正常鼠标交互。,macOS 在宿主修正 WebKit 零按钮按下事件,保留 Monaco 选择语义。Linux Chromium 真实 Monaco 控制事件回放通过,仍待 macOS 微信输入法与 Windows WebView2 实机验证。,macos/EditorFrontend/main.ts; macos/EditorFrontend/mouse-input.ts; macos/Experiments/Monaco/mouse-input.integration.ts,windows/tauri/src/features/editor/components/monaco-editor.tsx -editor-language-basics,编辑器,文本编辑,语法高亮与编辑器模型,已实现,待验证,已实现,待验证,Editor,分别打开 Java、Markdown 和普通文本,确认语言识别、语法高亮和模型切换;开启缩略图并滚动,确认滚动条轨道不透出编辑器代码。,,macos/Sources/Lithe/Views/Editor; macos/Sources/Lithe/Models/Editor,windows/tauri/src/features/editor; frontend/editor; windows/tauri/src/features/editor/engines/monaco/theme.ts -editor-macos-ime-presentation,编辑器,文本编辑,macOS 中文组合输入层遮住原文字,输入和删除候选文字时不透出整行重影,已实现,待验证,平台专属,不适用,Editor,运行 scripts/probe-macos-monaco.sh --workbench-tests。在 macOS 使用系统拼音与微信输入法,在含中文和英文的同一行中间输入、删减候选拼音、确认上屏,再按删除键;检查整行无重影、候选与光标正常、撤销恢复文本。覆盖明暗主题、背景图开关和分屏,结束输入后背景恢复。,修正 macOS 原生主题半透明当前行底色作为 Monaco 输入法覆盖层底色时的字形透出。Linux Chromium 的真实 Monaco 组合事件样式回归通过;macOS 原生输入法及删除键实机验证待完成。Windows 不加载此 macOS 样式。,macos/EditorFrontend/ime-input.css; macos/EditorFrontend/main.ts; macos/Experiments/Monaco/ime-input.integration.ts,windows/tauri/src/features/editor/components/monaco-editor.tsx -editor-wallpaper-overview-ruler,编辑器,编辑器外观,macOS 工作台壁纸模式下,编辑器概览标尺与代码缩略图随编辑器背景透明,不显示黑色竖条;普通主题仍保持实体背景,已实现,待验证,平台专属,不适用,Editor,在 macOS 选择工作台背景并打开文本文件,分别关闭和打开右侧 Agent 面板,确认编辑器最右侧概览标尺不出现黑色竖条;关闭背景图后确认普通主题底色保持不变。运行真实 WebKit 工作台主题集成测试。Windows 不使用 macOS 原生工作台壁纸。,,frontend/editor/src/theme.ts; macos/Sources/Lithe/Platform/MacOS/MonacoWorkbenchEditor.swift; macos/Experiments/Monaco/workbench.integration.ts,windows/tauri/src/features/editor/engines/monaco/theme.ts -editor-semantic-highlighting,编辑器,文本编辑,LSP 语义高亮,已实现,待验证,已实现,待验证,Editor / Language Tooling,打开普通 Java 文件并等待 JDTLS 就绪,开启语义高亮,确认字段、方法等按协商图例上色;编辑、切换、关闭文档和重连服务器后不应用旧结果;服务器 refresh 后重新请求;关闭设置或大文件降级时保留基础语法高亮。,Windows 复用 Core semanticTokens 和共享 fixture,修复 #675;Linux 上的适配器与模型测试不替代 Windows WebView2/JDTLS 实机验收。,macos/Sources/Lithe/Models/AppModel/AppModel+LanguageEditing.swift; macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift; frontend/editor/src/semantic-tokens.ts,windows/tauri/src/platform/lsp-core-adapter.ts; windows/tauri/src/features/editor/lsp/lsp-client.ts; windows/tauri/src/features/editor/engines/monaco/semantic-token-provider.ts; windows/tauri/src/features/editor/engines/monaco/semantic-token-provider.test.ts; windows/tauri/src/platform/lsp-core-adapter.test.ts -editor-multiline-tabs,编辑器,文本编辑,多行标签与标签导航,已实现,待验证,已实现,待验证,Editor,打开足够多文件触发多行标签,确认滚动、切换、关闭和活动文件保持。,,macos/Sources/Lithe/Views/Editor,windows/tauri/src/features/tabs -search-quick-open,搜索,搜索与导航,快速打开文件与符号,已实现,待验证,已实现,待验证,Search,使用文件名、路径和符号查询,确认排序、键盘导航和打开位置。,,macos/Sources/Lithe/Views/Search; macos/Sources/Lithe/Views/Workbench,windows/tauri/src/features/quick-open -search-project,搜索,搜索与导航,项目范围文本搜索,已实现,待验证,已实现,待验证,Search,使用同一项目和查询 fixture 对比命中路径、行号、列号和结果排序。,,macos/Sources/Lithe/Views/Search; macos/Sources/Lithe/Services/Language,windows/tauri/src/features/file-search -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 状态中时关闭;在 Windows 单文件工作区 Diff 中点击折叠条展开省略的未改动代码,确认 Diff 保持打开且每个修改块仍可单独暂存;在 Windows 未暂存单文件 Diff 中点击某个修改块的“回滚”,确认弹出确认框、取消不改文件、确认后只恢复该块且暂存区不变,最后一块回滚后文件从状态中消失时 Diff 关闭;暂存区、历史、分支比较与新增/未跟踪文件不显示回滚按钮。,,macos/Sources/Lithe/Views/Git; macos/Sources/Lithe/Views/Diff,windows/tauri/src/features/git; windows/tauri/src/features/viewer -git-history,版本控制,Git,提交历史与图谱,已实现,待验证,已实现,待验证,Git,分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;在 Windows Git Log 选中带多行正文的提交,确认右侧详情在标题下方完整显示正文(保留空行与缩进),复制提交信息和打开提交 Diff 时也包含正文;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端保留各自调色板,不要求 RGB 值一致。 读取提交正文期间切换仓库、重新打开另一提交或关闭面板,确认迟到结果不会打开旧 Diff 或覆盖新请求的加载状态。 Windows 另验证:单击提交后编辑区预览该提交第一个文件的 diff;单击或双击 Commit files 中的文件只显示该文件的 diff(不含提交说明,也不显示文件列表),并复用同一个预览标签;Git Log 刚打开、尚未点击提交时不会自动打开 diff;日期列对与本机同一时区的提交显示相对时间(刚刚、N 分钟前、今天/昨天 + 12 小时制,中文为上午/下午),其他时区或未知时区的提交只显示日期,鼠标移入行时刷新相对时间;作者/日期列可在行内列边缘拖动调整宽度并在重启后保留。 非连续多选提交修改同一路径时,预览保留每个提交的 diff 区段及提交标识;延迟请求返回前取消选择、隐藏 Git Log、切换仓库或卸载工具窗,确认旧请求不会重新打开或激活预览标签。,,macos/Sources/Lithe/Views/Git; macos/Sources/LitheGitModule/Services/GitGraphLayoutService.swift; macos/Sources/LitheGitModule/Services/GitGraphHeadOrdering.swift; macos/Sources/Lithe/Views/Git/GitGraphColor.swift,windows/tauri/src/features/git; windows/tauri/src/features/git/utils/git-graph-layout.ts; windows/tauri/src/features/git/utils/git-graph-colors.ts; windows/tauri/src/features/git/utils/git-graph-layout.test.ts; windows/tauri/src/features/git/components/log/git-commit-details.tsx; windows/tauri/src/features/git/components/log/git-commit-details.test.tsx; windows/tauri/src/features/git/hooks/use-git-diff-actions.test.tsx; windows/tauri/src/features/git/hooks/use-commit-file-preview.ts; windows/tauri/src/features/git/hooks/use-commit-file-preview.test.tsx -git-rebase-stash,版本控制,Git,Rebase 与 Stash,已实现,待验证,已实现,待验证,Git,执行交互式 Rebase 和 Stash 保存/恢复,确认中断、冲突和继续操作。,,macos/Sources/Lithe/Views/Git,windows/tauri/src/features/git -git-worktrees,版本控制,Git,Worktree 管理,已实现,待验证,已实现,待验证,Git,列出、创建、切换和删除 Worktree,确认路径、分支和安全检查。,,macos/Sources/Lithe/Views/Git,windows/tauri/src/features/git -git-multi-repository-references,版本控制,Git,多仓库引用面板:分组、配色与引用操作,已实现,待验证,已实现,待验证,Git,在多仓库工作区打开 Git Log:确认按仓库分组、仓库配色、非活动仓库分组只读,点其它仓库的引用会切换活动仓库且只加载一次,Pull 弹窗可选择远程分支与策略。,,macos/Sources/Lithe/Views/Git,windows/tauri/src/features/git -github-prs,协作,GitHub,Pull Request 列表与详情,已实现,待验证,已实现,待验证,GitHub,使用测试仓库验证 PR 列表、筛选、详情、分支比较和浏览器跳转。,,macos/Sources/Lithe/Views/GitHub; macos/Sources/Lithe/Services/GitHub,windows/tauri/src/features/github; shared/contracts/application-boundary.md -github-reviews-comments,协作,GitHub,Review 与评论,已实现,待验证,已实现,待验证,GitHub,创建、查看和回复 Review/评论,确认权限错误和网络失败不会丢失草稿。,,macos/Sources/Lithe/Views/GitHub,windows/tauri/src/features/github -ai-commit-generation,AI,AI 提交信息,Provider 配置与提交信息生成,已实现,待验证,已实现,待验证,Git / AI,用相同 diff、规则和 Provider 配置比较请求计划、取消、错误和生成文本。,,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 -ai-chat-session,AI,AI 对话,对话会话与流式响应,未实现,不适用,已实现,待验证,AI,Windows 验证新建会话、流式输出、取消和失败恢复;macOS 需要先定义产品范围。,,macos/Sources/Lithe/Platform/MacOS/AI,windows/tauri/src/features/ai/components/chat/ai-chat.tsx; windows/tauri/src/features/ai/services/ai-chat-service.ts -ai-chat-history,AI,AI 对话,对话历史、置顶与归档,未实现,不适用,已实现,待验证,AI,Windows 验证历史加载、重命名、置顶、归档、删除和重启后持久化。,,macos/Sources/Lithe/Platform/MacOS/AI,windows/tauri/src/features/ai/services/ai-chat-history-service.ts; windows/tauri/src/features/layout/components/sidebar/sidebar-history.tsx -ai-chat-providers,AI,AI 对话,多 Provider 与工作区范围,未实现,不适用,已实现,待验证,AI,Windows 验证 Provider 切换、凭据边界、工作区范围和模型错误提示。,,macos/Sources/Lithe/Platform/MacOS/AI,windows/tauri/src/features/ai/services/providers; windows/tauri/src/features/ai/lib/ai-workspace-scope.ts -java-runtime-discovery,Java,Java/Maven,JDK 与 Maven 工具链发现及按项目要求自动选择 JDK,已实现,待验证,已实现,待验证,Java / Maven,两端安装 JDK 8/17/21:项目要求 17 且 JAVA_HOME 为 8 时验证自动选择兼容版本;核对设置、Run、Maven 目标、测试使用同一 JDK。验证显式选择不变、没有兼容版本时回退并提示,以及无要求时的平台默认顺序。,,macos/Sources/Lithe/Services/Java; macos/Sources/Lithe/Views/Run; macos/Sources/Lithe/Core/Ports/RuntimeLocator.swift; macos/Tests/LitheTests/MavenRuntimeTests.swift; rust/lithe-core/src/execution/java_selection.rs,windows/tauri/src/features/maven; windows/tauri/src/features/workspace; windows/tauri/src-tauri/src/run.rs; shared/fixtures/run-configuration/automatic-java-selection.json -java-maven-project-settings,Java,Java/Maven,按项目配置 Maven 工具链、settings.xml 与本地仓库,已实现,待验证,已实现,待验证,Java / Maven,在 macOS 项目设置和 Windows Maven 设置页分别切换两个项目,配置 Maven、JDK、settings.xml 与本地仓库,验证保存重开、自动检测展示及项目间隔离;Windows 还需验证写盘失败后可直接重试。 Windows 自定义 settings.xml 后,仓库留空应显示该文件中的目录;验证 XML 实体和 CDATA、继承的项目 JDK/Maven、切换项目丢弃未保存草稿;冷启动 Wrapper 不应触发下载,版本探测卡住时设置窗口保持响应并在超时后清理探测进程;Run 配置显式选择的 Maven/JDK 优先于项目设置,未设置的字段分别继承 Maven 设置;确认主任务与前置任务使用相同工具链,Maven 设置上下文缺失时仍保留显式覆盖,均未配置时交由平台自动发现。,,macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift; macos/Sources/Lithe/Services/Java/ProjectRuntimeService.swift,windows/tauri/src/features/settings/components/tabs/maven-settings-panel.tsx; windows/tauri/src/features/maven/stores/maven.store.ts; windows/tauri/src-tauri/src/maven.rs; windows/tauri/src-tauri/src/maven/settings_xml.rs; windows/tauri/src/features/maven/components/maven-detected-value.test.tsx; windows/tauri/src/features/settings/components/tabs/maven-settings-panel.test.tsx; windows/tauri/src/features/run/stores/run.store.ts; windows/tauri/src/features/run/stores/run-maven-context.test.ts -java-maven-project,Java,Java/Maven,项目根、模块与源码集识别,已实现,待验证,已实现,待验证,Java / Maven,使用单模块、多模块和非标准源码目录 fixture 对比项目模型。,,macos/Sources/Lithe/Services/Java; shared/contracts/application-boundary.md,windows/tauri/src/features/maven; windows/tauri/src/features/workspace -java-maven-profiles-dependencies,Java,Java/Maven,Profile 与依赖树,已实现,待验证,已实现,待验证,Java / Maven,"切换 Maven profile 并刷新依赖树,确认模块归属、顺序、版本冲突/重复/依赖管理标注和错误边界;在依赖节点超过 6,000 个的项目中确认依赖树完整加载。 Windows 在 Maven 面板展开「依赖项」应直接加载依赖树,失败时显示宿主返回的真实原因,而不是通用的“无法加载 Maven 依赖”(#970)。",,macos/Sources/Lithe/Views/Run; macos/Sources/Lithe/Services/Java; macos/Sources/LitheExecutionModule/Services/MavenService.swift; macos/Sources/Lithe/Platform/MacOS/Persistence/MacMavenDependencyOutputStore.swift; rust/lithe-core/src/project/maven_dependency_tree.rs,windows/tauri/src/features/maven; windows/tauri/src-tauri/src/maven.rs; rust/lithe-core/src/project/maven_dependency_tree.rs -java-maven-settings-reach-jdt,Java,Java/Maven,Maven 设置与重新加载实时作用于 Java 语言服务,并展示依赖解析问题,已实现,待验证,已实现,待验证,Java / Maven,打开一个依赖尚未下载的 Maven 项目,在 Java 语言服务导入过程中修改 settings.xml、本地仓库或镜像并保存,不点重新加载,确认第三方 import 在导入完成后解析成功;修改 settings.xml 内容(路径不变)后点「重新加载」,确认 Java 语言服务不重启而依赖重新解析;故意写错一个依赖版本,确认 Maven 工具窗口列出 pom.xml 问题、通知可跳转到对应行,修正后问题消失;初始化期间设置不变时点击重新加载,确认导入就绪后仍执行一次强制更新;旧 profile 任务失败或超时期间切换 profile,确认旧请求结束后应用最新选择;保存设置与重新加载交错执行时,已发送的 settings 副本保持可读。,,macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift; macos/Sources/LitheExecutionModule/Services/MavenService.swift; macos/Sources/Lithe/Models/Java/MavenResolutionProblems.swift; macos/Sources/Lithe/Views/Run/MavenView.swift; rust/lithe-core/src/lsp/languages/jdt_maven_settings.rs,windows/tauri/src/features/maven/services/java-maven-configuration.ts; windows/tauri/src/features/maven/services/reload-maven-workspace.ts; windows/tauri/src/features/maven/hooks/use-maven-resolution-problems.ts; windows/tauri/src/platform/lsp-core-adapter.ts; windows/tauri/src/platform/tauri-core.ts; rust/lithe-core/src/lsp/languages/jdt_maven_settings.rs -java-maven-toolbar-tooltips,Java,Java/Maven,Maven 工具栏图标悬停说明,已实现,待验证,已实现,待验证,Java / Maven,在 macOS 和 Windows 的 Maven 工具窗口逐一悬停运行、执行目标、重新加载、跳过测试、折叠和设置图标,确认提示与动作一致、提示框宽度适配文字;再检查禁用按钮和窄窗口中的提示换行与边缘约束。,,macos/Sources/Lithe/Views/Run/MavenView.swift; macos/Sources/Lithe/Views/Workbench/WorkbenchHoverTooltip.swift; macos/Tests/LitheTests/WorkbenchHoverTooltipTests.swift,windows/tauri/src/features/maven/components/maven-pane.tsx -java-build-diagnostics,Java,Java/Maven,构建输出与编译诊断,已实现,待验证,已实现,待验证,Java / Maven,使用成功、编译失败和进程失败构建 fixture,对比诊断位置、输出和退出状态。 Windows Maven 构建输出使用 Geist Mono 显示 ====、==>、=>、!=、>=,确认禁用连字后逐字符显示且复制文本与原始输出一致;检查有输出与空态、ANSI 颜色、自动换行切换及横向滚动。,,macos/Sources/Lithe/Views/Run; macos/Sources/Lithe/Services/Diagnostics,windows/tauri/src/features/maven; windows/tauri/src/features/diagnostics; windows/tauri/src/features/maven/components/maven-run-pane.tsx; windows/tauri/src/features/run/components/run-output-text.tsx -spring-index,Java,Spring / MyBatis,Spring 配置、Bean 与 Endpoint 索引,已实现,待验证,已实现,待验证,Java / Spring,使用 Spring fixture 对比配置、Bean、Endpoint 索引、刷新和失效处理。,,macos/Sources/Lithe/Application/Features; macos/Sources/Lithe/Views/Run; shared/fixtures/spring,windows/tauri/src/features/spring; shared/fixtures/spring -spring-navigation,Java,Spring / MyBatis,Spring 符号导航与代码关联,已实现,待验证,已实现,待验证,Java / Spring,从 Bean、配置和 Endpoint 结果跳转到源代码并返回,确认行列号一致。,,macos/Sources/Lithe/Application/Features; macos/Sources/Lithe/Views/Language,windows/tauri/src/features/spring -spring-endpoints-search,Java,Spring / MyBatis,Spring HTTP 接口筛选与源码跳转,已实现,待验证,已实现,待验证,Java / Spring,在两端打开 Spring fixture,按路由、控制器、方法名和 HTTP 方法筛选,再点击接口核对源码行列;Windows 使用命令面板打开 Spring Endpoints,验证同项目刷新和失败保留结果、源码目录重命名或删除、全量重扫、扫描中切换或关闭项目、窄面板方法集合截断及超过 200 个接口的滚动与跳转。,,macos/Sources/Lithe/Views/Run/SpringEndpointsView.swift; macos/Sources/Lithe/Application/Features/SpringFeatureModel.swift,windows/tauri/src/features/spring/components/spring-endpoints-pane.tsx; windows/tauri/src/features/spring/hooks/use-spring-index.ts; windows/tauri/src/features/spring/hooks/use-spring-index.test.tsx; windows/tauri/src/features/keymaps/commands/view-command-actions.ts -mybatis-navigation,Java,Spring / MyBatis,MyBatis Mapper/XML 导航,已实现,待验证,已实现,待验证,Java / MyBatis,使用 Mapper/XML fixture 对比索引、导航、文件变更刷新和失效处理。,,macos/Sources/Lithe/Application/Features/MybatisFeatureModel.swift; macos/Sources/Lithe/Application/Composition/DocumentFeatureComposition.swift,windows/tauri/src/features/mybatis -lsp-lifecycle,Java,语言服务,LSP/JDTLS 启动与工作区准备,已实现,待验证,已实现,待验证,Language Tooling,在两端启动真实 JDTLS,验证项目准备、重启、超时、取消和资源清理,并确认典型工作流后已安装程序包(macOS app bundle、Windows 安装目录)的文件清单不变。,,macos/Sources/Lithe/Core/Language; macos/Sources/Lithe/Services/Language; rust/lithe-core/src/lsp/languages/jdt_configuration.rs,windows/tauri/src/features; windows/tauri/src-tauri/src/lsp.rs; rust/lithe-core/src/lsp/languages/jdt_configuration.rs; shared/fixtures/lsp -lsp-completion-hover,Java,语言服务,补全与 Hover,已实现,待验证,已实现,待验证,Language Tooling,在相同 Java fixture 中验证补全、Hover、排序、超时和空结果。,,macos/Sources/Lithe/Core/Language; shared/fixtures/lsp,windows/tauri/src/features; shared/fixtures/lsp -lsp-diagnostics,Java,语言服务,诊断与编译错误定位,已实现,待验证,已实现,待验证,Language Tooling,制造语法和类型错误,比较诊断等级、消息、行列号和清理行为。,,macos/Sources/Lithe/Core/Language; macos/Sources/Lithe/Services/Diagnostics,windows/tauri/src/features/diagnostics; shared/fixtures/lsp -lsp-navigation-edits,Java,语言服务,跳转、引用、语义标记与编辑,已实现,待验证,已实现,待验证,Language Tooling,验证定义、引用、语义标记和 UTF-16 文本编辑的坐标转换。,,macos/Sources/Lithe/Core/Language; shared/contracts/rust-core-api.md,windows/tauri/src/features; shared/fixtures/lsp -run-discovery,运行与调试,运行配置,入口点与运行配置发现,已实现,待验证,已实现,待验证,Run,验证 Spring Boot、Java、Maven、Gradle、npm、Cargo、Go、Python 和 Docker Compose 入口识别;多模块项目把服务工作目录改成模块目录后,服务仍留在列表中并以该目录启动;填写不存在的目录或 ${workspaceFolder} 等变量时,保存被拒绝并提示原因;在 Windows 确认重新扫描服务使用双箭头刷新图标。,,macos/Sources/Lithe/Views/Run; shared/fixtures/run-configuration,windows/tauri/src/features/run; shared/fixtures/run-configuration -run-save-toolchain,运行与调试,运行配置,保存前同步与工具链解析,已实现,待验证,已实现,待验证,Run,修改未保存文件后运行,验证同步、JDK/Maven 选择和版本不匹配诊断。,,macos/Sources/Lithe/Views/Run; macos/Sources/Lithe/Services/Java,windows/tauri/src/features/run; windows/tauri/src/features/maven -run-java-test,运行与调试,运行配置,Java main 与测试运行,已实现,待验证,已实现,待验证,Run,运行 main、单测试和测试类,验证参数、输出、失败状态和终端策略。 Windows Run 运行输出使用 Geist Mono 显示 ====、==>、=>、!=、>=,确认禁用连字后逐字符显示且复制文本与原始输出一致;检查有输出与空态、ANSI 颜色、自动换行切换及横向滚动。,,macos/Sources/Lithe/Views/Run; shared/fixtures/debug,windows/tauri/src/features/run; shared/fixtures/debug; windows/tauri/src/features/run/components/run-pane.tsx; windows/tauri/src/features/run/components/run-output-text.tsx -run-java-long-classpath,运行与调试,运行配置,超长 Java 类路径自动缩短,已实现,待验证,已实现,待验证,Run,使用包含大量依赖的 Java 项目运行 main,确认超长类路径自动写入参数文件、进程可启动且参数文件在退出后清理。 Windows 上分别用 JDK 8 和 JDK 17 运行同一个依赖很多、路径含空格和中文的项目 main:JDK 8 应在临时目录生成只含 META-INF/MANIFEST.MF 的 .classpath.jar 并以 -cp 引用,JDK 17 仍使用 @argfile;两者都能启动,进程退出或启动失败后临时文件被删除,安装目录与 JDK 目录不新增文件。 在慢速网络盘 classpath 准备期间确认工作台可交互;停止或重新运行后,旧准备结果不得启动进程,旧 executionId 的停止请求不得影响新执行。,,macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacJavaLaunchArgumentPreparer.swift; macos/Sources/LitheExecutionModule/Services/RunService.swift; rust/lithe-core/src/execution/launch_command.rs,windows/tauri/src-tauri/src/run/launch_arguments.rs; rust/lithe-core/src/execution/launch_command.rs; rust/lithe-core/src/execution/classpath_jar.rs; windows/tauri/src-tauri/src/run.rs -debug-breakpoints,运行与调试,调试器,启动调试与断点,已实现,待验证,部分实现,待验证,Debug,设置、命中、禁用和重新定位断点,确认调试会话生命周期。,Windows 真实调试产品链路仍在 #466 跟进。,macos/Sources/Lithe/Views/Debug; shared/fixtures/debug,windows/tauri/src/features/debugger; shared/fixtures/debug -debug-state,运行与调试,调试器,变量、异常与断开策略,已实现,待验证,部分实现,待验证,Debug,验证变量分页、异常信息、step filters、暂停/继续和 disconnect policy。,Windows 真实调试产品链路仍在 #466 跟进。,macos/Sources/Lithe/Views/Debug; shared/fixtures/debug,windows/tauri/src/features/debugger; shared/fixtures/debug -terminal-shell,工作台,终端,Shell 发现与配置,已实现,待验证,已实现,待验证,Terminal,验证设置页默认 Shell 选项与终端「新建终端」菜单检测到的 Shell 一致、可选 Git Bash 并生效,以及环境变量、工作目录和不可用 Shell 的提示。,,macos/Sources/Lithe/Views/Terminal; macos/Sources/Lithe/Views/App/SettingsView.swift; macos/Sources/Lithe/Services,windows/tauri/src/features/terminal; windows/tauri/src/features/settings/components/macos-settings-panels.tsx; windows/tauri/src/features/settings/lib/default-shell-options.ts; windows/tauri/src-tauri -terminal-sessions,工作台,终端,多会话与标签,已实现,待验证,已实现,待验证,Terminal,创建多个终端会话并切换、重命名、关闭,确认子进程生命周期和资源清理;Windows 横向标签栏的新建终端与 Shell 配置按钮紧跟最后一个标签,标签溢出时按钮保持可见;标签栏右侧最小化按钮隐藏终端面板且会话继续运行,重新打开时恢复为停靠状态;Windows 打开底部终端面板时默认通栏(与 IDEA 一致):面板位于左右两条活动栏之间并横跨侧边栏与编辑器下方(活动栏保持通高,底部的活动栏按钮位置不上移),侧边栏让出纵向空间,且仍可通过面板菜单切换为编辑器宽度。,,macos/Sources/Lithe/Views/Terminal,windows/tauri/src/features/terminal -terminal-interaction,工作台,终端,复制粘贴、搜索与调整大小,已实现,待验证,已实现,待验证,Terminal,验证复制粘贴、终端搜索、调整面板大小和高频输入不丢失。,,macos/Sources/Lithe/Views/Terminal,windows/tauri/src/features/terminal -terminal-long-command-input,工作台,终端,Windows Shell 超长命令输入,平台专属,不适用,已实现,待验证,Terminal,在 Windows cmd.exe 和 PowerShell 中执行超过 Shell 单行限制的 IDE 多行命令,确认脚本执行、原始命令内容和终端关闭后的临时文件清理。,仅在 IDE 后端为 cmd.exe/PowerShell 的超长多行文本创建一次性脚本;不修改系统 Shell 配置。,macos/Sources/Lithe/Views/Terminal,windows/tauri/crates/terminal/src/connection.rs; windows/tauri/src-tauri/src/terminal.rs -markdown-rendering,编辑器,Markdown,Markdown 预览与富文本渲染,已实现,待验证,已实现,待验证,Editor,使用代码高亮、表格、Mermaid、链接和相对路径 fixture 对比渲染结果。,,macos/Sources/Lithe/Views/Editor/MarkdownPreviewView.swift; macos/Sources/Lithe/Core/Ports/MarkdownRendering.swift,windows/tauri/src/features/editor/markdown -markdown-images-links,编辑器,Markdown,图片导入与链接定位,已实现,待验证,已实现,待验证,Editor,验证本地图片、远程图片、相对链接和打开源文件行为。,,macos/Sources/Lithe/Services/Markdown; macos/Sources/Lithe/Platform/MacOS/MarkdownPreviewWebView.swift,windows/tauri/src/features/editor/markdown; windows/tauri/src/features/viewer -history-snapshots,工作台,本地历史,快照列表与 Diff,已实现,待验证,已实现,待验证,Local History,编辑同一文件多次,比较快照时间、内容 Diff 和文件范围。,,macos/Sources/Lithe/Views/History; macos/Sources/Lithe/Services,windows/tauri/src/features/local-history -history-restore,工作台,本地历史,恢复、删除与持久化,已实现,待验证,已实现,待验证,Local History,验证恢复、删除、应用重启后持久化和失败回滚。,,macos/Sources/Lithe/Views/History,windows/tauri/src/features/local-history -database-connections,数据库,数据库工作区,连接配置与 Provider,已实现,待验证,已实现,待验证,Database,创建、编辑、测试和删除各支持数据库连接,确认凭据和错误边界。,,macos/Sources/Lithe/Views/Database; macos/Sources/Lithe/Services,windows/tauri/src/features/database -database-schema,数据库,数据库工作区,Schema、表与列浏览,已实现,待验证,已实现,待验证,Database,展开数据库、Schema、表和列,验证刷新、筛选和空结果展示。,,macos/Sources/Lithe/Views/Database,windows/tauri/src/features/database -database-query-history,数据库,数据库工作区,SQL 查询、历史与分页,已实现,待验证,已实现,待验证,Database,执行查询、取消查询、查看 SQL 历史和分页结果,确认长结果不阻塞界面。,,macos/Sources/Lithe/Views/Database,windows/tauri/src/features/database -database-crud-export,数据库,数据库工作区,表数据编辑、CRUD 与导出,已实现,待验证,已实现,待验证,Database,验证新增、编辑、删除、事务失败、复制和结果导出。,,macos/Sources/Lithe/Views/Database,windows/tauri/src/features/database -file-explorer,工作区,项目浏览,项目文件树与资源打开,已实现,待验证,已实现,待验证,Workspace,浏览目录、展开/折叠、打开资源并在文件变更后刷新树。,,macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift; macos/Sources/Lithe/Models/Workspace,windows/tauri/src/features/file-explorer; windows/tauri/src/features/sidebar -dependency-browser,工作区,项目浏览,项目依赖与模块浏览,已实现,待验证,已实现,待验证,Workspace / Java,打开 Maven 项目依赖和模块树,验证导航、刷新和空项目状态。,,macos/Sources/Lithe/Views/Workspace/DependencySidebarView.swift,windows/tauri/src/features/maven; windows/tauri/src/features/sidebar -outline-symbols,编辑器,代码结构,当前文件 Outline 与符号树,部分实现,待验证,已实现,待验证,Language Tooling,使用 Java 文件验证符号树、展开折叠、排序和跳转;确认 macOS 是否提供同等独立 Outline 入口。,,macos/Sources/Lithe/Views/Language; macos/Sources/Lithe/Views/Search,windows/tauri/src/features/outline -references-pane,编辑器,代码结构,引用结果面板与引用跳转,已实现,待验证,已实现,待验证,Language Tooling,从符号发起引用查询,验证结果分组、文件定位、关闭和重新查询。,,macos/Sources/Lithe/Views/Language/JavaReferencesView.swift,windows/tauri/src/features/references -diagnostics-workspace,可观测性,诊断与日志,工作区诊断与问题面板,已实现,待验证,已实现,待验证,Diagnostics,制造多个来源的错误和警告,验证筛选、排序、定位和清除。,,macos/Sources/Lithe/Application/Features/DiagnosticsFeatureModel.swift; macos/Sources/Lithe/Views/Language/JavaProblemsView.swift,windows/tauri/src/features/diagnostics; windows/tauri/src/features/logging; windows/tauri/src/ui/dialog.tsx -diagnostics-export,可观测性,诊断与日志,诊断包导出与脱敏,已实现,待验证,已实现,待验证,Diagnostics,导出诊断包,确认敏感路径、凭据和大文件被正确脱敏或限制;检查预览说明和文件列表在确认弹窗内完整显示、长内容正常换行且不溢出。,,macos/Sources/Lithe/Views/Diagnostics/DiagnosticsExportSheet.swift; macos/Sources/Lithe/Services/Diagnostics,windows/tauri/src/features/diagnostics -application-settings,设置,应用配置,应用、项目与运行设置,已实现,待验证,已实现,待验证,Settings,修改应用级和项目级设置,验证持久化、迁移、重启恢复和无效值提示;macOS 保持设置窗口打开切换亮色、暗色和跟随系统模式,并改变系统外观,确认标题栏、设置树、插件列表与详情背景和主界面侧边栏使用同一主题色,窗口按钮可用且可拖动;缩小设置窗口确认最小尺寸 900×700,底部操作按钮为 72×28 且边框与输入框一样以 1pt 内描边绘制;拖动设置树与插件列表右侧分隔线,确认设置分类栏不能小于原始宽度、插件列表分隔线可向左缩至 200 pt 或向右加宽,在 900 pt 最小宽度下仍有至少 150 pt 可分配的拖动余量,窗口缩小时设置内容区保留至少 500 pt、插件详情区至少 160 pt,拖动结束后宽度可恢复,悬停及拖动分隔线仅显示左右调整光标而不改变线条颜色;点击设置搜索框后切换分类及点击内容空白区域,确认焦点边框消失;悬停插件条目及设置窗口各子页面的按钮和选择控件时光标保持箭头;从主窗口打开设置后切换到其他应用,确认设置窗口随主窗口置后且不置顶遮挡;在插件页点击 Enable 后点击设置窗口 OK,确认成功时应用并关闭、失败时保留待确认状态且可重试;应用期间确认窗口关闭按钮和常规关闭命令不能关闭窗口,应用结束后恢复关闭能力;A 项目开始应用插件后从 B 项目重新打开共享设置窗口,确认 A 完成后 B 窗口仍打开且未应用草稿保留;待确认时点击 Cancel 或窗口关闭按钮,确认重开后草稿已清除;亮色模式确认分段选择的选中文字清晰可读;检查搜索框、普通及密码输入框、多行编辑框(含获得与失去焦点的边框)、数值步进器、下拉框和分段选择在各设置子页面中的背景、边框、焦点态及操作行为一致,关闭后重开再检查;macOS 切换通用、编辑器、快捷键、项目、运行配置和语言服务等设置页,检查右侧路径标题、分组线、行对齐、说明文字、下拉框、复选框、分段控件与底部操作区;打开短列表和超过十项的下拉框,检查当前项蓝色高亮、无勾号、圆角、紧凑行距、鼠标悬停不跳动、键盘选择与按需滚动;再次点击原下拉按钮应收起弹层,点击另一处下拉框应切换弹层,点击空白处应关闭弹层;缩小可用屏幕高度时检查弹层不遮挡控件;使用辅助功能检查勾选框和分段控件的当前状态。,,macos/Sources/Lithe/LitheApp.swift; macos/Sources/Lithe/Views/App/RootView.swift; macos/Sources/Lithe/Views/App/SettingsView.swift; macos/Sources/Lithe/Views/App/PluginManagementView.swift; macos/Sources/Lithe/Models/Workspace/ProjectSessionManager.swift; macos/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift; macos/Sources/Lithe/Views/Components/LitheSplitPaneView.swift; macos/Sources/Lithe/Views/Workbench/SplitHandleView.swift; macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift; macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift; macos/Tests/LitheTests/SettingsAppearanceContainerTests.swift; macos/Tests/LitheTests/SettingsSelectPopupGeometryTests.swift; macos/Sources/Lithe/Theme/LitheTheme.swift; macos/Tests/LitheTests/PluginManagementPresentationTests.swift,windows/tauri/src/features/settings -keymap-customization,设置,应用配置,快捷键查看与自定义,已实现,待验证,已实现,待验证,Settings,macOS 从设置 → 快捷键依次选择 macOS、IntelliJ IDEA Classic、Eclipse,核对运行、调试、查找等操作在界面和实际监听器中的绑定;IDEA Classic 的 Stop Run 应为 Control-F2,Eclipse 的 Resume 应为 F8,继承父 keymap 的命令应使用上游绑定,Find、Replace、GotoImplementation 等显式清空的命令应无绑定;检查分类树初始折叠、全部展开和折叠、分类展开、搜索时自动显示匹配操作、选中行高亮、右侧紧凑键帽及工具栏编辑菜单;通过键帽、工具栏或右键菜单新增、删除与恢复默认,并检查键盘辅助功能;分别修改两套预设的同一命令,切换与重启后确认自定义绑定互不覆盖,并验证旧版单套覆盖迁移到 macOS;按命令名称、ID 和按键搜索,制造冲突并恢复当前预设或应用全部默认;导出再导入 keybindings.json,确认预设与自定义绑定恢复且命令实际执行。Windows 保持现有快捷键编辑、导出和导入验证。,Windows 已接入现有快捷键编辑器,修复 #718 的占位入口;Linux 前端回归验证不替代 Windows WebView2、原生文件对话框和快捷键实际执行验收。,macos/Sources/Lithe/Views/App/KeyboardShortcutSettingsView.swift; macos/Sources/Lithe/Models/Keymap,windows/tauri/src/features/keymaps; windows/tauri/src/features/settings/components/macos-settings-panels.tsx; windows/tauri/src/features/settings/components/tabs/keyboard-settings.tsx; windows/tauri/src/features/settings/components/keyboard-settings-routing.test.tsx -plugin-extension-management,扩展,插件与主题,插件、语言服务与主题管理,已实现,待验证,已实现,待验证,Extensions,macOS 全新安装时打开插件页,确认显示 PHP Support 未安装占位项,并可选择已签名的 PHP 插件包安装;选择非 PHP 包应被拒绝,安装后可搜索并启用、禁用 PHP,确认状态与重启恢复,且页面不显示无恢复路径的卸载操作;同时打开 Maven 面板确认插件内容不被遮挡。Windows 检查扩展管理入口和持久化。 Windows 缺少 Bun 或语言服务器时更新扩展,确认旧扩展的安装、启用和待更新状态保留,错误给出安装依赖后重试的提示;预检期间禁用或卸载扩展,确认返回后不会继续安装或重新启用;手动将 Marksman 等语言服务器安装到 PATH 后重启,确认能识别并启用。,,macos/Sources/Lithe/Views/App/PluginManagementView.swift; macos/Sources/Lithe/Views/App/SettingsView.swift; macos/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift; macos/Tests/LitheTests/PluginManagementPresentationTests.swift; macos/Sources/Lithe/Platform/MacOS/Plugins,windows/tauri/src/extensions; windows/tauri/src/features/settings; windows/tauri/src-tauri/src/language_tools.rs; windows/tauri/src/extensions/registry/extension-store-lifecycle.test.ts -onboarding-welcome,启动与窗口,首次启动,欢迎页、项目入口与首次启动引导,已实现,待验证,已实现,待验证,Onboarding,清空首次启动状态,验证打开项目、克隆项目、最近项目和跳过引导;在 macOS 深色与浅色模式检查欢迎页布局、颜色和交互,确认默认及最小内容尺寸均为 800×650、可向屏幕可用区域放大,左下角齿轮菜单采用设置页下拉框配色并可进入设置与检查更新。,,macos/Sources/Lithe/Views/App/WelcomeView.swift; macos/Sources/Lithe/Views/Workspace,windows/tauri/src/features/onboarding; windows/tauri/src/features/bootstrap -command-palette,工作台,命令与布局,命令面板与全局动作,已实现,待验证,已实现,待验证,Workbench,搜索并执行打开面板、运行、Git 和设置命令,确认快捷键和不可用命令状态。,,macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift; macos/Sources/Lithe/Views/Search/SearchEverywhereView.swift,windows/tauri/src/features/command-palette -workbench-compact-menu-bar,工作台,命令与布局,紧凑菜单栏可在标题栏左侧原位展开完整水平菜单,并在点击外部或按 Escape 后收起,不遮挡工作区,平台专属,不适用,已实现,待验证,Workbench,在 Windows 工作台点击标题栏左端菜单按钮,确认汉堡按钮由“文件”菜单项取代,“文件”项处于选中状态且其下拉菜单立即展开;菜单与标题栏同高,不覆盖编辑区域,项目/分支控件在展开时隐藏。检查常驻品牌标识与下方活动栏图标列对齐且不可选中,并确认项目菜单显示当前项目图标(无自定义图标时显示项目缩写)。点击其他菜单项时菜单条保持展开并执行动作;点击工作区、标题栏空白和右侧控件或按 Escape 后收起;按 Escape 后确认焦点返回菜单入口;窄窗口确认菜单在标题栏内水平滚动且不挤压窗口控件。macOS 保持系统菜单栏行为。,,macos/Sources/Lithe/LitheApp.swift,windows/tauri/src/features/window/components/title-bar/title-bar.tsx; windows/tauri/src/features/window/components/title-bar/title-project-menu.tsx; windows/tauri/src/features/window/components/window-menu-bar.tsx; windows/tauri/src/features/window/hooks/use-compact-menu-bar-dismissal.ts; windows/tauri/src/features/window/hooks/use-compact-menu-bar-dismissal.test.tsx -workbench-settings-entry,工作台,命令与布局,从工作台 chrome 打开设置:Windows 点击标题栏搜索按钮右侧的齿轮,macOS 通过应用菜单及面板内入口,已实现,待验证,已实现,待验证,Workbench,在 Windows 打开工作区,确认左侧活动栏底部不再显示设置齿轮,活动栏右键“在活动栏中显示”列表不包含设置项;点击标题栏右上搜索按钮右侧的齿轮打开设置对话框并再次关闭;携带旧版设置文件(活动栏排序或隐藏列表含 settings)启动后确认遗留条目被清理且活动栏显示正常。macOS 通过应用菜单和各面板入口打开设置保持不变。,,macos/Sources/Lithe/Models/LitheAction.swift; macos/Sources/Lithe/Views/App/SettingsView.swift,windows/tauri/src/features/window/components/title-bar/title-bar.tsx; windows/tauri/src/features/layout/components/sidebar/sidebar-pane-selector.tsx; windows/tauri/src/features/layout/config/item-order.ts -window-controls,工作台,窗口管理,标题栏控制窗口最小化、最大化/还原与关闭;命令面板可最小化、最大化及切换全屏,已实现,待验证,已实现,待验证,Workbench,在 macOS 检查系统标题栏控制及全屏入口;在 Windows 验证标题栏最小化、最大化/还原和关闭,以及命令面板的窗口操作,确认图标语义、最大化/还原状态与按钮标签一致,并确认悬停关闭按钮背景贴合窗口右边缘。,,macos/Sources/Lithe/LitheApp.swift; macos/Sources/Lithe/Views/App/RootView.swift,windows/tauri/src/features/window/components/title-bar/window-controls.tsx; windows/tauri/src/features/command-palette/constants/window-actions.tsx; windows/tauri/src/features/window/utils/window-actions.ts -workbench-panes,工作台,命令与布局,分栏、面板与工具窗布局,已实现,待验证,已实现,待验证,Workbench,打开、关闭、移动和调整面板,验证布局持久化及高频拖动稳定性;在 macOS 启用背景图后检查项目栏、编辑器、底部及右侧工具窗之间是否仍有清晰的分割区间;将 macOS 左侧工具窗缩至 30pt 并确认目录树始终贴左、不随宽度向右移动、内容不溢出、右上和右下圆角仍可见,关闭并重新打开项目后仍保持窄侧栏及其他布局尺寸,重新展开后布局正常;在 Maven 项目产生构建输出时确认左侧模块按钮可滚动、设置按钮始终完整显示;在亮色主题且有未读通知时确认未打开的铃铛图标清晰可见、打开通知面板后白色图标与蓝色背景成对出现;在深色 Lithe 主题下对照外框渐变、面板对比度、背景图片回退、左右工具窗的 40pt 按钮槽、30pt 选中块及 IDEA Community 图标对比度,悬停工具窗按钮时确认箭头光标,检查项目栏标题颜色和弹窗主按钮主题色;检查 macOS 40pt 顶栏 Logo 与系统窗口按钮垂直居中,项目切换器使用 20pt 项目字母头像和 30pt 悬停块,项目与分支切换块悬停保持箭头光标,红绿灯右侧留白与 IDEA 接近,项目头像、欢迎列表及切换菜单按标准化项目路径共享稳定颜色(超过 9 个项目、移除后重开仍一致),顶栏使用 IDEA Islands 主题项目色并按 85% 混色、600pt 水平和 300pt 垂直范围渐变,深色窗口分隔条四角与底栏缺口无浅色亮斑,缩放窗口及从欢迎页进入工作区再返回后仍对齐。,,macos/Sources/Lithe/Views/Workbench; macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift; macos/Sources/Lithe/Views/Components/LitheSplitPaneView.swift; macos/Sources/Lithe/Services/Workbench/WorkbenchLayoutStore.swift; macos/Sources/Lithe/Views/App/RootView.swift; macos/Sources/Lithe/LitheApp.swift; macos/Sources/Lithe/Theme/LitheTheme.swift; macos/Resources/IDEAIcons/expui; macos/Resources/IDEAIcons/terminal,windows/tauri/src/features/panes; windows/tauri/src/features/layout -workbench-activity-tooltips,工作台,工作台布局,活动栏图标的稳定悬停说明,已实现,待验证,已实现,待验证,Workbench,逐一悬停左上导航、左下工具入口及右侧通知/插件/Maven,快速切换图标并移出活动栏;确认始终只显示当前图标说明,面板更新和滚动后不残留旧提示,禁用入口可显示说明,短文字自适应且提示不超出窗口边缘。,,macos/Sources/Lithe/Views/Workbench/WorkbenchHoverTooltip.swift; macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift; macos/Tests/LitheTests/WorkbenchHoverTooltipTests.swift,windows/tauri/src/features/layout/components/sidebar/sidebar-pane-selector.tsx; windows/tauri/src/features/layout/components/footer/footer-tab-control.tsx; windows/tauri/src/features/layout/components/plugin-activity-rail.tsx -workbench-activity-rail-window-edge,工作台,工作台布局,活动栏贴合窗口外沿,已实现,待验证,已实现,待验证,Workbench,打开工作区,检查左右活动栏与窗口外沿齐平,外侧没有留白、圆角或边框;缩放窗口并切换活动面板后确认布局仍贴边。,,macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift,windows/tauri/src/features/layout/components/main-layout.tsx; windows/tauri/src/features/layout/components/plugin-activity-rail.tsx; windows/tauri/src/features/layout/components/sidebar/main-sidebar.tsx -embedded-viewers,编辑器,资源预览,HTML、SVG、媒体、PDF 与二进制预览,已实现,待验证,已实现,待验证,Editor,分别打开 HTML、SVG、图片、PDF 和二进制文件,验证缩放、错误和源文件返回。 两端将 .html 默认打开程序改成文本编辑器,再点击 HTML 浏览器按钮,确认仍打开默认网页浏览器中的磁盘文件;覆盖中文、空格、# 文件名和无默认浏览器失败提示。,,macos/Sources/Lithe/Views/Editor/HTMLPreviewView.swift; macos/Sources/Lithe/Views/Editor/MediaViewerView.swift; macos/Sources/Lithe/Views/Editor/SVGPreviewView.swift; macos/Sources/Lithe/Platform/MacOS/UI/MacHTMLBrowserOpener.swift; macos/Tests/LitheTests/HTMLBrowserOpenerTests.swift,windows/tauri/src/features/viewer; windows/tauri/src-tauri/src/html_browser.rs; windows/tauri/src/features/editor/components/html/html-preview.tsx -docker-compose,运行与调试,项目运行,Docker / Compose 项目识别与运行,部分实现,待验证,已实现,待验证,Run / Docker,使用 Dockerfile 和 Compose fixture 验证识别、命令参数、输出和进程停止;确认 macOS 是否只有入口识别。,,macos/Sources/Lithe/Views/Run/RunConfigurationIcon.swift; macos/Sources/Lithe/Services/Java,windows/tauri/src/features/docker; windows/tauri/src/features/run -remote-workspace,工作区,远程开发,远程连接与远程路径工作区,未实现,不适用,已实现,待验证,Remote,Windows 验证连接、密码提示、远程路径、断线和重连;macOS 需要补充产品入口或明确不支持。,,macos/Sources/Lithe/Services,windows/tauri/src/features/remote; windows/tauri/src/features/file-system -vim-mode,编辑器,编辑模式,Vim 模式与命令状态,未实现,不适用,已实现,待验证,Editor,Windows 验证 Normal/Insert/Visual 模式、命令执行和设置持久化;macOS 需要确认产品范围。,,macos/Sources/Lithe/Views/Editor,windows/tauri/src/features/vim; windows/tauri/src/features/editor -workbench-notifications,可观测性,通知与资源,操作通知、错误提示与自动消失,已实现,待验证,已实现,待验证,Workbench,触发成功、警告和失败通知,验证队列、关闭、超时和重复通知。,,macos/Sources/Lithe/Application/Features/WorkbenchNotificationFeatureModel.swift,windows/tauri/src/features/notifications -memory-monitor,可观测性,通知与资源,应用与语言服务内存监控,已实现,待验证,已实现,待验证,Workbench,打开内存指标并观察应用、语言服务和总内存变化,确认采样失败不会阻塞工作台。,,macos/Sources/Lithe/Services/Monitoring/MemoryUsageMonitor.swift,windows/tauri/src/features/layout/services/memory-api.ts; windows/tauri/src/platform/tauri-core.ts -updates-check-download,发行与更新,应用更新,检查、下载与安装更新,已实现,待验证,已实现,待验证,Release,使用 preview 更新清单验证检查、下载、校验、安装和重启;从应用菜单或欢迎页左下角齿轮菜单手动检查到新版本时直接弹出更新详情并可一键安装,设置页检查到新版本时在检查按钮旁显示安装按钮,启动时的自动检查只在标题栏提示。 Windows 未检查、正在检查、跳过版本和失败时不得显示已是最新版;菜单通知明确指 Lithe 主程序,不代表语言扩展状态。,,macos/Sources/Lithe/Views/App/UpdateControl.swift; macos/Sources/Lithe/Views/App/SettingsView.swift; macos/Sources/Lithe/Platform/MacOS/Updates,windows/tauri/src/features/layout/components/app-update-control.tsx; windows/tauri/src/features/layout/components/app-update-details-dialog.tsx; windows/tauri/src/features/settings/components/macos-settings-panels.tsx; windows/tauri/src/features/settings/hooks/use-updater.ts; windows/tauri/src/features/settings/stores/update.store.test.ts -updates-failure-rollback,发行与更新,应用更新,失败重试与回滚,部分实现,待验证,已实现,待验证,Release,模拟清单错误、下载失败和安装失败,确认重试、回滚和用户提示。,macOS 回退更新方案仍在 #734 跟进。,macos/Sources/Lithe/Platform/MacOS/Updates,docs/releases/windows-updater.md; windows/tauri/src/features/settings/hooks/use-updater.ts -community-feed,社区,LINUX DO 社区,主题列表与详情,未实现,不适用,未实现,不适用,Community,如果重新引入社区入口,在 macOS 和 Windows 验证 Latest/Top、主题详情、分页和浏览器跳转。,macOS 的 LINUX DO 插件已退役,当前两端均无社区入口。,macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift,windows/tauri/src/features -community-auth,社区,LINUX DO 社区,社区认证与请求失败处理,未实现,不适用,未实现,不适用,Community,如果重新引入社区入口,在 macOS 和 Windows 验证认证、过期、限流和网络错误。,macOS 的 LINUX DO 插件已退役,现存 fixture 不代表产品接入。,macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift; shared/fixtures/community,windows/tauri/src/features -wsl-paths,Windows 专属,WSL,WSL 文件与工作区路径,平台专属,不适用,已实现,待验证,Windows Platform,Windows 验证本地文件、WSL 文件、发行版识别和工作区打开。,,macos/Sources/Lithe,windows/tauri/src/features/wsl; windows/tauri/src/features/file-system -wsl-boundaries,Windows 专属,WSL,跨 WSL 边界移动与重命名,平台专属,不适用,已实现,待验证,Windows Platform,Windows 验证本地、同发行版、跨发行版移动/重命名及明确错误提示。,,macos/Sources/Lithe,windows/tauri/src/features/wsl; windows/tauri/src/features/file-system -editor-file-encoding-reopen,编辑器,文本编辑,按指定编码重新打开文本文件,已实现,待验证,已实现,待验证,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/services/document-encoding-workflow.ts; windows/tauri/crates/project/src/document_file.rs -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,macOS 在干净安装上打开插件管理,确认可从当前发行渠道下载并安装 PHP、失败时可从磁盘导入;安装后重启,在 LSP 界面只控制当前项目开关和状态,验证重装、卸载均在重启后生效且用户工具保留。Windows 从独立 .lithe-extension 文件导入,默认禁用;启用后重启确认能恢复,卸载后重启确认不恢复。,,Plugins/mac/Official/PhpSupport; scripts/official-plugin-distribution.mjs; macos/Sources/Lithe/Platform/MacOS/Plugins; macos/Sources/Lithe/Views/App/PluginManagementView.swift; macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift; .github/workflows/release-macos.yml,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 -editor-unicode-text-classification,编辑器,文本编辑,中文 properties 与未知扩展名文本的一致打开和恢复,已实现,待验证,已实现,待验证,Editor,双端打开高中文比例的 .properties 和未知扩展名 UTF-8 文件,编辑保存后恢复会话并验证内容及编码;验证 properties 高亮、emoji 跨边界、NUL 和控制字符拒绝、读取错误独立报告,并执行 text-content-v1.json 共享规则样例。,,macos/Sources/Lithe/Core/Rust/RustTextContentPolicy.swift; macos/Tests/LitheTests/TextContentPolicyTests.swift; frontend/editor/src/language-contributions.ts,windows/tauri/src/features/file-system/controllers/workspace-session-restore.ts; windows/tauri/src/features/file-system/controllers/workspace-session-restore.test.ts; frontend/editor/src/language-contributions.ts -ide-api-plugins,插件与扩展,IDE API / MCP,插件调用已授权项目的环境、Maven 与运行 API,已实现,待验证,已实现,待验证,IDE API,双端插件枚举显式授权的项目,读取环境并调用已允许的操作;撤销项目权限后拒绝调用,Windows 未声明 permissions.ide 的 Worker 无法访问。,复用 MCP 的同一应用功能和参数校验,不扩张语言包权限;原生运行验收待完成。,macos/Sources/LitheModuleAPI/Plugins/IDECapabilities.swift; macos/Sources/Lithe/Application/Composition/IdeCapabilityRegistry.swift,Plugins/win/SDK/ide-capabilities.ts; windows/tauri/src/extensions/ui/services/ide-extension-host.ts; windows/tauri/src/extensions/ui/services/ide-extension-host.test.ts -java-service-devtools-update,Run/Debug,Java 服务更新,运行中的 Spring Boot DevTools 服务手动保存并编译更新,已实现,待验证,已实现,待验证,Run/Debug,在双端运行 Spring Boot HTTP 服务,修改方法体并更新,检查响应、PID、DevTools 日志与断点暂停状态;覆盖编译失败、JVM 拒绝结构修改、连续点击、停止/重启、依赖路径变化。对比运行前后安装目录清单/哈希。Linux 仅完成共享 Core 和前端测试,原生验证待完成。,,macos/Sources/Lithe/Views/Run/RunView.swift,windows/tauri/src/features/run/components/run-pane.tsx -java-service-hot-code-replace,Run/Debug,Java 服务更新,本地 Java Debug 服务手动编译并热替换代码,已实现,待验证,已实现,待验证,Run/Debug,在双端运行 Spring Boot HTTP 服务,修改方法体并更新,检查响应、PID、DevTools 日志与断点暂停状态;覆盖编译失败、JVM 拒绝结构修改、连续点击、停止/重启、依赖路径变化。对比运行前后安装目录清单/哈希。Linux 仅完成共享 Core 和前端测试,原生验证待完成。,,macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift,windows/tauri/src/features/debugger/components/java-service-update.tsx diff --git a/docs/development/platform-parity-matrix.md b/docs/development/platform-parity-matrix.md index 3533caaa1..0cf9f61d0 100644 --- a/docs/development/platform-parity-matrix.md +++ b/docs/development/platform-parity-matrix.md @@ -1,316 +1,9 @@ -# macOS / Windows 功能对齐矩阵 +# macOS / Windows 功能矩阵 -> 本页由 `shared/platform-feature-matrix.json` 自动生成。不要直接编辑本文件;新增或变更功能时更新源数据,再运行 `node scripts/generate-platform-feature-matrix.mjs`。 +[在线查看 preview 分支矩阵](https://1lck.github.io/Lithe-IDEA/platform-feature-matrix/)(合并后由 Pages 工作流更新)。页面提供 Markdown、CSV、JSON 下载,并显示对应提交。 -- 最后复核:2026-09-29 -- 盘点状态:initial-static-inventory(根据 macOS Views/Application/Services、Windows features/extensions 和共享契约的代码入口进行初版盘点;未替代真实运行验收。) -- 功能项:117 -- macOS:实现:✅ 103 已实现,🟡 3 部分实现,❌ 7 未实现,🧩 4 平台专属;验证:✔️ 0 已验证,🔍 106 待验证,— 11 不适用 -- Windows:实现:✅ 99 已实现,🟡 11 部分实现,❌ 5 未实现,🧩 2 平台专属;验证:✔️ 0 已验证,🔍 110 待验证,— 7 不适用 +PR 对应的矩阵请在 **Verify Platform Feature Matrix** 工作流摘要中下载 artifact;在线页面不代表尚未合并的 PR。 -## 实现状态定义 +源数据位于 [能力目录](../../shared/platform-feature-matrix/features/),维护方式见 [功能对齐说明](platform-parity.md)。 -| 状态 | 含义 | -| --- | --- | -| ✅ 已实现
implemented | 代码入口和产品接入均已存在。 | -| 🟡 部分实现
partial | 已有实现,但范围、入口、平台能力或用户体验仍不完全一致。 | -| ❌ 未实现
missing | 当前没有足够的实现入口或产品接入证据。 | -| 🧩 平台专属
platform-specific | 刻意只属于某个平台,不以跨平台对齐为目标。 | - -## 验证状态定义 - -| 状态 | 含义 | -| --- | --- | -| ✔️ 已验证
verified | 已按该能力点的验证方式完成运行时验证。 | -| 🔍 待验证
pending | 静态代码无法替代运行时验证,仍需按验证方式确认。 | -| — 不适用
not-applicable | 当前没有可运行的实现入口,暂不适用运行时验证。 | - -## 功能矩阵 - -> 每一行对应一个可以单独验收的用户能力;区域和功能组只用于导航,不作为状态统计单位。单元格第一行是实现状态,第二行是验证状态。 - -
-AI · 19 个能力点 - -| 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | -| --- | --- | --- | --- | --- | --- | --- | -| IDE MCP | **插件与 MCP 列举运行配置、启动重启,按执行 ID 读取增量输出和停止**
ide-api-run-output | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Application/Features/IdeCapabilitiesFeatureModel.swift`、`macos/Sources/Lithe/Views/App/McpSettingsView.swift`、`macos/Tests/LitheTests/IdeCapabilitiesTests.swift`、`rust/lithe-ide-host/src/lib.rs` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/host-api/ide-capabilities.ts`、`windows/tauri/src/features/host-api/mcp-settings.tsx`、`windows/tauri/src/features/host-api/ide-capabilities.test.ts`、`rust/lithe-ide-host/src/lib.rs` | Agent | 运行服务、读增量输出、清空日志验证 reset、重启后拒绝旧 ID,停止正确进程,检查退出码及资源清理。 运行前后对比安装目录文件清单与哈希;当前 Linux 仅执行共享协议及前端验证,原生运行待验证。 | | -| IDE MCP | **插件与 MCP 读取 Maven 模块和 Profile,保存配置、重载并执行既有生命周期和插件目标**
ide-api-maven | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Application/Features/IdeCapabilitiesFeatureModel.swift`、`macos/Sources/Lithe/Views/App/McpSettingsView.swift`、`macos/Tests/LitheTests/IdeCapabilitiesTests.swift`、`rust/lithe-ide-host/src/lib.rs` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/host-api/ide-capabilities.ts`、`windows/tauri/src/features/host-api/mcp-settings.tsx`、`windows/tauri/src/features/host-api/ide-capabilities.test.ts`、`rust/lithe-ide-host/src/lib.rs` | Agent | 双端对同一示例项目设置 Profile/skipTests,重载、执行 clean verify,核对按钮与 MCP 的命令、输出、失败和取消。 运行前后对比安装目录文件清单与哈希;当前 Linux 仅执行共享协议及前端验证,原生运行待验证。 | | -| IDE MCP | **插件与 MCP 查询和修改项目 Java/Maven 默认环境,并返回实际选择结果**
ide-api-environment | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Application/Features/IdeCapabilitiesFeatureModel.swift`、`macos/Sources/Lithe/Views/App/McpSettingsView.swift`、`macos/Tests/LitheTests/IdeCapabilitiesTests.swift`、`rust/lithe-ide-host/src/lib.rs` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/host-api/ide-capabilities.ts`、`windows/tauri/src/features/host-api/mcp-settings.tsx`、`windows/tauri/src/features/host-api/ide-capabilities.test.ts`、`rust/lithe-ide-host/src/lib.rs` | Agent | 读取环境、修改一个默认值,检查其他字段保留;对照设置页与实际启动路径,验证保存失败及项目切换。 运行前后对比安装目录文件清单与哈希;当前 Linux 仅执行共享协议及前端验证,原生运行待验证。 | | -| IDE MCP | **从设置侧栏独立的 MCP 配置页按项目开启连接,分别授权配置与执行,复制 stdio 客户端配置;关闭项目撤销访问**
ide-mcp-connection | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Application/Features/IdeCapabilitiesFeatureModel.swift`、`macos/Sources/Lithe/Views/App/McpSettingsView.swift`、`macos/Tests/LitheTests/IdeCapabilitiesTests.swift`、`rust/lithe-ide-host/src/lib.rs`、`macos/Sources/Lithe/Views/App/SettingsView.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/host-api/ide-capabilities.ts`、`windows/tauri/src/features/host-api/mcp-settings.tsx`、`windows/tauri/src/features/host-api/ide-capabilities.test.ts`、`rust/lithe-ide-host/src/lib.rs`、`windows/tauri/src/features/settings/components/mcp-configuration-settings.tsx` | Agent | 检查设置侧栏 MCP 配置入口与搜索跳转;未打开项目时显示提示,切换项目后展示对应授权。双端打包启动后开启连接,配置真实 Agent,验证工具发现、默认拒绝写入、启用权限、禁用、项目关闭和多窗口隔离。 运行前后对比安装目录文件清单与哈希;当前 Linux 仅执行共享协议及前端验证,原生运行待验证。 | | -| Agent 对话 | **新安装及模块关闭时仍显示 Agent 侧栏入口,面板立即提示关闭或未配置原因并提供设置入口,启停后入口不重复或消失**
agent-entry-discoverability | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Application/Features/AgentConversationEntryPolicy.swift`、`macos/Sources/Lithe/Views/Agent/AgentConversationView.swift`、`macos/Tests/LitheTests/AgentConversationEntryPolicyTests.swift`、`macos/Resources/IDEAIcons/toolwindows/toolWindowAgent.svg`、`macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift` | ❌ 未实现
— 不适用
`windows/tauri/src/features` | Agent | 使用无本机模块配置的 macOS 测试账户启动并打开项目,确认 Agent 入口可见、默认开关关闭、面板显示设置指引;开启再关闭后入口和面板保留且不重复。确认读取入口不调用模块 factory、关闭状态无 Agent 连接或进程。Windows 尚无 Agent 对话 UI。 Agent 侧栏入口使用对话气泡加星光的专用线性图标;在深浅主题、选中与未选中状态下检查可辨识性。 | | -| Agent 对话 | **CC GUI 风格历史页:标题与会话 ID 搜索、更新时间、复制 ID、刷新与恢复、收藏与本地重命名、单条与批量移除确认及恢复、筛选、多选批量管理及 Markdown 导出**
agent-conversation-history | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Agent/AgentHistoryView.swift`、`macos/Sources/LitheAgentConversationModule/Application/AgentHistoryFeatureModel.swift`、`macos/Sources/Lithe/Platform/MacOS/Agent/MacAgentHistoryAdapters.swift`、`macos/Tests/LitheTests/AgentHistoryTests.swift` | ❌ 未实现
— 不适用
`rust/lithe-agent-host/src/lib.rs` | Agent | macOS:在深浅主题与窄宽面板查看历史页,验证标题/ID 搜索、空结果、返回保留草稿、刷新失败不丢列表、打开历史恢复会话;验证收藏/重命名重启后保留且不同项目/Agent 隔离;筛选和全选只操作可见项,单条与批量移除先确认,取消不修改记录,确认只移除提示时选中的会话,移除后可恢复且不改上游文件;单个与批量 Markdown 导出等待历史回放完成、保持原选中标签,取消/失败不写部分记录;部分回放后失败再导出必须重新加载并等待完成,断连后的未完整回放不可导出,完整快照断连后仍可导出;关闭项目释放导出等待。消息数量只显示已加载的用户与 Agent 消息,未知总数不伪造。Windows 页面待实现。 | | -| Agent 对话 | **Agent 上下文用量:输入框圆环百分比、悬停已用/容量 token,未使用时零占位、会话隔离与压缩后更新**
agent-context-usage | ✅ 已实现
🔍 待验证
`macos/Sources/LitheAgentConversationModule/Application/AgentContextUsage.swift`、`macos/Sources/Lithe/Views/Agent/AgentContextUsageView.swift`、`macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift`、`macos/Tests/LitheTests/AgentConversationPresentationTests.swift` | 🟡 部分实现
🔍 待验证
`rust/lithe-agent-host/src/lib.rs`、`shared/fixtures/agent/acp-events-v1.json` | Agent | macOS:真实 Agent 对话后检查输入框顶部圆环百分比;分别悬停圆环与百分比,立即在上方显示已用/容量 token,移开或切换面板后关闭浮层,窄面板浮层不越界;首次、Agent 未上报或已上报零使用量时显示 0%,悬停仅显示“上下文: 0.0%”,内部缺失状态不伪造 token 或容量;切换会话/Agent、新建会话不串用量,压缩后用量可降低,不累加计费用量;模型确认切换和断连后清除旧容量,重新加载或上报后更新;窄宽面板及深浅主题检查布局和辅助功能。Windows 共享 host 可转发 usage_update,页面待实现。 | | -| Agent 对话 | **可选 ACP Agent 对话:API Key 网关登录、项目会话与历史恢复、模型/权限/思考配置、不依赖凭据的本机默认模型同步、旧默认模型按上游推荐修复与品牌模型图标、CC GUI 风格可搜索模型与权限弹出面板、工具详情与权限、停止确认和进程清理**
agent-acp-conversation | ✅ 已实现
🔍 待验证
`macos/Sources/LitheAgentConversationModule`、`macos/Sources/Lithe/Views/Agent`、`rust/lithe-agent-host`、`macos/Tests/LitheTests/AgentBrandIconResourceTests.swift` | 🟡 部分实现
🔍 待验证
`rust/lithe-agent-host` | Agent | macOS + Codex + API Key:验证发消息前的上游配置选项和确认;无自定义服务商或 API Key 时确认仍显示本机顶层默认模型、保留密钥缺失错误;修改本机默认模型后重连,连续新建空会话确认不再使用旧导入模型;配置不在上游目录中的旧模型时确认新会话使用目录内的上游推荐值、拒绝与超时不显示假成功且 Codex 模型菜单显示 OpenAI 标志;验证工具输入/输出/文件/变更、权限拒绝、停止确认及超时重连、历史加载失败保留记录;临时项目跑通读文件→修改→执行测试→追问,复用文档观察和脏缓冲区保存保护。检查模型搜索(名称/ID/分组、空结果)、品牌图标/选中状态、无模型说明、权限说明、思考/速度子菜单、确认/失败与会话切换关闭弹出面板;检查项目切换、退出清理,以及 CC GUI 风格空态、搜索、标签、可调整输入区和配置菜单在窄宽面板及深浅主题下的布局。Windows 与 Claude 端到端仍待验证。 安装包环境下打开 Agent 面板,确认 Codex/Claude 图标从 Contents/Resources 加载且不依赖构建目录;资源包或图标缺失时显示默认图标而不闪退,读取前后包内文件清单和内容不变。 | | -| Agent 对话 | **连续 Agent 工具调用(执行命令、列文件、读文件与编辑等)合并为默认折叠的时间线卡片,汇总总数与失败/中断/完成进度,并逐条展开输入、输出与文件位置**
agent-tool-activity-timeline | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift`、`macos/Sources/Lithe/Views/Agent/AgentToolGroupView.swift`、`macos/Tests/LitheTests/AgentConversationPresentationTests.swift` | ❌ 未实现
— 不适用
`windows/tauri/src/features` | Agent | macOS:让真实 Agent 连续列文件、读取文件、执行命令和编辑,确认相邻工具调用只显示一张默认折叠的时间线卡片;展开后每条状态、完整输入、输出和文件位置仍可查看,进行中、全部完成、失败和中断时汇总正确。用 Agent 文字说明和新一轮用户消息隔开工具调用,确认保留文字顺序且不会跨边界合并;搜索工具输入、输出或文件路径时自动展开匹配组;覆盖文件路径仅由 diff 内容上报、未出现在工具标题、输入输出或 locations 中的情况,历史恢复结果与实时流一致。在窄宽面板及深浅主题检查长标题截断、悬停全文、列表滚动和详情可读性。Windows Agent 对话 UI 尚未实现。 | | -| Agent 对话 | **发送后实时计时、每轮结束固定耗时、Agent 上报的输入/输出 token 与缓存/推理用量详情**
agent-turn-statistics | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Agent/AgentTurnStatisticsView.swift`、`macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift`、`macos/Sources/LitheAgentConversationModule/Application/AgentTurnStatistics.swift`、`macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift`、`macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift`、`macos/Tests/LitheTests/AgentConversationPresentationTests.swift` | 🟡 部分实现
🔍 待验证
`rust/lithe-agent-host/src/prompt.rs`、`rust/lithe-agent-host/src/tests.rs`、`shared/fixtures/agent/acp-events-v1.json` | Agent | macOS:在真实 Agent 会话中确认发送后计时包含建会话、加载历史、工具和权限等待;正常完成、取消确认、请求失败和断连后停止计时,下一轮仍可看到上一轮统计。并发会话切换验证耗时与用量隔离;上报 usage 时显示准确输入/输出并悬停查看总量、缓存和推理值,缺失时只显示耗时。历史回放不编造统计,上下文占用与订阅额度不作为输入/输出 token。检查深浅主题和窄宽面板。Windows 共享 Host 已透传可选 usage,对话 UI 尚未接入。 | | -| 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 验证历史加载、重命名、置顶、归档、删除和重启后持久化。 | | -| AI 对话 | **多 Provider 与工作区范围**
ai-chat-providers | ❌ 未实现
— 不适用
`macos/Sources/Lithe/Platform/MacOS/AI` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/ai/services/providers`、`windows/tauri/src/features/ai/lib/ai-workspace-scope.ts` | AI | Windows 验证 Provider 切换、凭据边界、工作区范围和模型错误提示。 | | - -
- -
-工作区 · 7 个能力点 - -| 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | -| --- | --- | --- | --- | --- | --- | --- | -| 工作区生命周期 | **打开工作区与切换项目**
workspace-open-switch | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Workspace`、`macos/Sources/Lithe/Services/Workspace` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/workspace` | Workspace | 打开多个项目并在项目之间切换,确认当前项目、文件树和编辑器状态正确。 | | -| 工作区生命周期 | **文件树与文件操作**
workspace-files | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Workspace` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/file-system` | Workspace | 新建、移动、重命名、删除文件和目录,并确认相对路径与错误提示一致。 | | -| 工作区生命周期 | **脏状态、保存与外部修改**
workspace-document-sync | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Models/Editor`、`macos/Sources/Lithe/Services/Workspace` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/editor`、`windows/tauri/src/features/file-system` | Workspace | 编辑未保存文件、外部修改文件并重启应用,确认冲突、保存和恢复行为。 | | -| 工作区生命周期 | **多项目与多标签**
workspace-tabs-projects | ✅ 已实现
🔍 待验证
`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` | Workspace | 同时打开多个项目和文件,确认标签、项目上下文和关闭恢复行为。 Windows 重复打开同一本地目录(含大小写、分隔符和目录链接别名)时,恢复并聚焦已有窗口及项目标签;连续打开只产生一个窗口,关闭或打开失败后可以重试。 | | -| 项目浏览 | **项目文件树与资源打开**
file-explorer | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift`、`macos/Sources/Lithe/Models/Workspace` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/file-explorer`、`windows/tauri/src/features/sidebar` | Workspace | 浏览目录、展开/折叠、打开资源并在文件变更后刷新树。 | | -| 项目浏览 | **项目依赖与模块浏览**
dependency-browser | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Workspace/DependencySidebarView.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/maven`、`windows/tauri/src/features/sidebar` | Workspace / Java | 打开 Maven 项目依赖和模块树,验证导航、刷新和空项目状态。 | | -| 远程开发 | **远程连接与远程路径工作区**
remote-workspace | ❌ 未实现
— 不适用
`macos/Sources/Lithe/Services` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/remote`、`windows/tauri/src/features/file-system` | Remote | Windows 验证连接、密码提示、远程路径、断线和重连;macOS 需要补充产品入口或明确不支持。 | | - -
- -
-编辑器 · 16 个能力点 - -| 功能组 | 能力点 | 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`、`windows/tauri/src/features/tabs/components/tab-context-menu.tsx`、`windows/tauri/src/features/tabs/components/tab-context-menu.test.ts` | Editor | 打开、编辑、保存、关闭和恢复多个文本文件,确认光标、脏状态和标签状态;右键标签页确认关闭分组排在其他操作之前;在 macOS 打开无扩展名文本文件,确认标签与项目树图标一致;从外部改为二进制后折叠并展开项目树,确认两处图标更新一致。 | | -| 文本编辑 | **鼠标与触控板点击、选词和拖选**
editor-pointer-selection | ✅ 已实现
🔍 待验证
`macos/EditorFrontend/main.ts`、`macos/EditorFrontend/mouse-input.ts`、`macos/Experiments/Monaco/mouse-input.integration.ts` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/editor/components/monaco-editor.tsx` | Editor | macOS 分别启用微信输入法和系统 ABC,快速轻点不同位置后松手移动,确认不扩展选区、不移动文字;验证同位置双击选词、Shift 点击扩选、按住拖选、选中文字拖放、主编辑器和分屏及 diff。运行 scripts/probe-macos-monaco.sh --workbench-tests;Windows 验证同样的正常鼠标交互。 | macOS 在宿主修正 WebKit 零按钮按下事件,保留 Monaco 选择语义。Linux Chromium 真实 Monaco 控制事件回放通过,仍待 macOS 微信输入法与 Windows WebView2 实机验证。 | -| 文本编辑 | **语法高亮与编辑器模型**
editor-language-basics | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Editor`、`macos/Sources/Lithe/Models/Editor` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/editor`、`frontend/editor`、`windows/tauri/src/features/editor/engines/monaco/theme.ts` | Editor | 分别打开 Java、Markdown 和普通文本,确认语言识别、语法高亮和模型切换;开启缩略图并滚动,确认滚动条轨道不透出编辑器代码。 | | -| 文本编辑 | **macOS 中文组合输入层遮住原文字,输入和删除候选文字时不透出整行重影**
editor-macos-ime-presentation | ✅ 已实现
🔍 待验证
`macos/EditorFrontend/ime-input.css`、`macos/EditorFrontend/main.ts`、`macos/Experiments/Monaco/ime-input.integration.ts` | 🧩 平台专属
— 不适用
`windows/tauri/src/features/editor/components/monaco-editor.tsx` | Editor | 运行 scripts/probe-macos-monaco.sh --workbench-tests。在 macOS 使用系统拼音与微信输入法,在含中文和英文的同一行中间输入、删减候选拼音、确认上屏,再按删除键;检查整行无重影、候选与光标正常、撤销恢复文本。覆盖明暗主题、背景图开关和分屏,结束输入后背景恢复。 | 修正 macOS 原生主题半透明当前行底色作为 Monaco 输入法覆盖层底色时的字形透出。Linux Chromium 的真实 Monaco 组合事件样式回归通过;macOS 原生输入法及删除键实机验证待完成。Windows 不加载此 macOS 样式。 | -| 编辑器外观 | **macOS 工作台壁纸模式下,编辑器概览标尺与代码缩略图随编辑器背景透明,不显示黑色竖条;普通主题仍保持实体背景**
editor-wallpaper-overview-ruler | ✅ 已实现
🔍 待验证
`frontend/editor/src/theme.ts`、`macos/Sources/Lithe/Platform/MacOS/MonacoWorkbenchEditor.swift`、`macos/Experiments/Monaco/workbench.integration.ts` | 🧩 平台专属
— 不适用
`windows/tauri/src/features/editor/engines/monaco/theme.ts` | Editor | 在 macOS 选择工作台背景并打开文本文件,分别关闭和打开右侧 Agent 面板,确认编辑器最右侧概览标尺不出现黑色竖条;关闭背景图后确认普通主题底色保持不变。运行真实 WebKit 工作台主题集成测试。Windows 不使用 macOS 原生工作台壁纸。 | | -| 文本编辑 | **LSP 语义高亮**
editor-semantic-highlighting | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Models/AppModel/AppModel+LanguageEditing.swift`、`macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift`、`frontend/editor/src/semantic-tokens.ts` | ✅ 已实现
🔍 待验证
`windows/tauri/src/platform/lsp-core-adapter.ts`、`windows/tauri/src/features/editor/lsp/lsp-client.ts`、`windows/tauri/src/features/editor/engines/monaco/semantic-token-provider.ts`、`windows/tauri/src/features/editor/engines/monaco/semantic-token-provider.test.ts`、`windows/tauri/src/platform/lsp-core-adapter.test.ts` | Editor / Language Tooling | 打开普通 Java 文件并等待 JDTLS 就绪,开启语义高亮,确认字段、方法等按协商图例上色;编辑、切换、关闭文档和重连服务器后不应用旧结果;服务器 refresh 后重新请求;关闭设置或大文件降级时保留基础语法高亮。 | Windows 复用 Core semanticTokens 和共享 fixture,修复 #675;Linux 上的适配器与模型测试不替代 Windows WebView2/JDTLS 实机验收。 | -| 文本编辑 | **多行标签与标签导航**
editor-multiline-tabs | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Editor` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/tabs` | Editor | 打开足够多文件触发多行标签,确认滚动、切换、关闭和活动文件保持。 | | -| Markdown | **Markdown 预览与富文本渲染**
markdown-rendering | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Editor/MarkdownPreviewView.swift`、`macos/Sources/Lithe/Core/Ports/MarkdownRendering.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/editor/markdown` | Editor | 使用代码高亮、表格、Mermaid、链接和相对路径 fixture 对比渲染结果。 | | -| Markdown | **图片导入与链接定位**
markdown-images-links | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Services/Markdown`、`macos/Sources/Lithe/Platform/MacOS/MarkdownPreviewWebView.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/editor/markdown`、`windows/tauri/src/features/viewer` | Editor | 验证本地图片、远程图片、相对链接和打开源文件行为。 | | -| 代码结构 | **当前文件 Outline 与符号树**
outline-symbols | 🟡 部分实现
🔍 待验证
`macos/Sources/Lithe/Views/Language`、`macos/Sources/Lithe/Views/Search` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/outline` | Language Tooling | 使用 Java 文件验证符号树、展开折叠、排序和跳转;确认 macOS 是否提供同等独立 Outline 入口。 | | -| 代码结构 | **引用结果面板与引用跳转**
references-pane | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Language/JavaReferencesView.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/references` | Language Tooling | 从符号发起引用查询,验证结果分组、文件定位、关闭和重新查询。 | | -| 资源预览 | **HTML、SVG、媒体、PDF 与二进制预览**
embedded-viewers | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Editor/HTMLPreviewView.swift`、`macos/Sources/Lithe/Views/Editor/MediaViewerView.swift`、`macos/Sources/Lithe/Views/Editor/SVGPreviewView.swift`、`macos/Sources/Lithe/Platform/MacOS/UI/MacHTMLBrowserOpener.swift`、`macos/Tests/LitheTests/HTMLBrowserOpenerTests.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/viewer`、`windows/tauri/src-tauri/src/html_browser.rs`、`windows/tauri/src/features/editor/components/html/html-preview.tsx` | Editor | 分别打开 HTML、SVG、图片、PDF 和二进制文件,验证缩放、错误和源文件返回。 两端将 .html 默认打开程序改成文本编辑器,再点击 HTML 浏览器按钮,确认仍打开默认网页浏览器中的磁盘文件;覆盖中文、空格、# 文件名和无默认浏览器失败提示。 | | -| 编辑模式 | **Vim 模式与命令状态**
vim-mode | ❌ 未实现
— 不适用
`macos/Sources/Lithe/Views/Editor` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/vim`、`windows/tauri/src/features/editor` | Editor | Windows 验证 Normal/Insert/Visual 模式、命令执行和设置持久化;macOS 需要确认产品范围。 | | -| 文本编辑 | **按指定编码重新打开文本文件**
editor-file-encoding-reopen | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift`、`macos/Sources/Lithe/Views/Editor/StandaloneEditorView.swift`、`macos/Sources/Lithe/Application/Features/DocumentFeatureModel.swift`、`macos/Sources/Lithe/Platform/MacOS/FileSystem/MacDocumentEncoding.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/command-palette/components/encoding-picker.tsx`、`windows/tauri/src/features/editor/services/document-encoding-workflow.ts`、`windows/tauri/crates/project/src/document_file.rs` | Editor | 在 macOS 和 Windows 实机验证 UTF-8、UTF-8 BOM 与 GBK/GB18030 的自动识别,并分别验证 Shift JIS、Windows-1252 的指定编码重新打开、编码转换保存、脏文件选择和外部修改保护。 | | -| 文本编辑 | **按指定编码保存文本文件**
editor-file-encoding-save | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift`、`macos/Sources/Lithe/Views/Editor/StandaloneEditorView.swift`、`macos/Sources/Lithe/Application/Features/DocumentFeatureModel.swift`、`macos/Sources/Lithe/Platform/MacOS/FileSystem/MacDocumentEncoding.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/command-palette/components/encoding-picker.tsx`、`windows/tauri/src/features/editor/stores/editor-app.store.ts`、`windows/tauri/crates/project/src/document_file.rs` | Editor | 在 macOS 和 Windows 实机验证 UTF-8、UTF-8 BOM 与 GBK/GB18030 的自动识别,并分别验证 Shift JIS、Windows-1252 的指定编码重新打开、编码转换保存、脏文件选择和外部修改保护。 | | -| 文本编辑 | **中文 properties 与未知扩展名文本的一致打开和恢复**
editor-unicode-text-classification | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Core/Rust/RustTextContentPolicy.swift`、`macos/Tests/LitheTests/TextContentPolicyTests.swift`、`frontend/editor/src/language-contributions.ts` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/file-system/controllers/workspace-session-restore.ts`、`windows/tauri/src/features/file-system/controllers/workspace-session-restore.test.ts`、`frontend/editor/src/language-contributions.ts` | Editor | 双端打开高中文比例的 .properties 和未知扩展名 UTF-8 文件,编辑保存后恢复会话并验证内容及编码;验证 properties 高亮、emoji 跨边界、NUL 和控制字符拒绝、读取错误独立报告,并执行 text-content-v1.json 共享规则样例。 | | - -
- -
-搜索 · 4 个能力点 - -| 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | -| --- | --- | --- | --- | --- | --- | --- | -| 搜索与导航 | **快速打开文件与符号**
search-quick-open | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Search`、`macos/Sources/Lithe/Views/Workbench` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/quick-open` | Search | 使用文件名、路径和符号查询,确认排序、键盘导航和打开位置。 | | -| 搜索与导航 | **项目范围文本搜索**
search-project | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Search`、`macos/Sources/Lithe/Services/Language` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/file-search` | Search | 使用同一项目和查询 fixture 对比命中路径、行号、列号和结果排序。 | | -| 搜索与导航 | **全局命令与设置搜索**
search-global | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Search` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/global-search`、`windows/tauri/src/features/command-palette` | Search | 使用快捷键搜索命令、设置和工作区内容,确认结果来源和跳转行为。 | | -| 搜索与导航 | **项目替换与取消**
search-replace | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Search`、`macos/Sources/Lithe/Services/Language` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/file-search` | Search | 执行替换预览、确认替换和取消操作,确认未保存文件与异常文件不会被静默覆盖。 | | - -
- -
-版本控制 · 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 状态中时关闭;在 Windows 单文件工作区 Diff 中点击折叠条展开省略的未改动代码,确认 Diff 保持打开且每个修改块仍可单独暂存;在 Windows 未暂存单文件 Diff 中点击某个修改块的“回滚”,确认弹出确认框、取消不改文件、确认后只恢复该块且暂存区不变,最后一块回滚后文件从状态中消失时 Diff 关闭;暂存区、历史、分支比较与新增/未跟踪文件不显示回滚按钮。 | | -| Git | **提交历史与图谱**
git-history | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git`、`macos/Sources/LitheGitModule/Services/GitGraphLayoutService.swift`、`macos/Sources/LitheGitModule/Services/GitGraphHeadOrdering.swift`、`macos/Sources/Lithe/Views/Git/GitGraphColor.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git`、`windows/tauri/src/features/git/utils/git-graph-layout.ts`、`windows/tauri/src/features/git/utils/git-graph-colors.ts`、`windows/tauri/src/features/git/utils/git-graph-layout.test.ts`、`windows/tauri/src/features/git/components/log/git-commit-details.tsx`、`windows/tauri/src/features/git/components/log/git-commit-details.test.tsx`、`windows/tauri/src/features/git/hooks/use-git-diff-actions.test.tsx`、`windows/tauri/src/features/git/hooks/use-commit-file-preview.ts`、`windows/tauri/src/features/git/hooks/use-commit-file-preview.test.tsx` | Git | 分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;在 Windows Git Log 选中带多行正文的提交,确认右侧详情在标题下方完整显示正文(保留空行与缩进),复制提交信息和打开提交 Diff 时也包含正文;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端保留各自调色板,不要求 RGB 值一致。 读取提交正文期间切换仓库、重新打开另一提交或关闭面板,确认迟到结果不会打开旧 Diff 或覆盖新请求的加载状态。 Windows 另验证:单击提交后编辑区预览该提交第一个文件的 diff;单击或双击 Commit files 中的文件只显示该文件的 diff(不含提交说明,也不显示文件列表),并复用同一个预览标签;Git Log 刚打开、尚未点击提交时不会自动打开 diff;日期列对与本机同一时区的提交显示相对时间(刚刚、N 分钟前、今天/昨天 + 12 小时制,中文为上午/下午),其他时区或未知时区的提交只显示日期,鼠标移入行时刷新相对时间;作者/日期列可在行内列边缘拖动调整宽度并在重启后保留。 非连续多选提交修改同一路径时,预览保留每个提交的 diff 区段及提交标识;延迟请求返回前取消选择、隐藏 Git Log、切换仓库或卸载工具窗,确认旧请求不会重新打开或激活预览标签。 | | -| Git | **Rebase 与 Stash**
git-rebase-stash | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git` | Git | 执行交互式 Rebase 和 Stash 保存/恢复,确认中断、冲突和继续操作。 | | -| Git | **Worktree 管理**
git-worktrees | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git` | Git | 列出、创建、切换和删除 Worktree,确认路径、分支和安全检查。 | | -| Git | **多仓库引用面板:分组、配色与引用操作**
git-multi-repository-references | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git` | Git | 在多仓库工作区打开 Git Log:确认按仓库分组、仓库配色、非活动仓库分组只读,点其它仓库的引用会切换活动仓库且只加载一次,Pull 弹窗可选择远程分支与策略。 | | -| 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 的计划、依赖排序、执行结果与重试。平台负责真实暂存勾选、确认与结果界面、认证取消和项目生命周期。原生界面实测待平台验收。 | - -
- -
-协作 · 2 个能力点 - -| 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | -| --- | --- | --- | --- | --- | --- | --- | -| GitHub | **Pull Request 列表与详情**
github-prs | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/GitHub`、`macos/Sources/Lithe/Services/GitHub` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/github`、`shared/contracts/application-boundary.md` | GitHub | 使用测试仓库验证 PR 列表、筛选、详情、分支比较和浏览器跳转。 | | -| GitHub | **Review 与评论**
github-reviews-comments | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/GitHub` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/github` | GitHub | 创建、查看和回复 Review/评论,确认权限错误和网络失败不会丢失草稿。 | | - -
- -
-Java · 15 个能力点 - -| 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | -| --- | --- | --- | --- | --- | --- | --- | -| Java/Maven | **JDK 与 Maven 工具链发现及按项目要求自动选择 JDK**
java-runtime-discovery | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Services/Java`、`macos/Sources/Lithe/Views/Run`、`macos/Sources/Lithe/Core/Ports/RuntimeLocator.swift`、`macos/Tests/LitheTests/MavenRuntimeTests.swift`、`rust/lithe-core/src/execution/java_selection.rs` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/maven`、`windows/tauri/src/features/workspace`、`windows/tauri/src-tauri/src/run.rs`、`shared/fixtures/run-configuration/automatic-java-selection.json` | Java / Maven | 两端安装 JDK 8/17/21:项目要求 17 且 JAVA_HOME 为 8 时验证自动选择兼容版本;核对设置、Run、Maven 目标、测试使用同一 JDK。验证显式选择不变、没有兼容版本时回退并提示,以及无要求时的平台默认顺序。 | | -| Java/Maven | **按项目配置 Maven 工具链、settings.xml 与本地仓库**
java-maven-project-settings | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift`、`macos/Sources/Lithe/Services/Java/ProjectRuntimeService.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/settings/components/tabs/maven-settings-panel.tsx`、`windows/tauri/src/features/maven/stores/maven.store.ts`、`windows/tauri/src-tauri/src/maven.rs`、`windows/tauri/src-tauri/src/maven/settings_xml.rs`、`windows/tauri/src/features/maven/components/maven-detected-value.test.tsx`、`windows/tauri/src/features/settings/components/tabs/maven-settings-panel.test.tsx`、`windows/tauri/src/features/run/stores/run.store.ts`、`windows/tauri/src/features/run/stores/run-maven-context.test.ts` | Java / Maven | 在 macOS 项目设置和 Windows Maven 设置页分别切换两个项目,配置 Maven、JDK、settings.xml 与本地仓库,验证保存重开、自动检测展示及项目间隔离;Windows 还需验证写盘失败后可直接重试。 Windows 自定义 settings.xml 后,仓库留空应显示该文件中的目录;验证 XML 实体和 CDATA、继承的项目 JDK/Maven、切换项目丢弃未保存草稿;冷启动 Wrapper 不应触发下载,版本探测卡住时设置窗口保持响应并在超时后清理探测进程;Run 配置显式选择的 Maven/JDK 优先于项目设置,未设置的字段分别继承 Maven 设置;确认主任务与前置任务使用相同工具链,Maven 设置上下文缺失时仍保留显式覆盖,均未配置时交由平台自动发现。 | | -| Java/Maven | **项目根、模块与源码集识别**
java-maven-project | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Services/Java`、`shared/contracts/application-boundary.md` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/maven`、`windows/tauri/src/features/workspace` | Java / Maven | 使用单模块、多模块和非标准源码目录 fixture 对比项目模型。 | | -| Java/Maven | **Profile 与依赖树**
java-maven-profiles-dependencies | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Run`、`macos/Sources/Lithe/Services/Java`、`macos/Sources/LitheExecutionModule/Services/MavenService.swift`、`macos/Sources/Lithe/Platform/MacOS/Persistence/MacMavenDependencyOutputStore.swift`、`rust/lithe-core/src/project/maven_dependency_tree.rs` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/maven`、`windows/tauri/src-tauri/src/maven.rs`、`rust/lithe-core/src/project/maven_dependency_tree.rs` | Java / Maven | 切换 Maven profile 并刷新依赖树,确认模块归属、顺序、版本冲突/重复/依赖管理标注和错误边界;在依赖节点超过 6,000 个的项目中确认依赖树完整加载。 Windows 在 Maven 面板展开「依赖项」应直接加载依赖树,失败时显示宿主返回的真实原因,而不是通用的“无法加载 Maven 依赖”(#970)。 | | -| Java/Maven | **Maven 设置与重新加载实时作用于 Java 语言服务,并展示依赖解析问题**
java-maven-settings-reach-jdt | ✅ 已实现
🔍 待验证
`macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift`、`macos/Sources/LitheExecutionModule/Services/MavenService.swift`、`macos/Sources/Lithe/Models/Java/MavenResolutionProblems.swift`、`macos/Sources/Lithe/Views/Run/MavenView.swift`、`rust/lithe-core/src/lsp/languages/jdt_maven_settings.rs` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/maven/services/java-maven-configuration.ts`、`windows/tauri/src/features/maven/services/reload-maven-workspace.ts`、`windows/tauri/src/features/maven/hooks/use-maven-resolution-problems.ts`、`windows/tauri/src/platform/lsp-core-adapter.ts`、`windows/tauri/src/platform/tauri-core.ts`、`rust/lithe-core/src/lsp/languages/jdt_maven_settings.rs` | Java / Maven | 打开一个依赖尚未下载的 Maven 项目,在 Java 语言服务导入过程中修改 settings.xml、本地仓库或镜像并保存,不点重新加载,确认第三方 import 在导入完成后解析成功;修改 settings.xml 内容(路径不变)后点「重新加载」,确认 Java 语言服务不重启而依赖重新解析;故意写错一个依赖版本,确认 Maven 工具窗口列出 pom.xml 问题、通知可跳转到对应行,修正后问题消失;初始化期间设置不变时点击重新加载,确认导入就绪后仍执行一次强制更新;旧 profile 任务失败或超时期间切换 profile,确认旧请求结束后应用最新选择;保存设置与重新加载交错执行时,已发送的 settings 副本保持可读。 | | -| Java/Maven | **Maven 工具栏图标悬停说明**
java-maven-toolbar-tooltips | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Run/MavenView.swift`、`macos/Sources/Lithe/Views/Workbench/WorkbenchHoverTooltip.swift`、`macos/Tests/LitheTests/WorkbenchHoverTooltipTests.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/maven/components/maven-pane.tsx` | Java / Maven | 在 macOS 和 Windows 的 Maven 工具窗口逐一悬停运行、执行目标、重新加载、跳过测试、折叠和设置图标,确认提示与动作一致、提示框宽度适配文字;再检查禁用按钮和窄窗口中的提示换行与边缘约束。 | | -| Java/Maven | **构建输出与编译诊断**
java-build-diagnostics | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Run`、`macos/Sources/Lithe/Services/Diagnostics` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/maven`、`windows/tauri/src/features/diagnostics`、`windows/tauri/src/features/maven/components/maven-run-pane.tsx`、`windows/tauri/src/features/run/components/run-output-text.tsx` | Java / Maven | 使用成功、编译失败和进程失败构建 fixture,对比诊断位置、输出和退出状态。 Windows Maven 构建输出使用 Geist Mono 显示 ====、==>、=>、!=、>=,确认禁用连字后逐字符显示且复制文本与原始输出一致;检查有输出与空态、ANSI 颜色、自动换行切换及横向滚动。 | | -| Spring / MyBatis | **Spring 配置、Bean 与 Endpoint 索引**
spring-index | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Application/Features`、`macos/Sources/Lithe/Views/Run`、`shared/fixtures/spring` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/spring`、`shared/fixtures/spring` | Java / Spring | 使用 Spring fixture 对比配置、Bean、Endpoint 索引、刷新和失效处理。 | | -| Spring / MyBatis | **Spring 符号导航与代码关联**
spring-navigation | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Application/Features`、`macos/Sources/Lithe/Views/Language` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/spring` | Java / Spring | 从 Bean、配置和 Endpoint 结果跳转到源代码并返回,确认行列号一致。 | | -| Spring / MyBatis | **Spring HTTP 接口筛选与源码跳转**
spring-endpoints-search | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Run/SpringEndpointsView.swift`、`macos/Sources/Lithe/Application/Features/SpringFeatureModel.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/spring/components/spring-endpoints-pane.tsx`、`windows/tauri/src/features/spring/hooks/use-spring-index.ts`、`windows/tauri/src/features/spring/hooks/use-spring-index.test.tsx`、`windows/tauri/src/features/keymaps/commands/view-command-actions.ts` | Java / Spring | 在两端打开 Spring fixture,按路由、控制器、方法名和 HTTP 方法筛选,再点击接口核对源码行列;Windows 使用命令面板打开 Spring Endpoints,验证同项目刷新和失败保留结果、源码目录重命名或删除、全量重扫、扫描中切换或关闭项目、窄面板方法集合截断及超过 200 个接口的滚动与跳转。 | | -| Spring / MyBatis | **MyBatis Mapper/XML 导航**
mybatis-navigation | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Application/Features/MybatisFeatureModel.swift`、`macos/Sources/Lithe/Application/Composition/DocumentFeatureComposition.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/mybatis` | Java / MyBatis | 使用 Mapper/XML fixture 对比索引、导航、文件变更刷新和失效处理。 | | -| 语言服务 | **LSP/JDTLS 启动与工作区准备**
lsp-lifecycle | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Core/Language`、`macos/Sources/Lithe/Services/Language`、`rust/lithe-core/src/lsp/languages/jdt_configuration.rs` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features`、`windows/tauri/src-tauri/src/lsp.rs`、`rust/lithe-core/src/lsp/languages/jdt_configuration.rs`、`shared/fixtures/lsp` | Language Tooling | 在两端启动真实 JDTLS,验证项目准备、重启、超时、取消和资源清理,并确认典型工作流后已安装程序包(macOS app bundle、Windows 安装目录)的文件清单不变。 | | -| 语言服务 | **补全与 Hover**
lsp-completion-hover | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Core/Language`、`shared/fixtures/lsp` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features`、`shared/fixtures/lsp` | Language Tooling | 在相同 Java fixture 中验证补全、Hover、排序、超时和空结果。 | | -| 语言服务 | **诊断与编译错误定位**
lsp-diagnostics | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Core/Language`、`macos/Sources/Lithe/Services/Diagnostics` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/diagnostics`、`shared/fixtures/lsp` | Language Tooling | 制造语法和类型错误,比较诊断等级、消息、行列号和清理行为。 | | -| 语言服务 | **跳转、引用、语义标记与编辑**
lsp-navigation-edits | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Core/Language`、`shared/contracts/rust-core-api.md` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features`、`shared/fixtures/lsp` | Language Tooling | 验证定义、引用、语义标记和 UTF-16 文本编辑的坐标转换。 | | - -
- -
-运行与调试 · 7 个能力点 - -| 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | -| --- | --- | --- | --- | --- | --- | --- | -| 运行配置 | **入口点与运行配置发现**
run-discovery | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Run`、`shared/fixtures/run-configuration` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/run`、`shared/fixtures/run-configuration` | Run | 验证 Spring Boot、Java、Maven、Gradle、npm、Cargo、Go、Python 和 Docker Compose 入口识别;多模块项目把服务工作目录改成模块目录后,服务仍留在列表中并以该目录启动;填写不存在的目录或 ${workspaceFolder} 等变量时,保存被拒绝并提示原因;在 Windows 确认重新扫描服务使用双箭头刷新图标。 | | -| 运行配置 | **保存前同步与工具链解析**
run-save-toolchain | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Run`、`macos/Sources/Lithe/Services/Java` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/run`、`windows/tauri/src/features/maven` | Run | 修改未保存文件后运行,验证同步、JDK/Maven 选择和版本不匹配诊断。 | | -| 运行配置 | **Java main 与测试运行**
run-java-test | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Run`、`shared/fixtures/debug` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/run`、`shared/fixtures/debug`、`windows/tauri/src/features/run/components/run-pane.tsx`、`windows/tauri/src/features/run/components/run-output-text.tsx` | Run | 运行 main、单测试和测试类,验证参数、输出、失败状态和终端策略。 Windows Run 运行输出使用 Geist Mono 显示 ====、==>、=>、!=、>=,确认禁用连字后逐字符显示且复制文本与原始输出一致;检查有输出与空态、ANSI 颜色、自动换行切换及横向滚动。 | | -| 运行配置 | **超长 Java 类路径自动缩短**
run-java-long-classpath | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacJavaLaunchArgumentPreparer.swift`、`macos/Sources/LitheExecutionModule/Services/RunService.swift`、`rust/lithe-core/src/execution/launch_command.rs` | ✅ 已实现
🔍 待验证
`windows/tauri/src-tauri/src/run/launch_arguments.rs`、`rust/lithe-core/src/execution/launch_command.rs`、`rust/lithe-core/src/execution/classpath_jar.rs`、`windows/tauri/src-tauri/src/run.rs` | Run | 使用包含大量依赖的 Java 项目运行 main,确认超长类路径自动写入参数文件、进程可启动且参数文件在退出后清理。 Windows 上分别用 JDK 8 和 JDK 17 运行同一个依赖很多、路径含空格和中文的项目 main:JDK 8 应在临时目录生成只含 META-INF/MANIFEST.MF 的 .classpath.jar 并以 -cp 引用,JDK 17 仍使用 @argfile;两者都能启动,进程退出或启动失败后临时文件被删除,安装目录与 JDK 目录不新增文件。 在慢速网络盘 classpath 准备期间确认工作台可交互;停止或重新运行后,旧准备结果不得启动进程,旧 executionId 的停止请求不得影响新执行。 | | -| 调试器 | **启动调试与断点**
debug-breakpoints | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Debug`、`shared/fixtures/debug` | 🟡 部分实现
🔍 待验证
`windows/tauri/src/features/debugger`、`shared/fixtures/debug` | Debug | 设置、命中、禁用和重新定位断点,确认调试会话生命周期。 | Windows 真实调试产品链路仍在 #466 跟进。 | -| 调试器 | **变量、异常与断开策略**
debug-state | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Debug`、`shared/fixtures/debug` | 🟡 部分实现
🔍 待验证
`windows/tauri/src/features/debugger`、`shared/fixtures/debug` | Debug | 验证变量分页、异常信息、step filters、暂停/继续和 disconnect policy。 | Windows 真实调试产品链路仍在 #466 跟进。 | -| 项目运行 | **Docker / Compose 项目识别与运行**
docker-compose | 🟡 部分实现
🔍 待验证
`macos/Sources/Lithe/Views/Run/RunConfigurationIcon.swift`、`macos/Sources/Lithe/Services/Java` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/docker`、`windows/tauri/src/features/run` | Run / Docker | 使用 Dockerfile 和 Compose fixture 验证识别、命令参数、输出和进程停止;确认 macOS 是否只有入口识别。 | | - -
- -
-工作台 · 13 个能力点 - -| 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | -| --- | --- | --- | --- | --- | --- | --- | -| 终端 | **Shell 发现与配置**
terminal-shell | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Terminal`、`macos/Sources/Lithe/Views/App/SettingsView.swift`、`macos/Sources/Lithe/Services` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/terminal`、`windows/tauri/src/features/settings/components/macos-settings-panels.tsx`、`windows/tauri/src/features/settings/lib/default-shell-options.ts`、`windows/tauri/src-tauri` | Terminal | 验证设置页默认 Shell 选项与终端「新建终端」菜单检测到的 Shell 一致、可选 Git Bash 并生效,以及环境变量、工作目录和不可用 Shell 的提示。 | | -| 终端 | **多会话与标签**
terminal-sessions | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Terminal` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/terminal` | Terminal | 创建多个终端会话并切换、重命名、关闭,确认子进程生命周期和资源清理;Windows 横向标签栏的新建终端与 Shell 配置按钮紧跟最后一个标签,标签溢出时按钮保持可见;标签栏右侧最小化按钮隐藏终端面板且会话继续运行,重新打开时恢复为停靠状态;Windows 打开底部终端面板时默认通栏(与 IDEA 一致):面板位于左右两条活动栏之间并横跨侧边栏与编辑器下方(活动栏保持通高,底部的活动栏按钮位置不上移),侧边栏让出纵向空间,且仍可通过面板菜单切换为编辑器宽度。 | | -| 终端 | **复制粘贴、搜索与调整大小**
terminal-interaction | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Terminal` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/terminal` | Terminal | 验证复制粘贴、终端搜索、调整面板大小和高频输入不丢失。 | | -| 终端 | **Windows Shell 超长命令输入**
terminal-long-command-input | 🧩 平台专属
— 不适用
`macos/Sources/Lithe/Views/Terminal` | ✅ 已实现
🔍 待验证
`windows/tauri/crates/terminal/src/connection.rs`、`windows/tauri/src-tauri/src/terminal.rs` | Terminal | 在 Windows cmd.exe 和 PowerShell 中执行超过 Shell 单行限制的 IDE 多行命令,确认脚本执行、原始命令内容和终端关闭后的临时文件清理。 | 仅在 IDE 后端为 cmd.exe/PowerShell 的超长多行文本创建一次性脚本;不修改系统 Shell 配置。 | -| 本地历史 | **快照列表与 Diff**
history-snapshots | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/History`、`macos/Sources/Lithe/Services` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/local-history` | Local History | 编辑同一文件多次,比较快照时间、内容 Diff 和文件范围。 | | -| 本地历史 | **恢复、删除与持久化**
history-restore | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/History` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/local-history` | Local History | 验证恢复、删除、应用重启后持久化和失败回滚。 | | -| 命令与布局 | **命令面板与全局动作**
command-palette | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift`、`macos/Sources/Lithe/Views/Search/SearchEverywhereView.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/command-palette` | Workbench | 搜索并执行打开面板、运行、Git 和设置命令,确认快捷键和不可用命令状态。 | | -| 命令与布局 | **紧凑菜单栏可在标题栏左侧原位展开完整水平菜单,并在点击外部或按 Escape 后收起,不遮挡工作区**
workbench-compact-menu-bar | 🧩 平台专属
— 不适用
`macos/Sources/Lithe/LitheApp.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/window/components/title-bar/title-bar.tsx`、`windows/tauri/src/features/window/components/title-bar/title-project-menu.tsx`、`windows/tauri/src/features/window/components/window-menu-bar.tsx`、`windows/tauri/src/features/window/hooks/use-compact-menu-bar-dismissal.ts`、`windows/tauri/src/features/window/hooks/use-compact-menu-bar-dismissal.test.tsx` | Workbench | 在 Windows 工作台点击标题栏左端菜单按钮,确认汉堡按钮由“文件”菜单项取代,“文件”项处于选中状态且其下拉菜单立即展开;菜单与标题栏同高,不覆盖编辑区域,项目/分支控件在展开时隐藏。检查常驻品牌标识与下方活动栏图标列对齐且不可选中,并确认项目菜单显示当前项目图标(无自定义图标时显示项目缩写)。点击其他菜单项时菜单条保持展开并执行动作;点击工作区、标题栏空白和右侧控件或按 Escape 后收起;按 Escape 后确认焦点返回菜单入口;窄窗口确认菜单在标题栏内水平滚动且不挤压窗口控件。macOS 保持系统菜单栏行为。 | | -| 命令与布局 | **从工作台 chrome 打开设置:Windows 点击标题栏搜索按钮右侧的齿轮,macOS 通过应用菜单及面板内入口**
workbench-settings-entry | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Models/LitheAction.swift`、`macos/Sources/Lithe/Views/App/SettingsView.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/window/components/title-bar/title-bar.tsx`、`windows/tauri/src/features/layout/components/sidebar/sidebar-pane-selector.tsx`、`windows/tauri/src/features/layout/config/item-order.ts` | Workbench | 在 Windows 打开工作区,确认左侧活动栏底部不再显示设置齿轮,活动栏右键“在活动栏中显示”列表不包含设置项;点击标题栏右上搜索按钮右侧的齿轮打开设置对话框并再次关闭;携带旧版设置文件(活动栏排序或隐藏列表含 settings)启动后确认遗留条目被清理且活动栏显示正常。macOS 通过应用菜单和各面板入口打开设置保持不变。 | | -| 窗口管理 | **标题栏控制窗口最小化、最大化/还原与关闭;命令面板可最小化、最大化及切换全屏**
window-controls | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/LitheApp.swift`、`macos/Sources/Lithe/Views/App/RootView.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/window/components/title-bar/window-controls.tsx`、`windows/tauri/src/features/command-palette/constants/window-actions.tsx`、`windows/tauri/src/features/window/utils/window-actions.ts` | Workbench | 在 macOS 检查系统标题栏控制及全屏入口;在 Windows 验证标题栏最小化、最大化/还原和关闭,以及命令面板的窗口操作,确认图标语义、最大化/还原状态与按钮标签一致,并确认悬停关闭按钮背景贴合窗口右边缘。 | | -| 命令与布局 | **分栏、面板与工具窗布局**
workbench-panes | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Workbench`、`macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift`、`macos/Sources/Lithe/Views/Components/LitheSplitPaneView.swift`、`macos/Sources/Lithe/Services/Workbench/WorkbenchLayoutStore.swift`、`macos/Sources/Lithe/Views/App/RootView.swift`、`macos/Sources/Lithe/LitheApp.swift`、`macos/Sources/Lithe/Theme/LitheTheme.swift`、`macos/Resources/IDEAIcons/expui`、`macos/Resources/IDEAIcons/terminal` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/panes`、`windows/tauri/src/features/layout` | Workbench | 打开、关闭、移动和调整面板,验证布局持久化及高频拖动稳定性;在 macOS 启用背景图后检查项目栏、编辑器、底部及右侧工具窗之间是否仍有清晰的分割区间;将 macOS 左侧工具窗缩至 30pt 并确认目录树始终贴左、不随宽度向右移动、内容不溢出、右上和右下圆角仍可见,关闭并重新打开项目后仍保持窄侧栏及其他布局尺寸,重新展开后布局正常;在 Maven 项目产生构建输出时确认左侧模块按钮可滚动、设置按钮始终完整显示;在亮色主题且有未读通知时确认未打开的铃铛图标清晰可见、打开通知面板后白色图标与蓝色背景成对出现;在深色 Lithe 主题下对照外框渐变、面板对比度、背景图片回退、左右工具窗的 40pt 按钮槽、30pt 选中块及 IDEA Community 图标对比度,悬停工具窗按钮时确认箭头光标,检查项目栏标题颜色和弹窗主按钮主题色;检查 macOS 40pt 顶栏 Logo 与系统窗口按钮垂直居中,项目切换器使用 20pt 项目字母头像和 30pt 悬停块,项目与分支切换块悬停保持箭头光标,红绿灯右侧留白与 IDEA 接近,项目头像、欢迎列表及切换菜单按标准化项目路径共享稳定颜色(超过 9 个项目、移除后重开仍一致),顶栏使用 IDEA Islands 主题项目色并按 85% 混色、600pt 水平和 300pt 垂直范围渐变,深色窗口分隔条四角与底栏缺口无浅色亮斑,缩放窗口及从欢迎页进入工作区再返回后仍对齐。 | | -| 工作台布局 | **活动栏图标的稳定悬停说明**
workbench-activity-tooltips | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Workbench/WorkbenchHoverTooltip.swift`、`macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift`、`macos/Tests/LitheTests/WorkbenchHoverTooltipTests.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/layout/components/sidebar/sidebar-pane-selector.tsx`、`windows/tauri/src/features/layout/components/footer/footer-tab-control.tsx`、`windows/tauri/src/features/layout/components/plugin-activity-rail.tsx` | Workbench | 逐一悬停左上导航、左下工具入口及右侧通知/插件/Maven,快速切换图标并移出活动栏;确认始终只显示当前图标说明,面板更新和滚动后不残留旧提示,禁用入口可显示说明,短文字自适应且提示不超出窗口边缘。 | | -| 工作台布局 | **活动栏贴合窗口外沿**
workbench-activity-rail-window-edge | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/layout/components/main-layout.tsx`、`windows/tauri/src/features/layout/components/plugin-activity-rail.tsx`、`windows/tauri/src/features/layout/components/sidebar/main-sidebar.tsx` | Workbench | 打开工作区,检查左右活动栏与窗口外沿齐平,外侧没有留白、圆角或边框;缩放窗口并切换活动面板后确认布局仍贴边。 | | - -
- -
-数据库 · 4 个能力点 - -| 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | -| --- | --- | --- | --- | --- | --- | --- | -| 数据库工作区 | **连接配置与 Provider**
database-connections | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Database`、`macos/Sources/Lithe/Services` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/database` | Database | 创建、编辑、测试和删除各支持数据库连接,确认凭据和错误边界。 | | -| 数据库工作区 | **Schema、表与列浏览**
database-schema | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Database` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/database` | Database | 展开数据库、Schema、表和列,验证刷新、筛选和空结果展示。 | | -| 数据库工作区 | **SQL 查询、历史与分页**
database-query-history | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Database` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/database` | Database | 执行查询、取消查询、查看 SQL 历史和分页结果,确认长结果不阻塞界面。 | | -| 数据库工作区 | **表数据编辑、CRUD 与导出**
database-crud-export | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Database` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/database` | Database | 验证新增、编辑、删除、事务失败、复制和结果导出。 | | - -
- -
-可观测性 · 4 个能力点 - -| 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | -| --- | --- | --- | --- | --- | --- | --- | -| 诊断与日志 | **工作区诊断与问题面板**
diagnostics-workspace | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Application/Features/DiagnosticsFeatureModel.swift`、`macos/Sources/Lithe/Views/Language/JavaProblemsView.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/diagnostics`、`windows/tauri/src/features/logging`、`windows/tauri/src/ui/dialog.tsx` | Diagnostics | 制造多个来源的错误和警告,验证筛选、排序、定位和清除。 | | -| 诊断与日志 | **诊断包导出与脱敏**
diagnostics-export | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Diagnostics/DiagnosticsExportSheet.swift`、`macos/Sources/Lithe/Services/Diagnostics` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/diagnostics` | Diagnostics | 导出诊断包,确认敏感路径、凭据和大文件被正确脱敏或限制;检查预览说明和文件列表在确认弹窗内完整显示、长内容正常换行且不溢出。 | | -| 通知与资源 | **操作通知、错误提示与自动消失**
workbench-notifications | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Application/Features/WorkbenchNotificationFeatureModel.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/notifications` | Workbench | 触发成功、警告和失败通知,验证队列、关闭、超时和重复通知。 | | -| 通知与资源 | **应用与语言服务内存监控**
memory-monitor | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Services/Monitoring/MemoryUsageMonitor.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/layout/services/memory-api.ts`、`windows/tauri/src/platform/tauri-core.ts` | Workbench | 打开内存指标并观察应用、语言服务和总内存变化,确认采样失败不会阻塞工作台。 | | - -
- -
-设置 · 2 个能力点 - -| 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | -| --- | --- | --- | --- | --- | --- | --- | -| 应用配置 | **应用、项目与运行设置**
application-settings | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/LitheApp.swift`、`macos/Sources/Lithe/Views/App/RootView.swift`、`macos/Sources/Lithe/Views/App/SettingsView.swift`、`macos/Sources/Lithe/Views/App/PluginManagementView.swift`、`macos/Sources/Lithe/Models/Workspace/ProjectSessionManager.swift`、`macos/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift`、`macos/Sources/Lithe/Views/Components/LitheSplitPaneView.swift`、`macos/Sources/Lithe/Views/Workbench/SplitHandleView.swift`、`macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift`、`macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift`、`macos/Tests/LitheTests/SettingsAppearanceContainerTests.swift`、`macos/Tests/LitheTests/SettingsSelectPopupGeometryTests.swift`、`macos/Sources/Lithe/Theme/LitheTheme.swift`、`macos/Tests/LitheTests/PluginManagementPresentationTests.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/settings` | Settings | 修改应用级和项目级设置,验证持久化、迁移、重启恢复和无效值提示;macOS 保持设置窗口打开切换亮色、暗色和跟随系统模式,并改变系统外观,确认标题栏、设置树、插件列表与详情背景和主界面侧边栏使用同一主题色,窗口按钮可用且可拖动;缩小设置窗口确认最小尺寸 900×700,底部操作按钮为 72×28 且边框与输入框一样以 1pt 内描边绘制;拖动设置树与插件列表右侧分隔线,确认设置分类栏不能小于原始宽度、插件列表分隔线可向左缩至 200 pt 或向右加宽,在 900 pt 最小宽度下仍有至少 150 pt 可分配的拖动余量,窗口缩小时设置内容区保留至少 500 pt、插件详情区至少 160 pt,拖动结束后宽度可恢复,悬停及拖动分隔线仅显示左右调整光标而不改变线条颜色;点击设置搜索框后切换分类及点击内容空白区域,确认焦点边框消失;悬停插件条目及设置窗口各子页面的按钮和选择控件时光标保持箭头;从主窗口打开设置后切换到其他应用,确认设置窗口随主窗口置后且不置顶遮挡;在插件页点击 Enable 后点击设置窗口 OK,确认成功时应用并关闭、失败时保留待确认状态且可重试;应用期间确认窗口关闭按钮和常规关闭命令不能关闭窗口,应用结束后恢复关闭能力;A 项目开始应用插件后从 B 项目重新打开共享设置窗口,确认 A 完成后 B 窗口仍打开且未应用草稿保留;待确认时点击 Cancel 或窗口关闭按钮,确认重开后草稿已清除;亮色模式确认分段选择的选中文字清晰可读;检查搜索框、普通及密码输入框、多行编辑框(含获得与失去焦点的边框)、数值步进器、下拉框和分段选择在各设置子页面中的背景、边框、焦点态及操作行为一致,关闭后重开再检查;macOS 切换通用、编辑器、快捷键、项目、运行配置和语言服务等设置页,检查右侧路径标题、分组线、行对齐、说明文字、下拉框、复选框、分段控件与底部操作区;打开短列表和超过十项的下拉框,检查当前项蓝色高亮、无勾号、圆角、紧凑行距、鼠标悬停不跳动、键盘选择与按需滚动;再次点击原下拉按钮应收起弹层,点击另一处下拉框应切换弹层,点击空白处应关闭弹层;缩小可用屏幕高度时检查弹层不遮挡控件;使用辅助功能检查勾选框和分段控件的当前状态。 | | -| 应用配置 | **快捷键查看与自定义**
keymap-customization | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/App/KeyboardShortcutSettingsView.swift`、`macos/Sources/Lithe/Models/Keymap` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/keymaps`、`windows/tauri/src/features/settings/components/macos-settings-panels.tsx`、`windows/tauri/src/features/settings/components/tabs/keyboard-settings.tsx`、`windows/tauri/src/features/settings/components/keyboard-settings-routing.test.tsx` | Settings | macOS 从设置 → 快捷键依次选择 macOS、IntelliJ IDEA Classic、Eclipse,核对运行、调试、查找等操作在界面和实际监听器中的绑定;IDEA Classic 的 Stop Run 应为 Control-F2,Eclipse 的 Resume 应为 F8,继承父 keymap 的命令应使用上游绑定,Find、Replace、GotoImplementation 等显式清空的命令应无绑定;检查分类树初始折叠、全部展开和折叠、分类展开、搜索时自动显示匹配操作、选中行高亮、右侧紧凑键帽及工具栏编辑菜单;通过键帽、工具栏或右键菜单新增、删除与恢复默认,并检查键盘辅助功能;分别修改两套预设的同一命令,切换与重启后确认自定义绑定互不覆盖,并验证旧版单套覆盖迁移到 macOS;按命令名称、ID 和按键搜索,制造冲突并恢复当前预设或应用全部默认;导出再导入 keybindings.json,确认预设与自定义绑定恢复且命令实际执行。Windows 保持现有快捷键编辑、导出和导入验证。 | Windows 已接入现有快捷键编辑器,修复 #718 的占位入口;Linux 前端回归验证不替代 Windows WebView2、原生文件对话框和快捷键实际执行验收。 | - -
- -
-扩展 · 1 个能力点 - -| 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | -| --- | --- | --- | --- | --- | --- | --- | -| 插件与主题 | **插件、语言服务与主题管理**
plugin-extension-management | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/App/PluginManagementView.swift`、`macos/Sources/Lithe/Views/App/SettingsView.swift`、`macos/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift`、`macos/Tests/LitheTests/PluginManagementPresentationTests.swift`、`macos/Sources/Lithe/Platform/MacOS/Plugins` | ✅ 已实现
🔍 待验证
`windows/tauri/src/extensions`、`windows/tauri/src/features/settings`、`windows/tauri/src-tauri/src/language_tools.rs`、`windows/tauri/src/extensions/registry/extension-store-lifecycle.test.ts` | Extensions | macOS 全新安装时打开插件页,确认显示 PHP Support 未安装占位项,并可选择已签名的 PHP 插件包安装;选择非 PHP 包应被拒绝,安装后可搜索并启用、禁用 PHP,确认状态与重启恢复,且页面不显示无恢复路径的卸载操作;同时打开 Maven 面板确认插件内容不被遮挡。Windows 检查扩展管理入口和持久化。 Windows 缺少 Bun 或语言服务器时更新扩展,确认旧扩展的安装、启用和待更新状态保留,错误给出安装依赖后重试的提示;预检期间禁用或卸载扩展,确认返回后不会继续安装或重新启用;手动将 Marksman 等语言服务器安装到 PATH 后重启,确认能识别并启用。 | | - -
- -
-启动与窗口 · 1 个能力点 - -| 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | -| --- | --- | --- | --- | --- | --- | --- | -| 首次启动 | **欢迎页、项目入口与首次启动引导**
onboarding-welcome | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/App/WelcomeView.swift`、`macos/Sources/Lithe/Views/Workspace` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/onboarding`、`windows/tauri/src/features/bootstrap` | Onboarding | 清空首次启动状态,验证打开项目、克隆项目、最近项目和跳过引导;在 macOS 深色与浅色模式检查欢迎页布局、颜色和交互,确认默认及最小内容尺寸均为 800×650、可向屏幕可用区域放大,左下角齿轮菜单采用设置页下拉框配色并可进入设置与检查更新。 | | - -
- -
-发行与更新 · 2 个能力点 - -| 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | -| --- | --- | --- | --- | --- | --- | --- | -| 应用更新 | **检查、下载与安装更新**
updates-check-download | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/App/UpdateControl.swift`、`macos/Sources/Lithe/Views/App/SettingsView.swift`、`macos/Sources/Lithe/Platform/MacOS/Updates` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/layout/components/app-update-control.tsx`、`windows/tauri/src/features/layout/components/app-update-details-dialog.tsx`、`windows/tauri/src/features/settings/components/macos-settings-panels.tsx`、`windows/tauri/src/features/settings/hooks/use-updater.ts`、`windows/tauri/src/features/settings/stores/update.store.test.ts` | Release | 使用 preview 更新清单验证检查、下载、校验、安装和重启;从应用菜单或欢迎页左下角齿轮菜单手动检查到新版本时直接弹出更新详情并可一键安装,设置页检查到新版本时在检查按钮旁显示安装按钮,启动时的自动检查只在标题栏提示。 Windows 未检查、正在检查、跳过版本和失败时不得显示已是最新版;菜单通知明确指 Lithe 主程序,不代表语言扩展状态。 | | -| 应用更新 | **失败重试与回滚**
updates-failure-rollback | 🟡 部分实现
🔍 待验证
`macos/Sources/Lithe/Platform/MacOS/Updates` | ✅ 已实现
🔍 待验证
`docs/releases/windows-updater.md`、`windows/tauri/src/features/settings/hooks/use-updater.ts` | Release | 模拟清单错误、下载失败和安装失败,确认重试、回滚和用户提示。 | macOS 回退更新方案仍在 #734 跟进。 | - -
- -
-社区 · 2 个能力点 - -| 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | -| --- | --- | --- | --- | --- | --- | --- | -| LINUX DO 社区 | **主题列表与详情**
community-feed | ❌ 未实现
— 不适用
`macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift` | ❌ 未实现
— 不适用
`windows/tauri/src/features` | Community | 如果重新引入社区入口,在 macOS 和 Windows 验证 Latest/Top、主题详情、分页和浏览器跳转。 | macOS 的 LINUX DO 插件已退役,当前两端均无社区入口。 | -| LINUX DO 社区 | **社区认证与请求失败处理**
community-auth | ❌ 未实现
— 不适用
`macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift`、`shared/fixtures/community` | ❌ 未实现
— 不适用
`windows/tauri/src/features` | Community | 如果重新引入社区入口,在 macOS 和 Windows 验证认证、过期、限流和网络错误。 | macOS 的 LINUX DO 插件已退役,现存 fixture 不代表产品接入。 | - -
- -
-Windows 专属 · 2 个能力点 - -| 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | -| --- | --- | --- | --- | --- | --- | --- | -| WSL | **WSL 文件与工作区路径**
wsl-paths | 🧩 平台专属
— 不适用
`macos/Sources/Lithe` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/wsl`、`windows/tauri/src/features/file-system` | Windows Platform | Windows 验证本地文件、WSL 文件、发行版识别和工作区打开。 | | -| WSL | **跨 WSL 边界移动与重命名**
wsl-boundaries | 🧩 平台专属
— 不适用
`macos/Sources/Lithe` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/wsl`、`windows/tauri/src/features/file-system` | Windows Platform | Windows 验证本地、同发行版、跨发行版移动/重命名及明确错误提示。 | | - -
- -
-语言支持 · 2 个能力点 - -| 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | -| --- | --- | --- | --- | --- | --- | --- | -| PHP | **PHP 按需安装与插件生命周期**
php-optional-plugin | ✅ 已实现
🔍 待验证
`Plugins/mac/Official/PhpSupport`、`scripts/official-plugin-distribution.mjs`、`macos/Sources/Lithe/Platform/MacOS/Plugins`、`macos/Sources/Lithe/Views/App/PluginManagementView.swift`、`macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift`、`.github/workflows/release-macos.yml` | ✅ 已实现
🔍 待验证
`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 Support | macOS 在干净安装上打开插件管理,确认可从当前发行渠道下载并安装 PHP、失败时可从磁盘导入;安装后重启,在 LSP 界面只控制当前项目开关和状态,验证重装、卸载均在重启后生效且用户工具保留。Windows 从独立 .lithe-extension 文件导入,默认禁用;启用后重启确认能恢复,卸载后重启确认不恢复。 | | -| PHP | **PHP 插件运行与 PHPUnit 测试**
php-run-test | ✅ 已实现
🔍 待验证
`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` | PHP Support | macOS 运行 PHP 文件和单条/整套 PHPUnit;Windows 运行字符串/数组 Composer script 和整套 PHPUnit。禁用后菜单消失、运行进程退出,特殊文件名保持原样。Windows 暂不支持单方法发现。 | | - -
- -
-插件与扩展 · 1 个能力点 - -| 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | -| --- | --- | --- | --- | --- | --- | --- | -| IDE API / MCP | **插件调用已授权项目的环境、Maven 与运行 API**
ide-api-plugins | ✅ 已实现
🔍 待验证
`macos/Sources/LitheModuleAPI/Plugins/IDECapabilities.swift`、`macos/Sources/Lithe/Application/Composition/IdeCapabilityRegistry.swift` | ✅ 已实现
🔍 待验证
`Plugins/win/SDK/ide-capabilities.ts`、`windows/tauri/src/extensions/ui/services/ide-extension-host.ts`、`windows/tauri/src/extensions/ui/services/ide-extension-host.test.ts` | IDE API | 双端插件枚举显式授权的项目,读取环境并调用已允许的操作;撤销项目权限后拒绝调用,Windows 未声明 permissions.ide 的 Worker 无法访问。 | 复用 MCP 的同一应用功能和参数校验,不扩张语言包权限;原生运行验收待完成。 | - -
- -
-Run/Debug · 2 个能力点 - -| 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | -| --- | --- | --- | --- | --- | --- | --- | -| Java 服务更新 | **运行中的 Spring Boot DevTools 服务手动保存并编译更新**
java-service-devtools-update | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Run/RunView.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/run/components/run-pane.tsx` | Run/Debug | 在双端运行 Spring Boot HTTP 服务,修改方法体并更新,检查响应、PID、DevTools 日志与断点暂停状态;覆盖编译失败、JVM 拒绝结构修改、连续点击、停止/重启、依赖路径变化。对比运行前后安装目录清单/哈希。Linux 仅完成共享 Core 和前端测试,原生验证待完成。 | | -| Java 服务更新 | **本地 Java Debug 服务手动编译并热替换代码**
java-service-hot-code-replace | ✅ 已实现
🔍 待验证
`macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/debugger/components/java-service-update.tsx` | Run/Debug | 在双端运行 Spring Boot HTTP 服务,修改方法体并更新,检查响应、PID、DevTools 日志与断点暂停状态;覆盖编译失败、JVM 拒绝结构修改、连续点击、停止/重启、依赖路径变化。对比运行前后安装目录清单/哈希。Linux 仅完成共享 Core 和前端测试,原生验证待完成。 | | - -
- - -## 使用规则 - -1. 功能开发或修复的 PR 必须更新对应能力点的实现状态、验证状态、证据路径和验证方式;如果一项功能包含多个独立用户动作,应拆成多行。 -2. `implementationStatus` 和 `verificationStatus` 分别表达实现程度和运行验证结果;只有实现状态为 `implemented` 且验证状态为 `verified` 才能表示已完成验收。 -3. 代码入口存在但没有真实运行验证时,保留实现状态并将验证状态设为 `pending`;不要把静态盘点写成 `verified`。 -4. 新的共享行为先更新 `shared/contracts/` 和 fixture,再按验证方式完成两端验证后将验证状态推进到 `verified`。 -5. 功能 PR 必须同时包含源数据变更;纯重构如确实没有用户可观察变化,可由 reviewer 添加 `matrix-exempt` label 作为显式例外。 +本地运行 `node scripts/generate-platform-feature-matrix.mjs`,打开 `.artifacts/platform-feature-matrix/index.html`。生成文件不提交到 Git。 diff --git a/docs/development/platform-parity.md b/docs/development/platform-parity.md index fcd7b2530..9f3ffe76a 100644 --- a/docs/development/platform-parity.md +++ b/docs/development/platform-parity.md @@ -2,7 +2,7 @@ ## 先说结论 -macOS 和 Windows 的功能状态以 `shared/platform-feature-matrix.json` 为唯一数据源,`docs/development/platform-parity-matrix.md` 和 `docs/development/platform-parity-matrix.csv` 都是自动生成的阅读视图。每个功能必须同时记录两端的实现状态、验证状态、代码证据、负责人和可执行的验证方式;这样新增 macOS 功能时,PR 就会明确暴露 Windows 的实现程度和运行验证进度。 +macOS 和 Windows 的功能状态以 `shared/platform-feature-matrix/features/.json` 为唯一数据源,HTML、Markdown、CSV 和汇总 JSON 都是 CI 生成的阅读视图,不提交到 Git。每个功能必须同时记录两端的实现状态、验证状态、代码证据、负责人和可执行的验证方式;这样新增 macOS 功能时,PR 就会明确暴露 Windows 的实现程度和运行验证进度。 当前表是基于两端代码入口和共享契约的**初版静态盘点**,不是已经完成所有平台实机验收的最终报告。`implementationStatus` 表示已找到的实现程度,`verificationStatus` 单独记录是否完成运行验证;只有实现状态为 `implemented` 且验证状态为 `verified` 才表示已经验收。 @@ -12,15 +12,15 @@ macOS 和 Windows 的功能状态以 `shared/platform-feature-matrix.json` 为 ## 开发者怎么更新 -1. 新增功能时,先在 `shared/platform-feature-matrix.json` 增加一个稳定的 `id`;一行只描述一个可以单独验收的用户能力,不要把“Git”或“数据库”这样的总模块作为一行。 +1. 新增功能时,先在 `shared/platform-feature-matrix/features/.json` 增加一个稳定的 `id`;一行只描述一个可以单独验收的用户能力,不要把“Git”或“数据库”这样的总模块作为一行。 2. 填写 `area`、`group` 和 `capability` 进行导航,再为 `macos` 和 `windows` 各填 `implementationStatus`、`verificationStatus` 与 `evidence`。代码存在但没有运行时证据时保留实现状态,并将验证状态写成 `pending`;已知 Issue 或范围限制写在可选的 `notes` 字段。 3. 在 `verification` 中写出两端都能执行的验证动作;如果行为刻意只属于一个平台,将实现状态写成 `platform-specific` 并说明原因。 -4. 运行 `node scripts/generate-platform-feature-matrix.mjs` 生成 Markdown 和 CSV 表格,再运行 `./scripts/verify-platform-feature-matrix.sh` 检查证据路径和生成结果。 -5. 功能 PR 必须同时包含源数据变更;不要直接编辑生成的 `docs/development/platform-parity-matrix.md` 或 `docs/development/platform-parity-matrix.csv`。 +4. 运行 `node scripts/generate-platform-feature-matrix.mjs` 生成 `.artifacts/platform-feature-matrix/` 下的阅读视图,再运行 `./scripts/verify-platform-feature-matrix.sh` 检查能力记录和证据路径。 +5. 功能 PR 必须同时包含源数据变更;不要提交生成视图;公共配置或日期变动不能代替能力更新。 ## 状态边界 -实现状态和验证状态是两个独立维度,完整定义以 `shared/platform-feature-matrix.json` 的 `statusDefinitions` 为准。 +实现状态和验证状态是两个独立维度,完整定义以 `shared/platform-feature-matrix/metadata.json` 的 `statusDefinitions` 为准。 - **实现状态**:`implemented`、`partial`、`missing`、`platform-specific`,描述代码入口、产品接入和范围。 - **验证状态**:`verified`、`pending`、`not-applicable`,描述是否按 `verification` 完成真实运行验证。 @@ -28,8 +28,14 @@ macOS 和 Windows 的功能状态以 `shared/platform-feature-matrix.json` 为 ## 查看当前状态 -当前能力数量、各平台缺口和待验证项都只维护在自动生成的 `docs/development/platform-parity-matrix.md` 与 CSV 视图中。本页只保留不会随功能数量变化的规则,避免手写数量和缺口列表与矩阵漂移。 +当前能力数量、各平台缺口和待验证项都只维护在[在线矩阵](https://1lck.github.io/Lithe-IDEA/platform-feature-matrix/)及其下载视图中。本页只保留不会随功能数量变化的规则,避免手写数量和缺口列表与矩阵漂移。 ## CI 接入建议 -`.github/workflows/verify-platform-feature-matrix.yml` 会在每个 Pull Request、`main` 分支推送和手动运行时执行 `./scripts/verify-platform-feature-matrix.sh`。它不会启动应用,只验证清单、证据路径和生成视图没有漂移;Pull Request 还会检查平台实现路径变更是否同时更新矩阵。纯重构如果确实没有用户可观察变化,reviewer 可以添加 `matrix-exempt` label 作为显式例外。Actions 摘要会提供 Markdown 在线查看和 CSV 下载入口,并上传包含 JSON、Markdown、CSV 的可下载 artifact。真正的运行时对齐仍由各平台测试和发布前验证负责。 +`.github/workflows/verify-platform-feature-matrix.yml` 在 PR、`preview` / `main` 推送和手动运行时校验清单、证据路径并生成阅读视图。PR 门禁比较能力 JSON 的实际内容,忽略格式、键顺序和条目顶层 `lastReviewed` 日期变化;公共配置不能满足门禁。条目是否与代码改动对应、运行证据是否充分仍由 reviewer 确认。纯重构可由 reviewer 添加 `matrix-exempt` 标签,增删标签都会重新执行门禁。 + +Actions 摘要提供当前版本的 HTML、Markdown、CSV、JSON 下载附件。现有 Pages 工作流在 `preview` 合并后把矩阵发布到 Agent Notes 站点的 `platform-feature-matrix/` 子目录,保持看板首页不变;页面显示提交 SHA。历史 PR 请下载对应 Actions artifact(保留 14 天),不要用在线 preview 页面作为 PR 验证结果。本地也可重新生成历史版本视图。 + +## 旧分支迁移 + +迁移前的 PR 先记录自己相对原基线在总 JSON 中的能力改动,更新到最新 `preview` 后,将这些改动逐项应用到 `features/.json`。不要恢复已删除的总 JSON 或生成 CSV,也不要用旧总表覆盖最新条目。解决同一能力上的分歧后运行校验和生成命令;其他能力的数据保持最新基线。 diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 355fc7a60..9b58b5600 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -1154,12 +1154,19 @@ "Search models" = "Search models"; "No matching models" = "No matching models"; "Thinking level" = "Thinking level"; +"Responding…" = "Responding…"; +"Plan %lld/%lld" = "Plan %lld/%lld"; "Speed" = "Speed"; "Fast" = "Fast"; "Approval mode" = "Approval mode"; "Ask for approval" = "Ask for approval"; "Approve for me" = "Approve for me"; "Full access" = "Full access"; +"Always ask before making changes" = "Ask before making changes or running actions that require approval."; +"Automatically accept all file edits" = "Automatically approve file edits; other actions follow permission rules."; +"Create a plan before making changes" = "Read and plan without modifying files."; +"Claude handles permission decisions" = "Claude evaluates and approves actions automatically."; +"Accepts all permissions" = "Skip routine approval prompts; environment and managed policies still apply."; "Always ask to edit external files and use the internet" = "Always ask to edit external files and use the internet"; "Only ask for actions detected as potentially unsafe" = "Only ask for actions detected as potentially unsafe"; "Unrestricted access to the internet and any file on your computer" = "Unrestricted access to the internet and any file on your computer"; @@ -1281,6 +1288,7 @@ "Repository needs attention" = "Repository needs attention"; "Codex subscription" = "Codex subscription"; +"Loading session settings…" = "Loading session settings…"; "Use your local ChatGPT account. No API key or URL is needed." = "Use your local ChatGPT account. No API key or URL is needed."; @@ -1313,6 +1321,14 @@ "Could not open HTML in browser" = "Could not open HTML in browser"; "Choose a default web browser in System Settings, then retry." = "Choose a default web browser in System Settings, then retry."; +"Paste" = "Paste"; +"Copied files" = "Copied files"; +"Move %lld items to Trash?" = "Move %lld items to Trash?"; +"Pasted files" = "Pasted files"; +"Copy Files" = "Copy"; +"Choose a directory inside the project" = "Choose a directory inside the project"; +"Cannot copy a directory into itself" = "Cannot copy a directory into itself"; +"Stopped moving the remaining items to Trash" = "Stopped moving the remaining items to Trash"; "AI tool connections (MCP)" = "AI tool connections (MCP)"; "Authorize this project to expose environment, Maven, run configurations and output to connected agents. Output may contain sensitive project data. Access ends when this project or Lithe closes." = "Authorize this project to expose environment, Maven, run configurations and output to connected agents. Output may contain sensitive project data. Access ends when this project or Lithe closes."; "Allow project environment and Maven configuration changes" = "Allow project environment and Maven configuration changes"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 2e8b7b013..112c6b06d 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -2242,6 +2242,7 @@ "Stop" = "停止"; "Deny" = "拒绝"; "Thinking…" = "思考中…"; +"Responding…" = "回复中…"; "Loading conversation…" = "正在加载对话…"; "Starting the Agent…" = "正在启动 Agent…"; "The Agent process starts when this panel opens." = "打开此面板时才会启动 Agent 进程。"; @@ -2259,6 +2260,26 @@ "Completed" = "已完成"; "Interrupted" = "已中断"; +/* Agent reasoning, plan and commands */ +"Thinking" = "思考"; +"Thinking process" = "思考过程"; +"Hide thinking" = "收起思考"; +"Show thinking" = "展开思考"; +"Plan %lld/%lld" = "计划 %lld/%lld"; +"Hide plan" = "收起计划"; +"Show plan" = "展开计划"; +"Agent commands" = "Agent 命令"; +"Tool call" = "工具调用"; +"Allow the Agent to continue?" = "允许 Agent 继续吗?"; +"The Agent request failed." = "Agent 请求失败。"; +"The response stopped at the model's token limit." = "回复已达到模型的 token 上限,已停止。"; +"The Agent stopped after reaching its request limit for this turn." = "Agent 已达到本轮请求次数上限,已停止。"; +"The Agent declined to continue." = "Agent 拒绝继续。"; +"Content" = "内容"; +"Unsupported content" = "不支持的内容"; +"Tool" = "工具"; +"You" = "你"; + /* Agent panel settings */ "Agent conversation" = "Agent 对话"; "Enable Agent conversation" = "启用 Agent 对话"; @@ -2399,6 +2420,11 @@ "Ask for approval" = "请求批准"; "Approve for me" = "替我审批"; "Full access" = "完全访问"; +"Always ask before making changes" = "修改文件或执行需要权限的操作前请求你的批准。"; +"Automatically accept all file edits" = "自动批准文件编辑,其他操作仍按权限规则处理。"; +"Create a plan before making changes" = "阅读和分析后制定计划,不修改文件。"; +"Claude handles permission decisions" = "由 Claude 自动评估并审批操作。"; +"Accepts all permissions" = "跳过常规权限审批,仍受环境和管理策略限制。"; "Always ask to edit external files and use the internet" = "修改工作区外文件或访问网络时先请求批准。"; "Only ask for actions detected as potentially unsafe" = "自动审批,仅在检测到潜在风险时请求你的确认。"; "Unrestricted access to the internet and any file on your computer" = "允许访问网络和电脑上的所有文件。"; @@ -2518,6 +2544,7 @@ "Repository needs attention" = "仓库需要处理"; "Codex subscription" = "Codex 官方订阅(本机账号)"; +"Loading session settings…" = "正在加载会话设置…"; "Use your local ChatGPT account. No API key or URL is needed." = "使用本机已登录的 ChatGPT 账号,无需 API Key 或 URL。"; @@ -2552,6 +2579,15 @@ "Could not open HTML in browser" = "无法在浏览器中打开 HTML"; "Choose a default web browser in System Settings, then retry." = "请在系统设置中选择默认网页浏览器,然后重试。"; + +"Paste" = "粘贴"; +"Copied files" = "已拷贝文件"; +"Move %lld items to Trash?" = "将 %lld 个项目移到废纸篓?"; +"Pasted files" = "已粘贴文件"; +"Copy Files" = "拷贝"; +"Choose a directory inside the project" = "请选择项目内的目录"; +"Cannot copy a directory into itself" = "不能将目录复制到其自身或子目录中"; +"Stopped moving the remaining items to Trash" = "已停止将剩余项目移到废纸篓"; "%llds" = "%lld秒"; "%lldm %llds" = "%lld分 %lld秒"; "%lldh %lldm %llds" = "%lld小时 %lld分 %lld秒"; diff --git a/macos/Sources/Lithe/Core/Ports/PlatformUI.swift b/macos/Sources/Lithe/Core/Ports/PlatformUI.swift index 364b0c4d1..accaca7d6 100644 --- a/macos/Sources/Lithe/Core/Ports/PlatformUI.swift +++ b/macos/Sources/Lithe/Core/Ports/PlatformUI.swift @@ -11,6 +11,8 @@ protocol PlatformUI: AnyObject { func open(_ url: URL) func openHTMLInBrowser(_ url: URL) async throws func copyToClipboard(_ value: String) + func copyFilesToClipboard(_ urls: [URL]) -> Bool + func fileURLsFromClipboard() -> [URL] func markdownImageFromClipboard() -> MarkdownImageSource? func startAccessingProject(_ url: URL) -> Bool func stopAccessingProject(_ url: URL) @@ -21,6 +23,9 @@ extension PlatformUI { throw CocoaError(.featureUnsupported) } + func copyFilesToClipboard(_ urls: [URL]) -> Bool { false } + func fileURLsFromClipboard() -> [URL] { [] } + func activateApplication() {} func startAccessingProject(_ url: URL) -> Bool { false } func stopAccessingProject(_ url: URL) {} diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 47bfd49f7..ecad84c19 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -1046,6 +1046,14 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { await workspaceFeature.duplicateProjectItem(at: sourceURL) } + func duplicateProjectItems(_ urls: [URL]) async { + await workspaceFeature.duplicateProjectItems(urls) + } + + func requestDeleteProjectItems(_ urls: [URL]) { + workspaceFeature.requestDeleteProjectItems(urls) + } + func requestDeleteProjectItem(at url: URL, isDirectory: Bool) { workspaceFeature.requestDeleteProjectItem(at: url, isDirectory: isDirectory) } @@ -1062,6 +1070,14 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { platformUI.revealInFileBrowser(url) } + func copyProjectItems(_ urls: [URL]) { + if platformUI.copyFilesToClipboard(urls) { showNotification("Copied files") } + } + + func pasteProjectItems(in directory: URL) async { + await workspaceFeature.pasteProjectItems(platformUI.fileURLsFromClipboard(), in: directory) + } + func copyProjectItemPath(_ url: URL, relative: Bool) { let relativeValue = relativePath(for: url) let value = relative ? (relativeValue.isEmpty ? "." : relativeValue) : url.path @@ -1385,9 +1401,9 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { Task { await gitFeature.finishToggleStaging(change, staged: staged) } } - func setStaging(_ changes: [GitChange], staged: Bool) { + func setStaging(_ changes: [GitChange], staged: Bool, includeWorkingTreeChanges: Bool = false) { guard let gitFeature = gitFeatureIfActive else { return } - let pendingChanges = gitFeature.beginSetStaging(changes, staged: staged) + let pendingChanges = gitFeature.beginSetStaging(changes, staged: staged, includeWorkingTreeChanges: includeWorkingTreeChanges) Task { await gitFeature.finishSetStaging(pendingChanges, staged: staged) } } diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift index ca22fcf71..83d43611e 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift @@ -1,6 +1,8 @@ +import CryptoKit import Foundation import LitheApplicationKernel import LitheModuleAPI +import LithePluginPackageSigning import Security protocol PluginPackageSignatureVerifying { @@ -20,6 +22,9 @@ enum PluginPackageStoreError: Error, Equatable, LocalizedError { case unsignedCode(URL) case invalidCodeSignature(URL) case signingTeamMismatch + case missingPackageSignature + case invalidPackageSignature(String) + case officialSignatureRequirementMismatch(PluginID) case retiredPlugin(PluginID) case invalidInstalledPlugin(PluginID?, String) @@ -37,6 +42,10 @@ enum PluginPackageStoreError: Error, Equatable, LocalizedError { case .unsignedCode(let url): "Plugin code is not signed: \(url.lastPathComponent)." case .invalidCodeSignature(let url): "Plugin signature is invalid: \(url.lastPathComponent)." case .signingTeamMismatch: "Plugin and host application signing teams do not match." + case .missingPackageSignature: "The official plugin package signature is missing." + case .invalidPackageSignature(let detail): "The official plugin package signature is invalid: \(detail)" + case .officialSignatureRequirementMismatch(let id): + "Official plugin \(id) declares a signature policy that does not match the host trust policy." case .retiredPlugin(let id): "Plugin \(id) has been removed from Lithe. Uninstall the old package." case .invalidInstalledPlugin(let id, let message): if let id { @@ -177,6 +186,7 @@ final class MacPluginPackageStore { manifest.version == record.activeVersion else { throw PluginPackageStoreError.manifestDoesNotMatchInstallation } + try validateOfficialTrustPolicy(manifest) _ = try ValidatedPluginCatalog(manifests: [manifest], hostVersion: hostVersion) try verifier.verify(packageAt: packageURL, manifest: manifest) try MacPluginLanguageServerPackageValidator.validate( @@ -235,6 +245,7 @@ final class MacPluginPackageStore { guard packageURL.lastPathComponent == manifest.id.rawValue else { throw PluginPackageStoreError.manifestDoesNotMatchInstallation } + try validateOfficialTrustPolicy(manifest) _ = try ValidatedPluginCatalog( manifests: installed.map(\.manifest) + [manifest], hostVersion: hostVersion @@ -282,6 +293,7 @@ final class MacPluginPackageStore { guard !Self.retiredPluginIDs.contains(sourceManifest.id) else { throw PluginPackageStoreError.retiredPlugin(sourceManifest.id) } + try validateOfficialTrustPolicy(sourceManifest) _ = try ValidatedPluginCatalog(manifests: [sourceManifest], hostVersion: hostVersion) try validatePathComponent(sourceManifest.id.rawValue) @@ -296,6 +308,7 @@ final class MacPluginPackageStore { guard manifest == sourceManifest else { throw PluginPackageStoreError.manifestDoesNotMatchInstallation } + try validateOfficialTrustPolicy(manifest) try verifier.verify(packageAt: stagedURL, manifest: manifest) do { try MacPluginLanguageServerPackageValidator.validate( @@ -385,6 +398,7 @@ final class MacPluginPackageStore { guard manifest.id == pluginID, manifest.version == previousVersion else { throw PluginPackageStoreError.manifestDoesNotMatchInstallation } + try validateOfficialTrustPolicy(manifest) try verifier.verify(packageAt: previousPackageURL, manifest: manifest) try MacPluginLanguageServerPackageValidator.validate( packageAt: previousPackageURL, @@ -459,6 +473,15 @@ final class MacPluginPackageStore { ), to: pluginDirectory.appendingPathComponent("installation.json")) } + private func validateOfficialTrustPolicy(_ manifest: PluginManifest) throws { + guard let trustedManifest = OfficialPluginCatalog.manifests.first(where: { + $0.id == manifest.id + }) else { return } + guard manifest.vendor.signatureRequirement == trustedManifest.vendor.signatureRequirement else { + throw PluginPackageStoreError.officialSignatureRequirementMismatch(manifest.id) + } + } + private func loadManifest(at packageURL: URL) throws -> PluginManifest { let values = try packageURL.resourceValues(forKeys: [.isDirectoryKey]) guard values.isDirectory == true else { @@ -532,6 +555,13 @@ struct MacOfficialPluginSignatureVerifier: PluginPackageSignatureVerifying { guard SecStaticCodeCheckValidity(pluginCode, validationFlags, nil) == errSecSuccess else { throw PluginPackageStoreError.invalidCodeSignature(pluginBundleURL) } + let signatureRequirement = OfficialPluginCatalog.manifests.first(where: { + $0.id == manifest.id + })?.vendor.signatureRequirement ?? manifest.vendor.signatureRequirement + if signatureRequirement == .publisherPackage { + try verifyPublisherPackageSignature(packageAt: packageURL, manifest: manifest) + return + } let pluginTeam = try teamIdentifier(for: pluginCode) let hostTeam = try teamIdentifier(for: hostCode) if let pluginTeam, let hostTeam { @@ -548,6 +578,37 @@ struct MacOfficialPluginSignatureVerifier: PluginPackageSignatureVerifying { } } + private func verifyPublisherPackageSignature( + packageAt packageURL: URL, + manifest: PluginManifest + ) throws { + let signatureURL = packageURL.appendingPathComponent(PluginPackageSignature.signatureFileName) + guard FileManager.default.fileExists(atPath: signatureURL.path) else { + throw PluginPackageStoreError.missingPackageSignature + } + let document: PluginPackageSignature.Document + do { + document = try PluginPackageSignature.read(from: packageURL) + } catch { + throw PluginPackageStoreError.invalidPackageSignature("The signature document could not be decoded.") + } + guard let publicKeyData = Data(base64Encoded: PluginPackageSignature.publisherPublicKeyBase64), + let publicKey = try? Curve25519.Signing.PublicKey(rawRepresentation: publicKeyData) else { + throw PluginPackageStoreError.invalidPackageSignature("The embedded public key is invalid.") + } + do { + try PluginPackageSignature.verify( + packageAt: packageURL, + pluginID: manifest.id.rawValue, + pluginVersion: manifest.version.description, + document: document, + publicKey: publicKey + ) + } catch { + throw PluginPackageStoreError.invalidPackageSignature(error.localizedDescription) + } + } + private func staticCode(at url: URL) throws -> SecStaticCode { var code: SecStaticCode? guard SecStaticCodeCreateWithPath(url as CFURL, [], &code) == errSecSuccess, diff --git a/macos/Sources/Lithe/Platform/MacOS/UI/MacFileClipboard.swift b/macos/Sources/Lithe/Platform/MacOS/UI/MacFileClipboard.swift new file mode 100644 index 000000000..ff91d7baa --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/UI/MacFileClipboard.swift @@ -0,0 +1,18 @@ +import AppKit +import Foundation + +/// File URLs use the native pasteboard format shared with Finder. +@MainActor +enum MacFileClipboard { + static func write(_ urls: [URL], to pasteboard: NSPasteboard) -> Bool { + guard !urls.isEmpty else { return false } + pasteboard.clearContents() + return pasteboard.writeObjects(urls.map { $0 as NSURL }) + } + + static func read(from pasteboard: NSPasteboard) -> [URL] { + (pasteboard.readObjects( + forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true] + ) as? [URL]) ?? [] + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/UI/MacPlatformUI.swift b/macos/Sources/Lithe/Platform/MacOS/UI/MacPlatformUI.swift index cf7498d85..6fb54dd8a 100644 --- a/macos/Sources/Lithe/Platform/MacOS/UI/MacPlatformUI.swift +++ b/macos/Sources/Lithe/Platform/MacOS/UI/MacPlatformUI.swift @@ -46,6 +46,14 @@ final class MacPlatformUI: PlatformUI { NSPasteboard.general.setString(value, forType: .string) } + func copyFilesToClipboard(_ urls: [URL]) -> Bool { + MacFileClipboard.write(urls, to: .general) + } + + func fileURLsFromClipboard() -> [URL] { + MacFileClipboard.read(from: .general) + } + func markdownImageFromClipboard() -> MarkdownImageSource? { MarkdownClipboardImageReader.read(from: .general) } diff --git a/macos/Sources/Lithe/Platform/MacOS/UI/ProjectTreeKeyboardCommands.swift b/macos/Sources/Lithe/Platform/MacOS/UI/ProjectTreeKeyboardCommands.swift new file mode 100644 index 000000000..3504f2f56 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/UI/ProjectTreeKeyboardCommands.swift @@ -0,0 +1,121 @@ +import AppKit +import SwiftUI + +/// Routes clipboard shortcuts only after a pointer interaction inside the tree. +/// A click elsewhere, or a keyboard focus change after the click, returns +/// keyboard ownership to the focused view. +struct ProjectTreeKeyboardCommands: NSViewRepresentable { + let copy: () -> Void + let paste: () -> Void + let selectAll: () -> Void + + func makeNSView(context: Context) -> ProjectTreeKeyboardCommandView { + let view = ProjectTreeKeyboardCommandView() + updateNSView(view, context: context) + return view + } + + func updateNSView(_ view: ProjectTreeKeyboardCommandView, context: Context) { + view.copyItems = copy + view.pasteItems = paste + view.selectAllItems = selectAll + } + + static func dismantleNSView(_ view: ProjectTreeKeyboardCommandView, coordinator: ()) { + view.removeMonitor() + } +} + +final class ProjectTreeKeyboardCommandView: NSView { + var copyItems: (() -> Void)? + var pasteItems: (() -> Void)? + var selectAllItems: (() -> Void)? + private var monitor: Any? + private var ownsKeyboard = false + // The tree has no focusable view, and opening a file may move focus to the + // editor asynchronously. The responder seen by the first key after a tree + // click is therefore the baseline; any later change came from the keyboard. + private var hasKeyboardResponder = false + private weak var keyboardResponder: NSResponder? + private weak var responderAtClick: NSResponder? + + override func hitTest(_ point: NSPoint) -> NSView? { nil } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + removeMonitor() + guard window != nil else { return } + monitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown, .keyDown]) { [weak self] event in + Self.monitorResult(for: event, view: self) + } + } + + /// The local monitor's return value: `nil` stops AppKit from also sending a + /// consumed shortcut to the editor. `view?.handle(event) ?? event` would + /// flatten that `nil` back into the event and deliver it twice. + static func monitorResult(for event: NSEvent, view: ProjectTreeKeyboardCommandView?) -> NSEvent? { + guard let view else { return event } + return view.handle(event) + } + + /// Returns `nil` when the tree consumed the event. + func handle(_ event: NSEvent) -> NSEvent? { + guard let window else { return event } + guard (event.window ?? NSApp.keyWindow) === window else { + releaseKeyboard() + return event + } + if event.type != .keyDown { + let point = event.window == nil + ? window.convertPoint(fromScreen: NSEvent.mouseLocation) + : event.locationInWindow + releaseKeyboard() + ownsKeyboard = !isHiddenOrHasHiddenAncestor && bounds.contains(convert(point, from: nil)) + responderAtClick = Self.focusOwner(window.firstResponder) + return event + } + guard ownsKeyboard, !isHiddenOrHasHiddenAncestor else { return event } + let responder = Self.focusOwner(window.firstResponder) + // A text field focused after the tree click, such as Search Everywhere, + // keeps standard text shortcuts. Some panels open from modifier-only + // gestures, so their field editor can be the first key responder. + if responder is NSTextField, responder !== responderAtClick { + releaseKeyboard() + return event + } + if !hasKeyboardResponder { + hasKeyboardResponder = true + keyboardResponder = responder + } else if keyboardResponder !== responder { + releaseKeyboard() + return event + } + guard event.modifierFlags.intersection([.command, .control, .option, .shift]) == .command else { return event } + switch event.charactersIgnoringModifiers?.lowercased() { + case "c": copyItems?() + case "v": pasteItems?() + case "a": selectAllItems?() + default: return event + } + return nil + } + + func removeMonitor() { + if let monitor { NSEvent.removeMonitor(monitor) } + monitor = nil + releaseKeyboard() + } + + /// Text fields in one window share a field editor; compare the edited field. + private static func focusOwner(_ responder: NSResponder?) -> NSResponder? { + guard let editor = responder as? NSTextView, editor.isFieldEditor else { return responder } + return editor.delegate as? NSTextField ?? editor + } + + private func releaseKeyboard() { + ownsKeyboard = false + hasKeyboardResponder = false + keyboardResponder = nil + responderAtClick = nil + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/UI/ProjectTreeModifiedClick.swift b/macos/Sources/Lithe/Platform/MacOS/UI/ProjectTreeModifiedClick.swift new file mode 100644 index 000000000..4437765b6 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/UI/ProjectTreeModifiedClick.swift @@ -0,0 +1,44 @@ +import AppKit +import SwiftUI + +/// Captures ⌘/Shift clicks on a tree row and reads their modifiers from the +/// mouse-down event itself. A SwiftUI Button action runs after that event, when +/// NSApp.currentEvent no longer describes the click. +struct ProjectTreeModifiedClick: NSViewRepresentable { + let select: (NSEvent.ModifierFlags) -> Void + + func makeNSView(context: Context) -> ProjectTreeModifiedClickView { + let view = ProjectTreeModifiedClickView() + view.select = select + return view + } + + func updateNSView(_ view: ProjectTreeModifiedClickView, context: Context) { + view.select = select + } +} + +final class ProjectTreeModifiedClickView: NSView { + var select: ((NSEvent.ModifierFlags) -> Void)? + /// The event AppKit is dispatching. Tests substitute the synthesized event, + /// because pulling it through NSApp's event queue ends the test process. + var currentEvent: () -> NSEvent? = { NSApp.currentEvent } + + /// Plain clicks stay with the Button, and Control-click stays the macOS + /// secondary click handled by the context menu. + static func captures(_ event: NSEvent) -> Bool { + event.type == .leftMouseDown + && !event.modifierFlags.contains(.control) + && !event.modifierFlags.intersection([.shift, .command]).isEmpty + } + + override func hitTest(_ point: NSPoint) -> NSView? { + guard let event = currentEvent(), Self.captures(event) else { return nil } + return super.hitTest(point) + } + + override func mouseDown(with event: NSEvent) { + guard Self.captures(event) else { return } + select?(event.modifierFlags) + } +} diff --git a/macos/Sources/Lithe/Views/Agent/AgentCommandCompletion.swift b/macos/Sources/Lithe/Views/Agent/AgentCommandCompletion.swift new file mode 100644 index 000000000..6a2ab49d6 --- /dev/null +++ b/macos/Sources/Lithe/Views/Agent/AgentCommandCompletion.swift @@ -0,0 +1,54 @@ +import LitheAgentConversationModule + +/// View-local command interaction shared by Return, Esc, arrow keys and Tab. +/// Completing a command edits the draft; only a send result starts a prompt. +struct AgentCommandCompletion { + enum Key { case submit, escape, up, down, tab } + enum Result { case ignored, handled, send, cancel } + + var draft = "" { + didSet { + if draft != oldValue { + highlightedIndex = 0 + dismissedDraft = nil + } + } + } + private(set) var highlightedIndex = 0 + private var dismissedDraft: String? + + func suggestions(in commands: [AgentCommand]) -> [AgentCommand]? { + dismissedDraft == draft ? nil : AgentCommand.suggestions(for: draft, in: commands) + } + + mutating func complete(_ command: AgentCommand) { + draft = command.invocation + " " + dismissedDraft = nil + } + + mutating func handle(_ key: Key, commands: [AgentCommand], isResponding: Bool) -> Result { + let suggestions = suggestions(in: commands) + switch key { + case .escape: + if suggestions != nil { + dismissedDraft = draft + return .handled + } + return isResponding ? .cancel : .ignored + case .submit: + guard let suggestions, !suggestions.isEmpty, + !suggestions.contains(where: { $0.invocation == draft }) else { return .send } + complete(suggestions[min(highlightedIndex, suggestions.count - 1)]) + return .handled + case .up, .down, .tab: + guard let suggestions, !suggestions.isEmpty else { return .ignored } + if key == .tab { + complete(suggestions[min(highlightedIndex, suggestions.count - 1)]) + } else { + let offset = key == .up ? -1 : 1 + highlightedIndex = (min(highlightedIndex, suggestions.count - 1) + offset + suggestions.count) % suggestions.count + } + return .handled + } + } +} diff --git a/macos/Sources/Lithe/Views/Agent/AgentComposerContent.swift b/macos/Sources/Lithe/Views/Agent/AgentComposerContent.swift new file mode 100644 index 000000000..b91083509 --- /dev/null +++ b/macos/Sources/Lithe/Views/Agent/AgentComposerContent.swift @@ -0,0 +1,50 @@ +import SwiftUI +import LitheAgentConversationModule + +/// Fixed strips and the flexible editor use the same sizes when computing the +/// split minimum. Commands never increase that minimum; attachments do. +enum AgentComposerMetrics { + static let contextHeight: CGFloat = 30 + static let toolbarHeight: CGFloat = 38 + static let fileHeight: CGFloat = 34 + static let writingLineHeight: CGFloat = 36 + static let bottomInset: CGFloat = 8 + static let splitTopInset: CGFloat = 10 + + static func minimumHeight(hasFiles: Bool) -> CGFloat { + contextHeight + toolbarHeight + writingLineHeight + bottomInset + splitTopInset + (hasFiles ? fileHeight : 0) + } +} + +struct AgentComposerMinimumHeightKey: PreferenceKey { + static let defaultValue = AgentComposerMetrics.minimumHeight(hasFiles: false) + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = max(value, nextValue()) } +} + +/// The production composer layout, also hosted in native regression tests. +struct AgentComposerContent: View { + let files: [AgentFileReference] + let commands: [AgentCommand]? + let highlightedIndex: Int + let onSelect: (AgentCommand) -> Void + let onRemoveFile: (String) -> Void + let onFocus: () -> Void + @ViewBuilder let context: () -> Context + @ViewBuilder let editor: () -> Editor + @ViewBuilder let toolbar: () -> Toolbar + + var body: some View { + VStack(spacing: 0) { + context() + if !files.isEmpty { AgentFileReferenceList(files: files, onRemove: onRemoveFile) } + AgentComposerDraftArea(commands: commands, highlightedIndex: highlightedIndex, onSelect: onSelect) { + ScrollView { editor() } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .contentShape(Rectangle()) + .onTapGesture(perform: onFocus) + } + toolbar() + } + .preference(key: AgentComposerMinimumHeightKey.self, value: AgentComposerMetrics.minimumHeight(hasFiles: !files.isEmpty)) + } +} diff --git a/macos/Sources/Lithe/Views/Agent/AgentComposerDraftArea.swift b/macos/Sources/Lithe/Views/Agent/AgentComposerDraftArea.swift new file mode 100644 index 000000000..6044f9735 --- /dev/null +++ b/macos/Sources/Lithe/Views/Agent/AgentComposerDraftArea.swift @@ -0,0 +1,81 @@ +import SwiftUI +import LitheAgentConversationModule + +/// Reserves a writing line before allocating space to command suggestions. +struct AgentComposerDraftArea: View { + let commands: [AgentCommand]? + let highlightedIndex: Int + let onSelect: (AgentCommand) -> Void + @ViewBuilder let editor: () -> Editor + + var body: some View { + GeometryReader { geometry in + let listHeight = max(0, geometry.size.height - AgentComposerMetrics.writingLineHeight) + let showsInline = listHeight >= AgentCommandSuggestionList.minimumHeight + VStack(spacing: 0) { + if let commands, showsInline { + suggestions(commands, maximumHeight: min(AgentCommandSuggestionList.defaultMaximumHeight, listHeight - 2)) + } + editor() + .frame(minHeight: AgentComposerMetrics.writingLineHeight, maxHeight: .infinity, alignment: .topLeading) + } + .anchorPreference(key: AgentFloatingCommandsKey.self, value: .bounds) { bounds in + guard let commands, !showsInline else { return nil } + return AgentFloatingCommands(bounds: bounds, + list: suggestions(commands, maximumHeight: AgentCommandSuggestionList.defaultMaximumHeight)) + } + } + .zIndex(1) + } + + private func suggestions(_ commands: [AgentCommand], maximumHeight: CGFloat) -> AgentCommandSuggestionList { + AgentCommandSuggestionList( + commands: commands, + highlightedIndex: min(highlightedIndex, max(0, commands.count - 1)), + onSelect: onSelect, + maximumHeight: maximumHeight + ) + } +} + +private struct AgentFloatingCommands { + let bounds: Anchor + let list: AgentCommandSuggestionList +} + +private struct AgentFloatingCommandsKey: PreferenceKey { + static var defaultValue: AgentFloatingCommands? + + static func reduce(value: inout AgentFloatingCommands?, nextValue: () -> AgentFloatingCommands?) { + value = nextValue() ?? value + } +} + +/// Render outside the split pane's hit bounds so floated rows remain clickable. +private struct AgentCommandSuggestionScope: ViewModifier { + func body(content: Content) -> some View { + content + .overlayPreferenceValue(AgentFloatingCommandsKey.self) { suggestion in + GeometryReader { geometry in + if let suggestion { + let frame = geometry[suggestion.bounds] + let list = AgentCommandSuggestionList( + commands: suggestion.list.commands, + highlightedIndex: suggestion.list.highlightedIndex, + onSelect: suggestion.list.onSelect, + maximumHeight: min(AgentCommandSuggestionList.defaultMaximumHeight, max(0, frame.minY - 2)) + ) + list.frame(width: frame.width) + .position(x: frame.midX, y: frame.minY - list.height / 2) + } + } + } + .transformPreference(AgentFloatingCommandsKey.self) { $0 = nil } + } +} + +extension View { + func agentCommandSuggestionScope() -> some View { + modifier(AgentCommandSuggestionScope()) + } +} diff --git a/macos/Sources/Lithe/Views/Agent/AgentComposerView.swift b/macos/Sources/Lithe/Views/Agent/AgentComposerView.swift index 1df5e9fc8..205dfb26b 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentComposerView.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentComposerView.swift @@ -15,6 +15,7 @@ struct AgentComposerView: View { let onError: (String?) -> Void var configOptions: [AgentSessionConfigOption] = [] var sessionID: String? + var isPreparingSession = false var isConfiguring = false var isCancelling = false var contextUsage: AgentContextUsage? @@ -23,36 +24,38 @@ struct AgentComposerView: View { var subscriptionAccount: String? var quotaFailure: String? var onSetConfig: (String, String) -> Void = { _, _ in } - @State private var draft = "" + var commands: [AgentCommand] = [] + @State private var completion = AgentCommandCompletion() @State private var files: [AgentFileReference] = [] @State private var isDropTargeted = false @State private var showsFilePicker = false @State private var isHovering = false @FocusState private var isFocused: Bool - private var hasContent: Bool { !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !files.isEmpty } + private var hasContent: Bool { !completion.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !files.isEmpty } + private var commandSuggestions: [AgentCommand]? { + completion.suggestions(in: commands) + } var body: some View { - VStack(spacing: 0) { + AgentComposerContent(files: files, commands: commandSuggestions, + highlightedIndex: completion.highlightedIndex, + onSelect: complete, onRemoveFile: { id in files.removeAll { $0.id == id } }, + onFocus: { isFocused = true }) { contextBar - if !files.isEmpty { - AgentFileReferenceList(files: files) { id in files.removeAll { $0.id == id } } - } - ScrollView { - TextField("Message the Agent", text: $draft, axis: .vertical) - .textFieldStyle(.plain) - .font(.system(size: 13)) - .foregroundStyle(AgentPanelStyle.text) - .lineLimit(1...) - .focused($isFocused) - .onSubmit(send) - .padding(.horizontal, 8) - .padding(.vertical, 10) - .frame(maxWidth: .infinity, alignment: .topLeading) - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .contentShape(Rectangle()) - .onTapGesture { isFocused = true } + } editor: { + TextField("Message the Agent", text: $completion.draft, axis: .vertical) + .textFieldStyle(.plain) + .font(.system(size: 13)) + .foregroundStyle(AgentPanelStyle.text) + .lineLimit(1...) + .focused($isFocused) + .onSubmit { _ = handleCommandKey(.submit) } + .modifier(AgentCommandKeyNavigation(onKey: handleCommandKey)) + .padding(.horizontal, 8) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .topLeading) + } toolbar: { toolbar } .background(AgentPanelStyle.canvas, in: RoundedRectangle(cornerRadius: 8)) @@ -75,9 +78,9 @@ struct AgentComposerView: View { } .onHover { isHovering = $0 } .padding(.horizontal, 8) - .padding(.bottom, 8) + .padding(.bottom, AgentComposerMetrics.bottomInset) .onAppear { isFocused = true } - .onExitCommand { if isResponding { onCancel() } } + .onExitCommand { _ = handleCommandKey(.escape) } } private var contextBar: some View { @@ -97,7 +100,7 @@ struct AgentComposerView: View { .font(.system(size: 11)) .foregroundStyle(AgentPanelStyle.secondary) .padding(.horizontal, 10) - .frame(height: 28) + .frame(height: AgentComposerMetrics.contextHeight - 2) .background(AgentPanelStyle.context, in: RoundedRectangle(cornerRadius: 7)) .padding(1) } @@ -108,7 +111,14 @@ struct AgentComposerView: View { .buttonStyle(AgentToolbarButtonStyle()) .help("Agent Settings") agentMenu - if !configOptions.isEmpty { + if isPreparingSession { + Label("Loading session settings…", systemImage: "hourglass") + .font(.system(size: 11)) + .foregroundStyle(AgentPanelStyle.secondary) + .lineLimit(1) + .padding(.horizontal, 4) + .accessibilityIdentifier("agent-session-settings-loading") + } else if !configOptions.isEmpty { AgentSessionSelectors( options: configOptions, agentName: selectedAgent?.name, @@ -117,10 +127,9 @@ struct AgentComposerView: View { ) .id(sessionID ?? selectedAgent?.id) if isConfiguring { ProgressView().controlSize(.mini) } - } - if configOptions.isEmpty, let model = selectedAgent?.modelName, !model.isEmpty { + } else if let model = selectedAgent?.modelName, !model.isEmpty { HStack(spacing: 5) { - AgentBrandIcon(name: selectedAgent?.name, size: 12) + AgentBrandIcon(name: selectedAgent?.name, size: 12, style: .brand) Text(model).lineLimit(1).truncationMode(.middle) } .font(.system(size: 11)) @@ -138,31 +147,42 @@ struct AgentComposerView: View { } .buttonStyle(.litheNoPress) .lithePointer() - .disabled(isCancelling || (!isResponding && (!hasContent || isBlocked || isConfiguring))) + .disabled(isCancelling || (!isResponding && (!hasContent || isBlocked || isPreparingSession || isConfiguring))) .help(isCancelling ? "Stopping…" : (isResponding ? "Stop" : "Send")) } .padding(.horizontal, 5) - .frame(height: 36) + .frame(height: AgentComposerMetrics.toolbarHeight - 2) .background(AgentPanelStyle.toolbar, in: RoundedRectangle(cornerRadius: 7)) .padding(1) } private var agentMenu: some View { Menu { - if agents.isEmpty { Text("No Agent is set up yet") } - ForEach(agents) { agent in - Button { onSelectAgent(agent.id) } label: { - if agent.id == selectedAgent?.id { - Label(agent.name, systemImage: "checkmark") - } else { - Text(agent.name) + if agents.isEmpty { + Text("No Agent is set up yet") + } else { + // The native picker owns the selection checkmark separately + // from each item's brand image. + Picker("Choose an Agent", selection: Binding( + get: { selectedAgent?.id }, + set: { if let id = $0 { onSelectAgent(id) } } + )) { + ForEach(agents) { agent in + Label { + Text(agent.name) + } icon: { + AgentBrandIcon(name: agent.name, size: 16, style: .brand) + } + .tag(Optional(agent.id)) } } + .pickerStyle(.inline) + .labelsHidden() } Divider() Button("Agent Settings…", action: onOpenSettings) } label: { - AgentBrandIcon(name: selectedAgent?.name, size: 16) + AgentBrandIcon(name: selectedAgent?.name, size: 18, style: .brand) .foregroundStyle(AgentPanelStyle.secondary) .frame(width: 28, height: 28) } @@ -171,6 +191,7 @@ struct AgentComposerView: View { .fixedSize() .help(selectedAgent?.name ?? String(localized: "Choose an Agent")) .accessibilityLabel("Switch Agent") + .accessibilityIdentifier("agent-composer-agent-selector") } private func addFiles(_ urls: [URL]) -> Bool { @@ -186,15 +207,31 @@ struct AgentComposerView: View { } } + private func complete(_ command: AgentCommand) { + completion.complete(command) + isFocused = true + } + + private func handleCommandKey(_ key: AgentCommandCompletion.Key) -> AgentCommandCompletion.Result { + let result = completion.handle(key, commands: commands, isResponding: isResponding) + switch result { + case .send: send() + case .cancel: onCancel() + case .handled: isFocused = true + case .ignored: break + } + return result + } + private func send() { guard hasContent, !isResponding else { return } - if isBlocked { + if isBlocked || isPreparingSession { onError(String(localized: "The conversation is still being prepared. Try again in a moment.")) return } do { - try onSend(draft, files) - draft = "" + try onSend(completion.draft, files) + completion.draft = "" files.removeAll() onError(nil) } catch { @@ -204,23 +241,47 @@ struct AgentComposerView: View { } +/// Arrow keys and Tab drive the command list while it is open. Key handling on a +/// focused text field needs macOS 14; macOS 13 keeps mouse selection and Return. +private struct AgentCommandKeyNavigation: ViewModifier { + let onKey: (AgentCommandCompletion.Key) -> AgentCommandCompletion.Result + + func body(content: Content) -> some View { + if #available(macOS 14.0, *) { + content + .onKeyPress(.upArrow) { handle(.up) } + .onKeyPress(.downArrow) { handle(.down) } + .onKeyPress(.tab) { handle(.tab) } + } else { + content + } + } + + @available(macOS 14.0, *) + private func handle(_ key: AgentCommandCompletion.Key) -> KeyPress.Result { + onKey(key) == .ignored ? .ignored : .handled + } +} + /// The shared split container keeps resize updates outside the conversation model. struct AgentConversationLayout: View { @ViewBuilder let transcript: Transcript @ViewBuilder let composer: Composer + @State private var composerMinimumHeight = AgentComposerMetrics.minimumHeight(hasFiles: false) var body: some View { GeometryReader { geometry in + let minimum = min(composerMinimumHeight, max(0, geometry.size.height - SplitHandleView.hitThickness)) LitheSplitPaneView( axis: .vertical, placement: .trailing, defaultSize: min(210, geometry.size.height * 0.3), - minimum: min(120, geometry.size.height * 0.4), - maximum: max(0, geometry.size.height * 0.6), + minimum: minimum, + maximum: max(minimum, geometry.size.height * 0.6), showsIdleDivider: false ) { composer - .padding(.top, 10) + .padding(.top, AgentComposerMetrics.splitTopInset) .overlay(alignment: .top) { Capsule().fill(AgentPanelStyle.muted.opacity(0.55)) .frame(width: 54, height: 3) @@ -231,5 +292,7 @@ struct AgentConversationLayout: View { transcript } } + .onPreferenceChange(AgentComposerMinimumHeightKey.self) { composerMinimumHeight = $0 } + .agentCommandSuggestionScope() } } diff --git a/macos/Sources/Lithe/Views/Agent/AgentConversationView.swift b/macos/Sources/Lithe/Views/Agent/AgentConversationView.swift index f01e37aa5..c995dc874 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentConversationView.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentConversationView.swift @@ -18,19 +18,16 @@ struct AgentConversationView: View { feature: model.agentManagementFeature, onDone: { showsSettings = false } ) - } else if let feature, let connection = feature.selectedConnection, let agentID = feature.selectedAgentID { - AgentConnectionView( - feature: connection, - history: feature.history(for: agentID), - agents: feature.agents, - selectedAgentID: feature.selectedAgentID, + } else if let feature { + AgentConfiguredConversationView( + feature: feature, + setupError: model.agentConversationSetupError, onSelectAgent: { model.selectAgentConversationAgent($0) }, onConnect: { model.connectAgentConversation() }, onOpenSettings: { showsSettings = true }, onCopySessionID: { model.copyAgentSessionID($0) }, onOpenFile: { model.openAgentFile($0) } ) - .id(feature.selectedAgentID) } else { AgentUnconfiguredConversationView( setupError: model.agentConversationSetupError, @@ -44,6 +41,37 @@ struct AgentConversationView: View { } } +/// Observe selection where the selected connection is resolved. AppModel does +/// not forward this optional module's changes, and each connection owns its UI. +struct AgentConfiguredConversationView: View { + @ObservedObject var feature: AgentConversationFeatureModel + let setupError: AgentConversationError? + let onSelectAgent: (String) -> Void + let onConnect: () -> Void + let onOpenSettings: () -> Void + let onCopySessionID: (String) -> Void + let onOpenFile: (AgentToolDetails.Location) -> Void + + var body: some View { + if let connection = feature.selectedConnection, let agentID = feature.selectedAgentID { + AgentConnectionView( + feature: connection, + history: feature.history(for: agentID), + agents: feature.agents, + selectedAgentID: agentID, + onSelectAgent: onSelectAgent, + onConnect: onConnect, + onOpenSettings: onOpenSettings, + onCopySessionID: onCopySessionID, + onOpenFile: onOpenFile + ) + .id(agentID) + } else { + AgentUnconfiguredConversationView(setupError: setupError, onOpenSettings: onOpenSettings) + } + } +} + /// Title on the left and icon actions on the right, like a chat client's /// session header: new conversation, history, settings. private struct AgentPanelHeader: View { @@ -131,6 +159,20 @@ private struct AgentConnectionView: View { private var selectedAgent: AgentOption? { agents.first { $0.id == selectedAgentID } } + /// A saved provider model is not the current session's confirmed model. + /// Include the ready-to-create gap, but stop waiting on failure or sign-in. + private var isPreparingSession: Bool { + switch feature.connectionState { + case .idle, .connecting: + return true + case .ready: + return feature.isCreatingSession || feature.selectedConversation?.isLoading == true + || (feature.selectedSessionID == nil && feature.errorMessage == nil) + case .authenticationRequired, .authenticating, .failed: + return false + } + } + var body: some View { ZStack { conversation @@ -218,6 +260,7 @@ private struct AgentConnectionView: View { onError: { localError = $0 }, configOptions: feature.selectedConversation?.configOptions ?? [], sessionID: feature.selectedSessionID, + isPreparingSession: isPreparingSession, isConfiguring: feature.selectedConversation?.pendingConfigToken != nil, isCancelling: feature.selectedConversation?.isCancelling == true, contextUsage: feature.selectedConversation?.contextUsage, @@ -225,7 +268,8 @@ private struct AgentConnectionView: View { subscriptionQuota: feature.subscriptionQuota, subscriptionAccount: feature.subscriptionEmail, quotaFailure: feature.quotaFailure, - onSetConfig: { feature.setConfigOption($0, value: $1) } + onSetConfig: { feature.setConfigOption($0, value: $1) }, + commands: feature.selectedConversation?.availableCommands ?? [] ) } } diff --git a/macos/Sources/Lithe/Views/Agent/AgentFileReferenceList.swift b/macos/Sources/Lithe/Views/Agent/AgentFileReferenceList.swift index 950a8f921..1be1da97f 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentFileReferenceList.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentFileReferenceList.swift @@ -29,6 +29,6 @@ struct AgentFileReferenceList: View { .padding(.horizontal, 8) } .scrollIndicators(.hidden) - .frame(height: 34) + .frame(height: AgentComposerMetrics.fileHeight) } } diff --git a/macos/Sources/Lithe/Views/Agent/AgentHistoryView.swift b/macos/Sources/Lithe/Views/Agent/AgentHistoryView.swift index 0eb5e6d4c..c0239dc2d 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentHistoryView.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentHistoryView.swift @@ -40,7 +40,7 @@ enum AgentHistoryPresentation { static func messageCount(_ conversation: AgentConversation?) -> Int? { guard let conversation, !conversation.isLoading, conversation.isAttached || !conversation.messages.isEmpty else { return nil } - return conversation.messages.filter { $0.role != .tool }.count + return conversation.messages.filter { $0.role == .user || $0.role == .agent }.count } } diff --git a/macos/Sources/Lithe/Views/Agent/AgentPanelStyle.swift b/macos/Sources/Lithe/Views/Agent/AgentPanelStyle.swift index f01612380..4702ee991 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentPanelStyle.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentPanelStyle.swift @@ -17,6 +17,14 @@ enum AgentPanelStyle { static let versionText = adaptive(dark: 0xddd6fe, light: 0x6d28d9) static let versionAccent = Color(red: 139 / 255, green: 92 / 255, blue: 246 / 255) + static func brandTint(for name: String?, isDark: Bool) -> UInt32? { + switch name?.lowercased() { + case "claude", "claude code": 0xd97757 + case "codex": isDark ? 0xcccccc : 0x333333 + default: nil + } + } + private static func adaptive(dark: UInt32, light: UInt32) -> Color { Color(nsColor: NSColor(name: nil) { appearance in let hex = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ? dark : light @@ -30,17 +38,25 @@ enum AgentPanelStyle { } } -/// Template SVGs use the same vendor silhouettes at welcome and toolbar sizes. +/// Reuse the vendor silhouettes; toolbar marks retain their color in native menus. struct AgentBrandIcon: View { + enum Style { case template, brand } + let name: String? var size: CGFloat = 16 + var style: Style = .template + @Environment(\.colorScheme) private var colorScheme + + private var tint: UInt32? { + style == .brand ? AgentPanelStyle.brandTint(for: name, isDark: colorScheme == .dark) : nil + } var body: some View { Group { - if let image = AgentBrandIconLoader.image(name: name, size: size) { + if let image = AgentBrandIconLoader.image(name: name, size: size, tint: tint) { Image(nsImage: image) .resizable() - .renderingMode(.template) + .renderingMode(tint == nil ? .template : .original) .aspectRatio(contentMode: .fit) } else { Image(systemName: "sparkles") @@ -59,11 +75,13 @@ enum AgentBrandIconLoader { let bundleURL: URL let filename: String let size: Int + let tint: UInt32? } private static var images: [CacheKey: NSImage] = [:] static func image( name: String?, size: CGFloat = 64, + tint: UInt32? = nil, resourceBundle: Bundle? = resolveResourceBundle() ) -> NSImage? { let filename: String @@ -73,15 +91,33 @@ enum AgentBrandIconLoader { default: return nil } guard let resourceBundle else { return nil } - let key = CacheKey(bundleURL: resourceBundle.bundleURL, filename: filename, size: max(1, Int(size.rounded()))) + let key = CacheKey(bundleURL: resourceBundle.bundleURL, filename: filename, + size: max(1, Int(size.rounded())), tint: tint) if let image = images[key] { return image } guard let url = resourceBundle.url(forResource: filename, withExtension: "svg", subdirectory: "AgentIcons"), let image = NSImage(contentsOf: url) else { return nil } image.isTemplate = true // Native Menu labels read NSImage.size rather than the SwiftUI frame. image.size = NSSize(width: key.size, height: key.size) - images[key] = image - return image + let rendered: NSImage + if let tint { + let color = NSColor(srgbRed: CGFloat((tint >> 16) & 255) / 255, + green: CGFloat((tint >> 8) & 255) / 255, + blue: CGFloat(tint & 255) / 255, alpha: 1) + // SwiftUI foreground styles can be lost when Menu converts its label + // to AppKit. Color the SVG's alpha mask in memory and keep it original. + rendered = NSImage(size: image.size, flipped: false) { rect in + image.draw(in: rect, from: .zero, operation: .copy, fraction: 1) + color.setFill() + rect.fill(using: .sourceIn) + return true + } + rendered.isTemplate = false + } else { + rendered = image + } + images[key] = rendered + return rendered } nonisolated static func resolveResourceBundle( diff --git a/macos/Sources/Lithe/Views/Agent/AgentSessionGuidanceViews.swift b/macos/Sources/Lithe/Views/Agent/AgentSessionGuidanceViews.swift new file mode 100644 index 000000000..ff12f535a --- /dev/null +++ b/macos/Sources/Lithe/Views/Agent/AgentSessionGuidanceViews.swift @@ -0,0 +1,241 @@ +import SwiftUI +import LitheAgentConversationModule + +/// The agent's reasoning, separate from its reply. Open while it streams and +/// collapsed once the reply or a tool call follows; a search keeps it open. +struct AgentThoughtRow: View { + let text: String + let isStreaming: Bool + var isSearching = false + @Binding var expansion: AgentThoughtExpansion + + private var isExpanded: Bool { expansion.isExpanded(isStreaming: isStreaming, isSearching: isSearching) } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Button { expansion.toggle(isStreaming: isStreaming, isSearching: isSearching) } label: { + HStack(spacing: 6) { + Image(systemName: "brain") + .font(.system(size: 10.5)) + Text(isStreaming ? "Thinking…" : "Thinking process") + .font(.system(size: 11.5, weight: .medium)) + Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + .font(.system(size: 9)) + Spacer(minLength: 0) + } + .foregroundStyle(LitheTheme.tertiaryText) + .contentShape(Rectangle()) + } + .buttonStyle(.litheNoPress) + .lithePointer() + .help(isExpanded ? "Hide thinking" : "Show thinking") + .accessibilityValue(isExpanded ? String(localized: "Expanded") : String(localized: "Collapsed")) + + if isExpanded { + Text(text.trimmingCharacters(in: .whitespacesAndNewlines)) + .font(.system(size: 12)) + .foregroundStyle(LitheTheme.secondaryText) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 10) + .overlay(alignment: .leading) { + Rectangle().fill(LitheTheme.panelBorder).frame(width: 2) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +/// Search temporarily reveals a match without replacing the user's disclosure preference. +struct AgentThoughtExpansion { + private var userExpanded: Bool? + + func isExpanded(isStreaming: Bool, isSearching: Bool) -> Bool { + isSearching || (userExpanded ?? isStreaming) + } + + mutating func toggle(isStreaming: Bool, isSearching: Bool) { + // A matching search must keep its text visible, including after a click. + guard !isSearching else { return } + userExpanded = !isExpanded(isStreaming: isStreaming, isSearching: false) + } +} + +/// Plan the agent reported for this conversation, pinned above the activity bar. +/// Collapsed it shows progress and the current step; it stays expandable after the turn. +struct AgentPlanView: View { + let plan: AgentPlan + let isResponding: Bool + @State private var expanded = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Button { expanded.toggle() } label: { + HStack(spacing: 7) { + Image(systemName: "list.bullet.clipboard") + .font(.system(size: 10.5)) + .foregroundStyle(plan.isComplete ? LitheTheme.success : LitheTheme.accent) + Text(String(format: String(localized: "Plan %lld/%lld"), plan.completedCount, plan.entries.count)) + .font(.system(size: 11.5, weight: .semibold)) + .foregroundStyle(LitheTheme.primaryText) + .monospacedDigit() + if !expanded, let current = plan.currentEntry { + Text(current.content) + .font(.system(size: 11.5)) + .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(1) + .truncationMode(.tail) + } + Spacer(minLength: 4) + Image(systemName: expanded ? "chevron.down" : "chevron.up") + .font(.system(size: 9)) + .foregroundStyle(LitheTheme.tertiaryText) + } + .padding(.horizontal, 10) + .frame(height: 28) + .contentShape(Rectangle()) + } + .buttonStyle(.litheNoPress) + .help(expanded ? "Hide plan" : "Show plan") + + if expanded { + Rectangle().fill(LitheTheme.panelBorder).frame(height: 1) + ScrollView(.vertical) { + VStack(alignment: .leading, spacing: 6) { + ForEach(Array(plan.entries.enumerated()), id: \.offset) { _, entry in + entryRow(entry) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + } + .frame(maxHeight: 180) + .fixedSize(horizontal: false, vertical: true) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .background(AgentPanelStyle.header, in: RoundedRectangle(cornerRadius: 5)) + .overlay(RoundedRectangle(cornerRadius: 5).stroke(AgentPanelStyle.border, lineWidth: 1)) + .padding(.horizontal, 18) + .padding(.bottom, 4) + } + + private func entryRow(_ entry: AgentPlan.Entry) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 7) { + Image(systemName: icon(entry.status)) + .font(.system(size: 10.5)) + .foregroundStyle(color(entry.status)) + Text(entry.content) + .font(.system(size: 11.5)) + .foregroundStyle(entry.status == .completed ? LitheTheme.tertiaryText : LitheTheme.primaryText) + .strikethrough(entry.status == .completed, color: LitheTheme.tertiaryText) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + .accessibilityElement(children: .combine) + .accessibilityValue(label(entry.status)) + } + + private func icon(_ status: AgentPlan.Entry.Status) -> String { + switch status { + case .completed: "checkmark.circle.fill" + case .inProgress: isResponding ? "circle.dotted" : "circle.lefthalf.filled" + case .pending: "circle" + } + } + + private func color(_ status: AgentPlan.Entry.Status) -> Color { + switch status { + case .completed: LitheTheme.success + case .inProgress: LitheTheme.accent + case .pending: LitheTheme.tertiaryText + } + } + + private func label(_ status: AgentPlan.Entry.Status) -> String { + switch status { + case .completed: String(localized: "Completed") + case .inProgress: String(localized: "Running") + case .pending: String(localized: "Pending") + } + } +} + +/// Slash commands matching the draft, shown above the writing area. +struct AgentCommandSuggestionList: View { + let commands: [AgentCommand] + let highlightedIndex: Int + let onSelect: (AgentCommand) -> Void + var maximumHeight: CGFloat = defaultMaximumHeight + static let defaultMaximumHeight: CGFloat = 168 + /// One complete command row including the list's vertical padding and border. + static let minimumHeight = rowHeight + 2 * contentInset + 2 * borderInset + private static let rowHeight: CGFloat = 26 + private static let contentInset: CGFloat = 3 + private static let borderInset: CGFloat = 1 + + var height: CGFloat { + let contentHeight = commands.isEmpty ? Self.rowHeight + : min(maximumHeight, CGFloat(commands.count) * Self.rowHeight + 2 * Self.contentInset) + return contentHeight + 2 * Self.borderInset + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if commands.isEmpty { + Text("No matching commands") + .font(.system(size: 11.5)) + .foregroundStyle(AgentPanelStyle.secondary) + .padding(.horizontal, 10) + .frame(height: Self.rowHeight) + } else { + ScrollViewReader { proxy in + ScrollView(.vertical) { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(Array(commands.enumerated()), id: \.element.id) { index, command in + row(command, isHighlighted: index == highlightedIndex).id(command.id) + } + } + .padding(.vertical, Self.contentInset) + } + .frame(maxHeight: maximumHeight) + .fixedSize(horizontal: false, vertical: true) + .onChange(of: highlightedIndex) { index in + if commands.indices.contains(index) { proxy.scrollTo(commands[index].id) } + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .background(AgentPanelStyle.context, in: RoundedRectangle(cornerRadius: 7)) + .padding(Self.borderInset) + .frame(height: height) + .accessibilityLabel("Agent commands") + } + + private func row(_ command: AgentCommand, isHighlighted: Bool) -> some View { + Button { onSelect(command) } label: { + HStack(spacing: 8) { + Text(command.invocation) + .font(.system(size: 11.5, weight: .medium, design: .monospaced)) + .foregroundStyle(AgentPanelStyle.text) + .lineLimit(1) + .layoutPriority(1) + Text(command.description) + .font(.system(size: 11.5)) + .foregroundStyle(AgentPanelStyle.secondary) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 0) + } + .padding(.horizontal, 10) + .frame(height: Self.rowHeight) + .background(isHighlighted ? AgentPanelStyle.focus.opacity(0.18) : Color.clear) + .contentShape(Rectangle()) + } + .buttonStyle(.litheNoPress) + .litheRowHover() + .help(command.hint.map { "\(command.invocation) \($0)" } ?? command.description) + } +} diff --git a/macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift b/macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift index e26f085db..346b69da9 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift @@ -1,6 +1,13 @@ import SwiftUI import LitheAgentConversationModule +private enum AgentSelectorLayout { + static let choiceRowHeight: CGFloat = 26 + static let modeRowHeight: CGFloat = 44 + static let modeViewportHeight: CGFloat = 320 + static let modeVerticalPadding: CGFloat = 5 +} + /// Search and presentation only; the Agent still owns IDs, choices and confirmed values. enum AgentSessionSelectorPresentation { static func filteredChoices(_ option: AgentSessionConfigOption, query: String) -> [AgentSessionConfigOption.Choice] { @@ -17,29 +24,35 @@ enum AgentSessionSelectorPresentation { return localized(option.name) } - static func choiceTitle(_ choice: AgentSessionConfigOption.Choice, in option: AgentSessionConfigOption) -> String { + static func choiceTitle(_ choice: AgentSessionConfigOption.Choice, in option: AgentSessionConfigOption, bundle: Bundle = .main) -> String { if option.id == "fast-mode" { - if choice.id == "off" { return String(localized: "Standard") } - if choice.id == "on" { return String(localized: "Fast") } + if choice.id == "off" { return localized("Standard", bundle: bundle) } + if choice.id == "on" { return localized("Fast", bundle: bundle) } } - return option.category == "model" ? choice.name : localized(choice.name) + // Agent-owned names must not collide with translations used elsewhere in the app. + if option.category == "model" || option.category == "mode" || option.category == "thought_level" { + return choice.name + } + return localized(choice.name, bundle: bundle) } - static func currentTitle(_ option: AgentSessionConfigOption) -> String { - option.choices.first { $0.id == option.currentValue }.map { choiceTitle($0, in: option) } ?? option.currentValue + static func currentTitle(_ option: AgentSessionConfigOption, bundle: Bundle = .main) -> String { + option.choices.first { $0.id == option.currentValue }.map { choiceTitle($0, in: option, bundle: bundle) } ?? option.currentValue } static func modeIcon(_ id: String) -> String { switch id { - case "read-only": "bubble.left.and.bubble.right" - case "agent": "checkmark.shield" - case "agent-full-access": "bolt" + case "read-only", "default", "manual": "bubble.left.and.bubble.right" + case "agent", "auto": "checkmark.shield" + case "agent-full-access", "bypassPermissions": "bolt" + case "acceptEdits": "pencil" + case "plan": "list.bullet.rectangle" default: "slider.horizontal.3" } } - static func localized(_ text: String) -> String { - String(localized: String.LocalizationValue(text)) + static func localized(_ text: String, bundle: Bundle = .main) -> String { + String(localized: String.LocalizationValue(text), bundle: bundle) } } @@ -74,6 +87,7 @@ struct AgentSessionSelectors: View { .help(AgentSessionSelectorPresentation.localized(mode.name)) .accessibilityLabel(Text("Approval mode")) .accessibilityValue(AgentSessionSelectorPresentation.currentTitle(mode)) + .accessibilityIdentifier("agent-session-mode-selector") .popover(isPresented: $showsModes, arrowEdge: .top) { AgentModePopover(option: mode) { value in select(mode.id, value) } .onExitCommand { showsModes = false } @@ -82,7 +96,7 @@ struct AgentSessionSelectors: View { if let model { Button { showsModels.toggle() } label: { selectorLabel { - AgentBrandIcon(name: agentName, size: 12) + AgentBrandIcon(name: agentName, size: 12, style: .brand) Text(modelSummary(model)) } } @@ -90,6 +104,7 @@ struct AgentSessionSelectors: View { .help(model.currentLabel) .accessibilityLabel(Text("Model")) .accessibilityValue(model.currentLabel) + .accessibilityIdentifier("agent-session-model-selector") .popover(isPresented: $showsModels, arrowEdge: .top) { AgentModelPopover(option: model, settings: settings, agentName: agentName, onSelect: select) .onExitCommand { showsModels = false } @@ -116,7 +131,7 @@ struct AgentSessionSelectors: View { private func modelSummary(_ model: AgentSessionConfigOption) -> String { let effort = options.first { $0.category == "thought_level" } - return [model.currentLabel, effort.map(AgentSessionSelectorPresentation.currentTitle)].compactMap { $0 }.joined(separator: " ") + return [model.currentLabel, effort.map { AgentSessionSelectorPresentation.currentTitle($0) }].compactMap { $0 }.joined(separator: " ") } private func selectorLabel(@ViewBuilder content: () -> Content) -> some View { @@ -171,7 +186,7 @@ struct AgentModelPopover: View { } } } - .frame(height: min(240, CGFloat(setting.choices.count * 32))) + .frame(height: min(240, CGFloat(setting.choices.count) * AgentSelectorLayout.choiceRowHeight)) } .padding(.bottom, 5) .frame(width: 180) @@ -210,13 +225,13 @@ struct AgentModelPopover: View { .padding(.horizontal, 12).padding(.vertical, 4) } AgentSelectorRow(isSelected: choice.id == option.currentValue, action: { onSelect(option.id, choice.id) }) { - AgentBrandIcon(name: agentName, size: 16) + AgentBrandIcon(name: agentName, size: 16, style: .brand) Text(choice.name).lineLimit(1).truncationMode(.middle) } } } } - .frame(height: min(240, CGFloat(choices.count * 32 + choices.filter { $0.group != nil }.count * 20))) + .frame(height: min(240, CGFloat(choices.count) * AgentSelectorLayout.choiceRowHeight + CGFloat(choices.filter { $0.group != nil }.count * 20))) } if !settings.isEmpty { Divider().overlay(AgentPanelStyle.border).padding(.vertical, 4) @@ -258,17 +273,18 @@ private struct AgentModelSettingRow: View { } } -private struct AgentModePopover: View { +struct AgentModePopover: View { let option: AgentSessionConfigOption let onSelect: (String) -> Void + @State private var contentHeight: CGFloat = 0 var body: some View { ScrollView { LazyVStack(spacing: 0) { ForEach(option.choices) { choice in - AgentSelectorRow(isSelected: choice.id == option.currentValue, minimumHeight: 64, action: { onSelect(choice.id) }) { + AgentSelectorRow(isSelected: choice.id == option.currentValue, minimumHeight: AgentSelectorLayout.modeRowHeight, action: { onSelect(choice.id) }) { Image(systemName: AgentSessionSelectorPresentation.modeIcon(choice.id)).frame(width: 16) - VStack(alignment: .leading, spacing: 3) { + VStack(alignment: .leading, spacing: 2) { Text(AgentSessionSelectorPresentation.choiceTitle(choice, in: option)).lineLimit(1) if let description = choice.description, !description.isEmpty { Text(AgentSessionSelectorPresentation.localized(description)) @@ -281,16 +297,33 @@ private struct AgentModePopover: View { } } } + .background { + GeometryReader { geometry in + // Cap the measurement so scrolling a long lazy list cannot keep resizing the popover. + Color.clear.preference(key: AgentModeContentHeightKey.self, + value: min(AgentSelectorLayout.modeViewportHeight, geometry.size.height)) + } + } + } + .onPreferenceChange(AgentModeContentHeightKey.self) { height in + if abs(height - contentHeight) > 0.5 { contentHeight = height } } - .padding(.vertical, 5) - .frame(width: 350, height: min(330, CGFloat(option.choices.count * 64 + 10))) + .padding(.vertical, AgentSelectorLayout.modeVerticalPadding) + .frame(width: 350, height: min(AgentSelectorLayout.modeViewportHeight, + max(CGFloat(option.choices.count) * AgentSelectorLayout.modeRowHeight, contentHeight)) + + 2 * AgentSelectorLayout.modeVerticalPadding) .background(AgentPanelStyle.header) } } +private struct AgentModeContentHeightKey: PreferenceKey { + static let defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = max(value, nextValue()) } +} + private struct AgentSelectorRow: View { let isSelected: Bool - var minimumHeight: CGFloat = 32 + var minimumHeight: CGFloat = AgentSelectorLayout.choiceRowHeight let action: () -> Void @ViewBuilder let content: Content @State private var isHovering = false @@ -308,7 +341,7 @@ private struct AgentSelectorRow: View { .font(.system(size: 12)) .foregroundStyle(AgentPanelStyle.text) .padding(.horizontal, 12) - .padding(.vertical, 8) + .padding(.vertical, 4) .frame(maxWidth: .infinity, minHeight: minimumHeight, alignment: .leading) .background(isSelected ? AgentPanelStyle.selected : (isHovering ? AgentPanelStyle.context : .clear)) .contentShape(Rectangle()) diff --git a/macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift b/macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift index f35837ec0..19f964643 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift @@ -76,12 +76,18 @@ struct AgentTranscriptView: View { var searchText = "" var onOpenFile: (AgentToolDetails.Location) -> Void = { _ in } @State private var showsAgentPicker = false + // A filtered-out row can be destroyed. Keep its preference in the owning + // transcript view, isolated by session and message, until the actual data goes away. + @State private var thoughtExpansions: [String: [String: AgentThoughtExpansion]] = [:] var body: some View { let conversation = feature.selectedConversation let messages = conversation?.messages ?? [] let transcript = AgentTranscriptItem.grouped(messages, turns: conversation?.completedTurns ?? []) .filter { $0.matches(searchText) } + // Only reasoning that is still streaming opens by default. + let liveThoughtID = conversation?.isResponding == true && messages.last?.role == .thought + ? messages.last?.id : nil VStack(spacing: 0) { if conversation?.isLoading != true && messages.isEmpty && feature.pendingNewConversationPrompt == nil { AgentHeroView(agentName: agentName, agentVersion: agentVersion) { @@ -128,7 +134,13 @@ struct AgentTranscriptView: View { Group { switch item { case .message(let message): - AgentMessageRow(message: message, onOpenFile: onOpenFile) + AgentMessageRow( + message: message, + isStreamingThought: liveThoughtID == message.id, + isSearching: !searchText.isEmpty, + thoughtExpansion: thoughtExpansion(for: message.id), + onOpenFile: onOpenFile + ) case .toolGroup(let tools): AgentToolGroupView(messages: tools, searchText: searchText, onOpenFile: onOpenFile) case .turnSummary(let turn): @@ -144,7 +156,8 @@ struct AgentTranscriptView: View { if conversation?.isResponding == true || feature.isCreatingSession { AgentThinkingRow( isCancelling: conversation?.isCancelling == true, - startedAt: conversation?.activeTurn?.startedAt ?? feature.pendingNewConversationStartedAt + startedAt: conversation?.activeTurn?.startedAt ?? feature.pendingNewConversationStartedAt, + hasStreamingThought: liveThoughtID != nil ).id("responding") } } @@ -169,8 +182,29 @@ struct AgentTranscriptView: View { if let permission = conversation?.permission { AgentPermissionCard(permission: permission, answer: { feature.answerPermission(optionID: $0) }, onOpenFile: onOpenFile) } + if let plan = conversation?.plan { + AgentPlanView(plan: plan, isResponding: conversation?.isResponding == true) + .id(feature.selectedSessionID) + } AgentActivitySummaryBar(messages: messages) } + .onChange(of: feature.openSessionIDs) { sessionIDs in + thoughtExpansions = thoughtExpansions.filter { sessionIDs.contains($0.key) } + } + .onChange(of: messages.map(\.id)) { messageIDs in + // Use unfiltered messages: changing the search must never discard preferences. + guard let sessionID = feature.selectedSessionID, let preferences = thoughtExpansions[sessionID] else { return } + let retainedIDs = Set(messageIDs) + thoughtExpansions[sessionID] = preferences.filter { retainedIDs.contains($0.key) } + } + } + + private func thoughtExpansion(for messageID: String) -> Binding { + guard let sessionID = feature.selectedSessionID else { return .constant(AgentThoughtExpansion()) } + return Binding( + get: { thoughtExpansions[sessionID]?[messageID] ?? AgentThoughtExpansion() }, + set: { thoughtExpansions[sessionID, default: [:]][messageID] = $0 } + ) } } @@ -310,6 +344,9 @@ private struct AgentPermissionCard: View { private struct AgentMessageRow: View { let message: AgentConversationMessage + var isStreamingThought = false + var isSearching = false + var thoughtExpansion: Binding = .constant(AgentThoughtExpansion()) var onOpenFile: (AgentToolDetails.Location) -> Void = { _ in } var body: some View { @@ -327,6 +364,9 @@ private struct AgentMessageRow: View { case .agent: AgentMarkdownMessage(text: message.text) .frame(maxWidth: .infinity, alignment: .leading) + case .thought: + AgentThoughtRow(text: message.text, isStreaming: isStreamingThought, isSearching: isSearching, + expansion: thoughtExpansion) case .tool: AgentToolGroupView(messages: [message], searchText: "", onOpenFile: onOpenFile) } diff --git a/macos/Sources/Lithe/Views/Agent/AgentTurnStatisticsView.swift b/macos/Sources/Lithe/Views/Agent/AgentTurnStatisticsView.swift index cb1449a4c..c8080073c 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentTurnStatisticsView.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentTurnStatisticsView.swift @@ -39,12 +39,18 @@ enum AgentTurnStatisticsPresentation { struct AgentThinkingRow: View { var isCancelling = false var startedAt: ContinuousClock.Instant? + var hasStreamingThought = false + + var status: String { + if isCancelling { return String(localized: "Stopping…") } + return hasStreamingThought ? String(localized: "Responding…") : String(localized: "Thinking…") + } var body: some View { TimelineView(.periodic(from: .now, by: 1)) { _ in HStack(spacing: 8) { ProgressView().controlSize(.small) - Text(isCancelling ? "Stopping…" : "Thinking…") + Text(status) if let startedAt { let elapsed = AgentTurnStatistics(id: "waiting", startedAt: startedAt).elapsed(at: .now) Text(AgentTurnStatisticsPresentation.duration(elapsed)).monospacedDigit() diff --git a/macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift b/macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift index aeea8f06f..1e1752ac3 100644 --- a/macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift +++ b/macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift @@ -34,9 +34,16 @@ struct ProjectSidebarView: View { @State private var expandedDirectoryPaths: Set = [] @State private var expandedTreeRootPath: String? @State private var contextMenuPath: String? + @State private var selection = ProjectTreeSelection() @State private var selectedContent: ProjectSidebarContent = .project @State private var dependencyRefreshRevision = 0 + private func selectedURLs(in root: FileNode) -> [URL] { + ProjectTreeSelection.visibleNodes(in: root, expandedPaths: expandedDirectoryPaths) + .filter { selection.paths.contains($0.url.path) && $0.url != root.url } + .map(\.url) + } + var body: some View { VStack(alignment: .leading, spacing: 0) { sidebarHeader @@ -72,6 +79,9 @@ struct ProjectSidebarView: View { ), directoryMarks: model.projectDirectoryMarks, actions: ProjectTreeActions(model: model), + selectionSnapshot: selection, + selection: $selection, + visibleNodes: ProjectTreeSelection.visibleNodes(in: root, expandedPaths: expandedDirectoryPaths), expandedDirectoryPathsSnapshot: expandedDirectoryPaths, expandedDirectoryPaths: $expandedDirectoryPaths, contextMenuPath: $contextMenuPath @@ -85,6 +95,27 @@ struct ProjectSidebarView: View { alignment: .topLeading ) } + .background(ProjectTreeKeyboardCommands( + copy: { model.copyProjectItems(selectedURLs(in: root)) }, + paste: { + let nodes = ProjectTreeSelection.visibleNodes(in: root, expandedPaths: expandedDirectoryPaths) + let focused = nodes.first { $0.url.path == selection.focusedPath } + let destination = focused.map { $0.isDirectory ? $0.url : $0.url.deletingLastPathComponent() } ?? root.url + Task { await model.pasteProjectItems(in: destination) } + }, + selectAll: { + selection.selectAll(in: root) + } + ).frame(maxWidth: .infinity, maxHeight: .infinity)) + .onChange(of: ProjectTreeSelection.visibleNodes(in: root, expandedPaths: expandedDirectoryPaths).map { $0.url.path }) { paths in + selection.retain(visiblePaths: paths) + } + .onChange(of: model.activeDocument?.url.standardizedFileURL.path) { path in + // A single selection follows the editor, as it did before + // multi-selection; an explicit group stays until changed. + guard let path, selection.paths.count <= 1 else { return } + selection.select(path, visiblePaths: [], extending: false, toggling: false) + } .scrollContentBackground(.hidden) .litheScrollViewChrome(usesCompactScrollers: true) .task( @@ -97,6 +128,7 @@ struct ProjectSidebarView: View { let revealRequest = model.projectTreeRevealRequest let shouldRefreshGit = expandedTreeRootPath != rootPath if shouldRefreshGit { + selection = ProjectTreeSelection() expandedTreeRootPath = rootPath expandedDirectoryPaths = [rootPath] } @@ -162,7 +194,11 @@ struct ProjectSidebarView: View { .allowsHitTesting(false) } .confirmationDialog( - "Move '\(model.pendingProjectItemDeletion?.url.lastPathComponent ?? "")' to Trash?", + model.pendingProjectItemDeletion.map { request -> LocalizedStringKey in + request.additionalItems.isEmpty + ? "Move '\(request.url.lastPathComponent)' to Trash?" + : "Move \(request.additionalItems.count + 1) items to Trash?" + } ?? "Move to Trash?", isPresented: Binding( get: { model.pendingProjectItemDeletion != nil }, set: { if !$0 { model.cancelProjectItemDeletion() } } @@ -320,6 +356,18 @@ private final class ProjectTreeActions: @unchecked Sendable { nonisolated func copyPath(_ url: URL, relative: Bool) { Task { @MainActor in self.model.copyProjectItemPath(url, relative: relative) } } + nonisolated func copyFiles(_ urls: [URL]) { + Task { @MainActor in self.model.copyProjectItems(urls) } + } + nonisolated func duplicateFiles(_ urls: [URL]) { + Task { await self.model.duplicateProjectItems(urls) } + } + nonisolated func deleteFiles(_ urls: [URL]) { + Task { @MainActor in self.model.requestDeleteProjectItems(urls) } + } + nonisolated func pasteFiles(in directory: URL) { + Task { await self.model.pasteProjectItems(in: directory) } + } nonisolated func duplicate(_ url: URL) { Task { await self.model.duplicateProjectItem(at: url) } } @@ -343,6 +391,15 @@ private final class ProjectTreeActions: @unchecked Sendable { nonisolated func selectChange(_ change: GitChange) { Task { @MainActor in self.model.selectChange(change) } } + nonisolated func stageChange(_ change: GitChange) { + Task { @MainActor in self.model.setStaging([change], staged: true, includeWorkingTreeChanges: true) } + } + nonisolated func stageAndOpenCommit(_ change: GitChange) { + Task { @MainActor in + self.model.setStaging([change], staged: true, includeWorkingTreeChanges: true) + self.model.selectedSidebar = .changes + } + } nonisolated func showLocalHistory(_ url: URL) { Task { @MainActor in self.model.showLocalHistory(for: url) } } @@ -369,6 +426,9 @@ private struct ProjectFileTreeContent: View, Equatable { let gitStatus: ProjectGitStatusSnapshot let directoryMarks: [String: WorkspaceDirectoryMark] let actions: ProjectTreeActions + let selectionSnapshot: ProjectTreeSelection + @Binding var selection: ProjectTreeSelection + let visibleNodes: [FileNode] let expandedDirectoryPathsSnapshot: Set @Binding var expandedDirectoryPaths: Set @Binding var contextMenuPath: String? @@ -382,6 +442,7 @@ private struct ProjectFileTreeContent: View, Equatable { && lhs.directoryMarks == rhs.directoryMarks && lhs.expandedDirectoryPathsSnapshot == rhs.expandedDirectoryPathsSnapshot && lhs.contextMenuPath == rhs.contextMenuPath + && lhs.selectionSnapshot == rhs.selectionSnapshot } var body: some View { @@ -395,6 +456,8 @@ private struct ProjectFileTreeContent: View, Equatable { projectRootURL: root.url, directoryMarks: directoryMarks, actions: actions, + selection: $selection, + visibleNodes: visibleNodes, expandedDirectoryPaths: $expandedDirectoryPaths, contextMenuPath: $contextMenuPath ) @@ -412,6 +475,8 @@ private struct FileNodeRow: View { let projectRootURL: URL let directoryMarks: [String: WorkspaceDirectoryMark] let actions: ProjectTreeActions + @Binding var selection: ProjectTreeSelection + let visibleNodes: [FileNode] @Binding var expandedDirectoryPaths: Set @Binding var contextMenuPath: String? @State private var resolvedJavaIconKind: LitheIconKind? @@ -448,6 +513,8 @@ private struct FileNodeRow: View { projectRootURL: projectRootURL, directoryMarks: directoryMarks, actions: actions, + selection: $selection, + visibleNodes: visibleNodes, expandedDirectoryPaths: $expandedDirectoryPaths, contextMenuPath: $contextMenuPath ) @@ -463,6 +530,7 @@ private struct FileNodeRow: View { private var directoryRow: some View { Button { contextMenuPath = nil + selectRow() if isExpanded { expandedDirectoryPaths.remove(node.url.path) node.collapsedAncestorPaths.forEach { expandedDirectoryPaths.remove($0) } @@ -493,7 +561,7 @@ private struct FileNodeRow: View { .frame(height: rowHeight) .contentShape(Rectangle()) .litheRowHover( - isActive: contextMenuPath == node.url.standardizedFileURL.path, + isActive: selection.covers(node.url.path) || contextMenuPath == node.url.standardizedFileURL.path, cornerRadius: LitheTheme.Metrics.projectTreeSelectionCornerRadius, activeBackground: LitheTheme.subtleSelection, animation: nil @@ -502,15 +570,25 @@ private struct FileNodeRow: View { .buttonStyle(.litheNoPress) .lithePointer() .padding(.horizontal, LitheTheme.Metrics.projectTreeContentHorizontalInset) + .overlay { + ProjectTreeModifiedClick { flags in + contextMenuPath = nil + selectRow(flags: flags) + } + } .litheContextMenu( - items: { directoryContextMenuItems }, - onRightClick: { contextMenuPath = node.url.standardizedFileURL.path } + items: { selection.paths.count > 1 ? batchMenuItems : clipboardMenuItems + [.separator] + directoryContextMenuItems }, + onRightClick: { + selection.selectForContextMenu(node.url.path) + contextMenuPath = node.url.standardizedFileURL.path + } ) } private var fileRow: some View { Button { contextMenuPath = nil + selectRow() ProjectFileRowActivation.performPrimary(isExecutableBinary: isExecutableFile) { actions.openFile(node.url) } @@ -539,8 +617,8 @@ private struct FileNodeRow: View { .frame(height: rowHeight) .contentShape(Rectangle()) .litheRowHover( - isActive: activeDocumentURL?.standardizedFileURL.path - == node.url.standardizedFileURL.path + isActive: selection.covers(node.url.path) + || (selection.paths.isEmpty && activeDocumentURL?.standardizedFileURL.path == node.url.standardizedFileURL.path) || contextMenuPath == node.url.standardizedFileURL.path, cornerRadius: LitheTheme.Metrics.projectTreeSelectionCornerRadius, activeBackground: LitheTheme.subtleSelection, @@ -550,9 +628,18 @@ private struct FileNodeRow: View { .buttonStyle(.litheNoPress) .lithePointer() .padding(.horizontal, LitheTheme.Metrics.projectTreeContentHorizontalInset) + .overlay { + ProjectTreeModifiedClick { flags in + contextMenuPath = nil + selectRow(flags: flags) + } + } .litheContextMenu( - items: { fileContextMenuItems }, - onRightClick: { contextMenuPath = node.url.standardizedFileURL.path } + items: { selection.paths.count > 1 ? batchMenuItems : clipboardMenuItems + [.separator] + fileContextMenuItems }, + onRightClick: { + selection.selectForContextMenu(node.url.path) + contextMenuPath = node.url.standardizedFileURL.path + } ) .simultaneousGesture( TapGesture(count: 2).onEnded { @@ -571,6 +658,43 @@ private struct FileNodeRow: View { } } + /// Modified clicks update selection without opening files or folding directories. + /// Control-click stays the macOS secondary click and opens the context menu. + private func selectRow(flags: NSEvent.ModifierFlags = []) { + selection.select( + node.url.path, + visiblePaths: visibleNodes.map { $0.url.path }, + extending: flags.contains(.shift), + toggling: flags.contains(.command) + ) + } + + private var selectedItemURLs: [URL] { + visibleNodes.filter { selection.paths.contains($0.url.path) && $0.url != projectRootURL }.map(\.url) + } + + private var batchMenuItems: [LitheContextMenuItem] { + let urls = selectedItemURLs + return clipboardMenuItems + [ + .separator, + .action("Duplicate", isEnabled: !urls.isEmpty) { actions.duplicateFiles(urls) }, + .action("Move to Trash", systemImage: "trash", role: .destructive, isEnabled: !urls.isEmpty) { + actions.deleteFiles(urls) + } + ] + } + + private var clipboardMenuItems: [LitheContextMenuItem] { + let urls = selectedItemURLs + let destination = node.isDirectory ? node.url : node.url.deletingLastPathComponent() + return [ + .action("Copy Files", systemImage: "doc.on.doc", shortcut: "⌘C", isEnabled: !urls.isEmpty) { + actions.copyFiles(urls) + }, + .action("Paste", shortcut: "⌘V") { actions.pasteFiles(in: destination) } + ] + } + private var directoryContextMenuItems: [LitheContextMenuItem] { var items: [LitheContextMenuItem] = [] @@ -716,6 +840,32 @@ private struct FileNodeRow: View { ] } + // Windows parity: a Git submenu with the remaining single-file actions. + // Show Diff stays as the standalone item above, so it is not repeated. + if let change = gitStatus.change(for: node.url) { + let plan = GitFileContextMenuPlan(change: change) + var gitItems: [LitheContextMenuItem] = [] + if plan.showsAdd { + gitItems.append( + .action("Add", systemImage: "plus") { + actions.stageChange(change) + } + ) + } + if plan.showsStageAndOpenCommit { + gitItems.append( + .action("Stage and Open Commit…", systemImage: "checkmark.circle") { + actions.stageAndOpenCommit(change) + } + ) + } + if !gitItems.isEmpty { + items += [ + .submenu("Git", systemImage: "arrow.triangle.branch", items: gitItems) + ] + } + } + items += [ .separator, .action("Duplicate") { diff --git a/macos/Sources/Lithe/Views/Workspace/ProjectTreeSelection.swift b/macos/Sources/Lithe/Views/Workspace/ProjectTreeSelection.swift new file mode 100644 index 000000000..6a3a3d13d --- /dev/null +++ b/macos/Sources/Lithe/Views/Workspace/ProjectTreeSelection.swift @@ -0,0 +1,86 @@ +import Foundation +import LitheCoreContracts + +/// Selection follows the displayed tree order, including only expanded children. +struct ProjectTreeSelection: Equatable { + private(set) var paths: Set = [] + private(set) var anchorPath: String? + private(set) var focusedPath: String? + + mutating func select(_ path: String, visiblePaths: [String], extending: Bool, toggling: Bool) { + focusedPath = path + if extending, let anchorPath, + let start = visiblePaths.firstIndex(of: anchorPath), + let end = visiblePaths.firstIndex(of: path) { + let range = Set(visiblePaths[min(start, end)...max(start, end)]) + paths = toggling ? paths.union(range) : range + } else if toggling { + if !paths.insert(path).inserted { paths.remove(path) } + anchorPath = path + } else { + paths = [path] + anchorPath = path + } + } + + /// A selected folder also shows its descendants as selected; batch actions + /// already operate on everything inside it. + func covers(_ path: String) -> Bool { + var current = path + while true { + if paths.contains(current) { return true } + let parent = (current as NSString).deletingLastPathComponent + guard parent != current, !parent.isEmpty else { return false } + current = parent + } + } + + mutating func selectForContextMenu(_ path: String) { + focusedPath = path + // Inside a single selected folder the row is its own target, because the + // single-item menu acts on the clicked row rather than the folder. + guard !paths.contains(path), !(paths.count > 1 && covers(path)) else { return } + paths = [path] + anchorPath = path + } + + mutating func retain(visiblePaths: [String]) { + let visible = Set(visiblePaths) + paths.formIntersection(visible) + if let anchorPath, !visible.contains(anchorPath) { self.anchorPath = nil } + if let focusedPath, !visible.contains(focusedPath) { self.focusedPath = nil } + } + + /// ⌘A selects the focused row's siblings in the displayed tree, so a + /// folder selects the items beside it rather than its own contents. Batch + /// actions already include a selected folder's descendants. With no focus, + /// or focus on the project row, the project's top-level items are selected. + mutating func selectAll(in root: FileNode) { + let parent = focusedPath.flatMap { Self.displayedParent(of: $0, in: root) } ?? root + let siblings = (parent.children ?? []).map(\.url.path) + guard let first = siblings.first else { return } + paths = Set(siblings) + anchorPath = first + if focusedPath.map({ !paths.contains($0) }) ?? true { focusedPath = first } + } + + /// Uses tree structure instead of path components because compacted Java + /// packages display below an ancestor that is not their filesystem parent. + private static func displayedParent(of path: String, in node: FileNode) -> FileNode? { + for child in node.children ?? [] { + if child.url.path == path { return node } + if let parent = displayedParent(of: path, in: child) { return parent } + } + return nil + } + + static func visibleNodes(in root: FileNode, expandedPaths: Set) -> [FileNode] { + var nodes = [root] + if root.isDirectory, expandedPaths.contains(root.url.path) { + for child in root.children ?? [] { + nodes += visibleNodes(in: child, expandedPaths: expandedPaths) + } + } + return nodes + } +} diff --git a/macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift b/macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift index c4d0d744f..683731efd 100644 --- a/macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift +++ b/macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift @@ -54,7 +54,9 @@ public final class AgentConnectionModel: ObservableObject { /// session ID being loaded. private var queuedPrompts: [String: AgentPrompt] = [:] private var loadTokens: [String: String] = [:] - private var pendingText: [String: String] = [:] + /// Streamed text not yet shown, per session. One buffer holds one role so + /// interleaved reasoning and reply chunks become separate messages. + private var pendingText: [String: (role: AgentConversationMessage.Role, text: String)] = [:] private var flushTask: Task? private var needsAttention = false @Published private var createToken: String? @@ -252,6 +254,9 @@ public final class AgentConnectionModel: ObservableObject { queuedPrompts[sessionID] = nil if selectedSessionID == sessionID { selectedSessionID = openSessionIDs.last + // Closing the final tab does not change connectionState or make + // the view appear again, so start its replacement settings here. + if selectedSessionID == nil { prepareConversation() } } } @@ -400,7 +405,7 @@ public final class AgentConnectionModel: ObservableObject { conversations[sessionID]?.errorMessage = stopReasonMessage(event["stopReason"] as? String) updateAttention() case "requestFailed": - requestFailed(token: token, sessionID: sessionID, message: event["message"] as? String ?? "The Agent request failed.") + requestFailed(token: token, sessionID: sessionID, message: event["message"] as? String ?? String(localized: "The Agent request failed.")) case "stopped": let old = detachConnection(failure: event["message"] as? String) if let old { @@ -494,8 +499,24 @@ public final class AgentConnectionModel: ObservableObject { applyConfiguration(update["configOptions"], to: sessionID) case "agent_message_chunk": guard let text = Self.text(of: update) else { return } - pendingText[sessionID, default: ""] += text - scheduleFlush() + buffer(text, role: .agent, in: sessionID) + case "agent_thought_chunk": + guard let text = Self.text(of: update) else { return } + buffer(text, role: .thought, in: sessionID) + case "plan": + guard let plan = AgentPlan.parse(update) else { return } + conversations[sessionID, default: AgentConversation()].plan = plan.entries.isEmpty ? nil : plan + case "available_commands_update": + guard let commands = AgentCommand.parse(update) else { return } + conversations[sessionID, default: AgentConversation()].availableCommands = commands + case "current_mode_update": + // Agents that expose modes as a config option may switch on their own, + // e.g. leaving plan mode; keep the selector on the reported mode. + guard let modeID = update["currentModeId"] as? String, + let index = conversations[sessionID]?.configOptions.firstIndex(where: { + $0.category == "mode" && $0.choices.contains { $0.id == modeID } + }) else { return } + conversations[sessionID]?.configOptions[index].currentValue = modeID case "user_message_chunk": guard let text = Self.text(of: update) else { return } flushPendingText() @@ -549,7 +570,7 @@ public final class AgentConnectionModel: ObservableObject { conversation.messages.append(AgentConversationMessage( id: id, role: .tool, - text: title ?? "Tool call", + text: title ?? String(localized: "Tool call"), toolStatus: status ?? .pending )) conversation.messages[conversation.messages.count - 1].toolDetails.merge(update) @@ -565,7 +586,7 @@ public final class AgentConnectionModel: ObservableObject { } var prompt = AgentPermissionPrompt( id: requestID, - title: tool?["title"] as? String ?? "Allow the Agent to continue?", + title: tool?["title"] as? String ?? String(localized: "Allow the Agent to continue?"), choices: options ) if let tool { @@ -606,7 +627,7 @@ public final class AgentConnectionModel: ObservableObject { conversation.isLoading = true conversations[sessionID] = conversation if !sendCommand(["kind": "loadSession", "token": token, "sessionId": sessionID]) { - requestFailed(token: token, sessionID: sessionID, message: errorMessage ?? "The Agent request failed.") + requestFailed(token: token, sessionID: sessionID, message: errorMessage ?? String(localized: "The Agent request failed.")) } } @@ -662,6 +683,7 @@ public final class AgentConnectionModel: ObservableObject { for id in conversations.keys { conversations[id]?.finishTurn(at: now()) conversations[id]?.contextUsage = nil + conversations[id]?.availableCommands = [] conversations[id]?.isResponding = false conversations[id]?.interruptPendingTools() conversations[id]?.isCancelling = false @@ -713,19 +735,25 @@ public final class AgentConnectionModel: ObservableObject { } } + private func buffer(_ text: String, role: AgentConversationMessage.Role, in sessionID: String) { + if let pending = pendingText[sessionID], pending.role != role { flushPendingText() } + pendingText[sessionID, default: (role, "")].text += text + scheduleFlush() + } + private func flushPendingText() { let pending = pendingText pendingText.removeAll() - for (sessionID, text) in pending where !text.isEmpty { - append(text, role: .agent, to: sessionID) + for (sessionID, chunk) in pending where !chunk.text.isEmpty { + append(chunk.text, role: chunk.role, to: sessionID) } } private func stopReasonMessage(_ reason: String?) -> String? { switch reason { - case "max_tokens": "The response stopped at the model's token limit." - case "max_turn_requests": "The Agent stopped after reaching its request limit for this turn." - case "refusal": "The Agent declined to continue." + case "max_tokens": String(localized: "The response stopped at the model's token limit.") + case "max_turn_requests": String(localized: "The Agent stopped after reaching its request limit for this turn.") + case "refusal": String(localized: "The Agent declined to continue.") default: nil } } diff --git a/macos/Sources/LitheAgentConversationModule/Application/AgentConversationState.swift b/macos/Sources/LitheAgentConversationModule/Application/AgentConversationState.swift index 15de2e934..75c465516 100644 --- a/macos/Sources/LitheAgentConversationModule/Application/AgentConversationState.swift +++ b/macos/Sources/LitheAgentConversationModule/Application/AgentConversationState.swift @@ -14,7 +14,8 @@ public struct AgentSessionSummary: Identifiable, Equatable, Sendable { } public struct AgentConversationMessage: Identifiable, Equatable, Sendable { - public enum Role: Equatable, Sendable { case user, agent, tool } + /// `thought` holds the agent's streamed reasoning, kept apart from its reply. + public enum Role: Equatable, Sendable { case user, agent, thought, tool } public enum ToolStatus: String, Equatable, Sendable { case pending case inProgress = "in_progress" @@ -55,6 +56,10 @@ public struct AgentPermissionPrompt: Identifiable, Equatable, Sendable { public struct AgentConversation: Equatable, Sendable { public var messages: [AgentConversationMessage] = [] public var contextUsage: AgentContextUsage? + /// Latest complete plan from the agent; history replay restores it. + public var plan: AgentPlan? + /// Slash commands the agent currently advertises for this session. + public var availableCommands: [AgentCommand] = [] public var activeTurn: AgentTurnStatistics? /// Local statistics survive tab switches and disconnects, but are not fabricated /// when the Agent replays history without timing or usage records. diff --git a/macos/Sources/LitheAgentConversationModule/Application/AgentHistoryFeatureModel.swift b/macos/Sources/LitheAgentConversationModule/Application/AgentHistoryFeatureModel.swift index 709ba8683..8ac223414 100644 --- a/macos/Sources/LitheAgentConversationModule/Application/AgentHistoryFeatureModel.swift +++ b/macos/Sources/LitheAgentConversationModule/Application/AgentHistoryFeatureModel.swift @@ -137,9 +137,14 @@ public struct AgentHistoryDocument: Sendable { switch message.role { case .user: role = String(localized: "You") case .agent: role = "Agent" + case .thought: role = String(localized: "Thinking") case .tool: role = String(localized: "Tool") } - var content = "## \(role)\n\n\(message.text)" + // Reasoning is exported as a quote so it stays distinct from the reply. + let body = message.role == .thought + ? message.text.components(separatedBy: .newlines).map { "> " + $0 }.joined(separator: "\n") + : message.text + var content = "## \(role)\n\n\(body)" if let status = message.toolStatus { content += "\n\nStatus: \(status.rawValue)" } for detail in [message.toolDetails.input, message.toolDetails.output].compactMap({ $0 }) { content += "\n\n\(detail)" diff --git a/macos/Sources/LitheAgentConversationModule/Application/AgentSessionConfiguration.swift b/macos/Sources/LitheAgentConversationModule/Application/AgentSessionConfiguration.swift index 0f331848d..c3f882849 100644 --- a/macos/Sources/LitheAgentConversationModule/Application/AgentSessionConfiguration.swift +++ b/macos/Sources/LitheAgentConversationModule/Application/AgentSessionConfiguration.swift @@ -81,16 +81,17 @@ public struct AgentToolDetails: Equatable, Sendable { case "content": guard let block = item["content"] as? [String: Any] else { return nil } if let text = block["text"] as? String { - return Content(title: "Output", text: Self.bounded(text)) + return Content(title: String(localized: "Output"), text: Self.bounded(text)) } - return Content(title: "Content", text: block["type"] as? String ?? "Unsupported content") + return Content(title: String(localized: "Content"), + text: block["type"] as? String ?? String(localized: "Unsupported content")) case "diff": let old = item["oldText"] as? String ?? "" let new = item["newText"] as? String ?? "" - return Content(title: item["path"] as? String ?? "Diff", + return Content(title: item["path"] as? String ?? String(localized: "Diff"), text: Self.bounded("---\n" + old + "\n+++\n" + new)) case "terminal": - return Content(title: "Terminal", text: item["terminalId"] as? String ?? "") + return Content(title: String(localized: "Terminal"), text: item["terminalId"] as? String ?? "") default: return nil } } diff --git a/macos/Sources/LitheAgentConversationModule/Application/AgentSessionGuidance.swift b/macos/Sources/LitheAgentConversationModule/Application/AgentSessionGuidance.swift new file mode 100644 index 000000000..d9442abed --- /dev/null +++ b/macos/Sources/LitheAgentConversationModule/Application/AgentSessionGuidance.swift @@ -0,0 +1,101 @@ +import Foundation + +/// The agent's current execution plan. ACP sends the complete plan on every +/// `plan` update, so a new value replaces the previous one. +public struct AgentPlan: Equatable, Sendable { + public struct Entry: Equatable, Sendable { + public enum Status: String, Equatable, Sendable { + case pending + case inProgress = "in_progress" + case completed + } + + public var content: String + /// Upstream priority label (`high`, `medium`, `low`); kept as reported. + public var priority: String? + public var status: Status + + public init(content: String, priority: String? = nil, status: Status) { + self.content = content + self.priority = priority + self.status = status + } + } + + public var entries: [Entry] + + public init(entries: [Entry]) { self.entries = entries } + + public var completedCount: Int { entries.filter { $0.status == .completed }.count } + public var isComplete: Bool { !entries.isEmpty && completedCount == entries.count } + /// The entry the agent is working on, or the next pending one. + public var currentEntry: Entry? { + entries.first { $0.status == .inProgress } ?? entries.first { $0.status == .pending } + } + + static let entryLimit = 100 + + /// An empty entry list is a valid update that clears the plan. + static func parse(_ update: [String: Any]) -> Self? { + guard let entries = update["entries"] as? [[String: Any]] else { return nil } + return Self(entries: entries.prefix(entryLimit).compactMap { entry in + guard let content = entry["content"] as? String, + let status = (entry["status"] as? String).flatMap(Entry.Status.init(rawValue:)) else { return nil } + return Entry(content: content, priority: entry["priority"] as? String, status: status) + }) + } +} + +/// A slash command the agent advertised for this session. Selecting one only +/// inserts `/name ` into the prompt; the agent interprets the sent text. +public struct AgentCommand: Identifiable, Equatable, Sendable { + public var name: String + public var description: String + /// Placeholder for the free-form input that follows the command name. + public var hint: String? + + public var id: String { name } + /// Codex lists skills as `$name` commands; they are mentioned as `$name`, not `/$name`. + public var isSkill: Bool { name.hasPrefix("$") } + /// Text the composer inserts to invoke the command. + public var invocation: String { isSkill ? name : "/" + name } + + public init(name: String, description: String, hint: String? = nil) { + self.name = name + self.description = description + self.hint = hint + } + + static let commandLimit = 200 + + static func parse(_ update: [String: Any]) -> [Self]? { + guard let commands = update["availableCommands"] as? [[String: Any]] else { return nil } + // A command name is its invocation and UI identity. Keep the first + // advertised definition when an upstream list repeats the same name. + var names = Set() + return commands.prefix(commandLimit).compactMap { command in + guard let name = command["name"] as? String, !name.isEmpty, + names.insert(name).inserted else { return nil } + let input = command["input"] as? [String: Any] + return Self(name: name, description: command["description"] as? String ?? "", + hint: (input?["hint"] as? String).flatMap { $0.isEmpty ? nil : $0 }) + } + } + + /// Commands matching the token being typed, or nil when the draft is not a + /// command prefix. `/` lists every command; `$` lists only skills, and only + /// when the agent advertises some, so a literal `$` is not intercepted. + public static func suggestions(for draft: String, in commands: [Self]) -> [Self]? { + guard let trigger = draft.first, trigger == "/" || trigger == "$" else { return nil } + let candidates = trigger == "$" ? commands.filter(\.isSkill) : commands + guard !candidates.isEmpty else { return nil } + let query = draft.dropFirst().lowercased() + guard !query.contains(where: \.isWhitespace) else { return nil } + func key(_ command: Self) -> String { + (command.isSkill ? String(command.name.dropFirst()) : command.name).lowercased() + } + let prefixed = candidates.filter { key($0).hasPrefix(query) } + let contained = candidates.filter { !key($0).hasPrefix(query) && key($0).contains(query) } + return prefixed + contained + } +} diff --git a/macos/Sources/LitheCoreContracts/Workspace/WorkspaceFeatureContracts.swift b/macos/Sources/LitheCoreContracts/Workspace/WorkspaceFeatureContracts.swift index e15099dd2..3a3b496a1 100644 --- a/macos/Sources/LitheCoreContracts/Workspace/WorkspaceFeatureContracts.swift +++ b/macos/Sources/LitheCoreContracts/Workspace/WorkspaceFeatureContracts.swift @@ -152,6 +152,7 @@ package struct ProjectItemDeletionRequest: Identifiable, Sendable { package let id: UUID package let url: URL package let isDirectory: Bool + package var additionalItems: [ProjectItemDeletionRequest] = [] package init(id: UUID = UUID(), url: URL, isDirectory: Bool) { self.id = id diff --git a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift index 05114b1bc..be39c16f1 100644 --- a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift +++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -1307,10 +1307,11 @@ package final class GitFeatureModel: ObservableObject { return staged } - package func beginSetStaging(_ changes: [GitChange], staged: Bool) -> [GitChange] { + package func beginSetStaging(_ changes: [GitChange], staged: Bool, includeWorkingTreeChanges: Bool = false) -> [GitChange] { guard !isCommitting else { return [] } let pendingChanges = changes.filter { - $0.canToggleStaging && pendingStagingStates[$0.id] == nil && $0.isStaged != staged + $0.canToggleStaging && pendingStagingStates[$0.id] == nil + && ($0.isStaged != staged || (staged && includeWorkingTreeChanges && $0.hasWorkingTreeChange)) } for change in pendingChanges { pendingStagingStates[change.id] = staged diff --git a/macos/Sources/LitheGitModule/Models/GitFileContextMenuPlan.swift b/macos/Sources/LitheGitModule/Models/GitFileContextMenuPlan.swift new file mode 100644 index 000000000..7805d8fd0 --- /dev/null +++ b/macos/Sources/LitheGitModule/Models/GitFileContextMenuPlan.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Decides which Git context-menu actions make sense for a file entry in the +/// project tree. Mirrors the Windows capability helper: a file without a Git +/// change entry gets no menu at all; partially staged files keep `Add` to +/// include remaining worktree edits. `canToggleStaging` is honored because +/// it is the established macOS gating flag for staging actions. +package struct GitFileContextMenuPlan: Equatable, Sendable { + package let showsAdd: Bool + package let showsStageAndOpenCommit: Bool + + package init(change: GitChange?) { + guard let change else { + self.showsAdd = false + self.showsStageAndOpenCommit = false + return + } + self.showsAdd = change.canToggleStaging && (!change.isStaged || change.hasWorkingTreeChange) + self.showsStageAndOpenCommit = true + } +} diff --git a/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift b/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift index f579620f3..ebf5a9d21 100644 --- a/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift +++ b/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift @@ -212,6 +212,11 @@ public enum OfficialPluginCatalog { private static let goLanguageID = "go" private static let phpLanguageID = "php" public static let phpPluginID = PluginID("dev.lithe.plugin.php-support") + public static let publisherPackageVendor = PluginVendor( + id: BuiltInPluginCatalog.vendor.id, + displayName: BuiltInPluginCatalog.vendor.displayName, + signatureRequirement: .publisherPackage + ) public static let manifests: [PluginManifest] = [ PluginManifest( @@ -276,7 +281,7 @@ public enum OfficialPluginCatalog { minimum: BuiltInPluginCatalog.hostVersion, maximumExclusive: PluginVersion(major: 0, minor: 4, patch: 0) ), - vendor: BuiltInPluginCatalog.vendor, + vendor: publisherPackageVendor, entrypoint: PluginEntrypoint( kind: .nativeBundle, bundleIdentifier: "dev.lithe.plugin.php-support.bundle", diff --git a/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift b/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift index 811a1080e..c897a5f75 100644 --- a/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift +++ b/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift @@ -99,6 +99,7 @@ public struct PluginHostCompatibility: Equatable, Codable, Sendable { public enum PluginSignatureRequirement: String, Codable, Sendable { case sameTeamAsHost + case publisherPackage } public struct PluginVendor: Equatable, Codable, Sendable { diff --git a/macos/Sources/LithePluginPackageSigning/PluginPackageSignature.swift b/macos/Sources/LithePluginPackageSigning/PluginPackageSignature.swift new file mode 100644 index 000000000..8d8a2ff94 --- /dev/null +++ b/macos/Sources/LithePluginPackageSigning/PluginPackageSignature.swift @@ -0,0 +1,242 @@ +import CryptoKit +import Foundation + +/// Signs and verifies the complete file tree of an official native plugin package. +/// +/// The package signature authenticates the release artifact independently from +/// Apple's code-signing identity. Native bundle validation remains required so +/// malformed or unsigned bundles cannot be loaded. +public enum PluginPackageSignature { + public static let schemaVersion = 1 + public static let algorithm = "ed25519" + public static let keyID = "lithe-official-plugins-v1" + /// Base64-encoded public key trusted for official separately distributed plugins. + public static let publisherPublicKeyBase64 = "5g83oIZu4TjOQr5g9KJcrNd2pgdXyEnvhJIXtrPoPyw=" + public static let signatureFileName = "lithe-plugin-signature.json" + + public struct Document: Codable, Equatable, Sendable { + public let schemaVersion: Int + public let algorithm: String + public let keyID: String + public let pluginID: String + public let pluginVersion: String + public let files: [String: String] + public let signature: String + + public init( + schemaVersion: Int = PluginPackageSignature.schemaVersion, + algorithm: String = PluginPackageSignature.algorithm, + keyID: String = PluginPackageSignature.keyID, + pluginID: String, + pluginVersion: String, + files: [String: String], + signature: String + ) { + self.schemaVersion = schemaVersion + self.algorithm = algorithm + self.keyID = keyID + self.pluginID = pluginID + self.pluginVersion = pluginVersion + self.files = files + self.signature = signature + } + } + + public enum Error: Swift.Error, Equatable, LocalizedError { + case invalidPackageRoot + case missingManifest + case invalidManifest + case unsupportedFile(String) + case fileListMismatch + case fileDigestMismatch(String) + case invalidDocument + case invalidSignature + case publicKeyUnavailable + + public var errorDescription: String? { + switch self { + case .invalidPackageRoot: + return "The plugin package root is invalid." + case .missingManifest: + return "The plugin package manifest is missing." + case .invalidManifest: + return "The plugin package manifest is invalid." + case .unsupportedFile(let path): + return "The plugin package contains an unsupported file: \(path)." + case .fileListMismatch: + return "The plugin package file list does not match its signature." + case .fileDigestMismatch(let path): + return "The plugin package file was changed after signing: \(path)." + case .invalidDocument: + return "The plugin package signature document is invalid." + case .invalidSignature: + return "The plugin package signature is invalid." + case .publicKeyUnavailable: + return "The official plugin signing key is unavailable." + } + } + } + + public static func makeDocument( + packageAt packageURL: URL, + pluginID: String, + pluginVersion: String, + privateKey: Curve25519.Signing.PrivateKey, + fileManager: FileManager = .default + ) throws -> Document { + let files = try fileDigests( + packageAt: packageURL, + fileManager: fileManager + ) + let payload = canonicalPayload( + pluginID: pluginID, + pluginVersion: pluginVersion, + files: files + ) + return Document( + pluginID: pluginID, + pluginVersion: pluginVersion, + files: files, + signature: try privateKey.signature(for: payload).base64EncodedString() + ) + } + + public static func verify( + packageAt packageURL: URL, + pluginID: String, + pluginVersion: String, + document: Document, + publicKey: Curve25519.Signing.PublicKey, + fileManager: FileManager = .default + ) throws { + guard document.schemaVersion == schemaVersion, + document.algorithm == algorithm, + document.keyID == keyID, + document.pluginID == pluginID, + document.pluginVersion == pluginVersion, + let signature = Data(base64Encoded: document.signature), + signature.count == 64 else { + throw Error.invalidDocument + } + + let actualFiles = try fileDigests( + packageAt: packageURL, + fileManager: fileManager + ) + guard Set(actualFiles.keys) == Set(document.files.keys) else { + throw Error.fileListMismatch + } + for path in actualFiles.keys.sorted() { + guard actualFiles[path] == document.files[path] else { + throw Error.fileDigestMismatch(path) + } + } + + let payload = canonicalPayload( + pluginID: document.pluginID, + pluginVersion: document.pluginVersion, + files: document.files + ) + guard publicKey.isValidSignature(signature, for: payload) else { + throw Error.invalidSignature + } + } + + public static func write( + _ document: Document, + to packageURL: URL, + fileManager: FileManager = .default + ) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + let data = try encoder.encode(document) + try data.write( + to: packageURL.appendingPathComponent(signatureFileName), + options: [.atomic] + ) + } + + public static func read( + from packageURL: URL, + fileManager: FileManager = .default + ) throws -> Document { + let signatureURL = packageURL.appendingPathComponent(signatureFileName) + guard fileManager.fileExists(atPath: signatureURL.path) else { + throw Error.invalidDocument + } + do { + return try JSONDecoder().decode(Document.self, from: Data(contentsOf: signatureURL)) + } catch { + throw Error.invalidDocument + } + } + + private static func fileDigests( + packageAt packageURL: URL, + fileManager: FileManager + ) throws -> [String: String] { + let root = packageURL.standardizedFileURL.resolvingSymlinksInPath() + guard (try? root.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true else { + throw Error.invalidPackageRoot + } + guard let enumerator = fileManager.enumerator( + at: root, + includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey], + options: [] + ) else { + throw Error.invalidPackageRoot + } + + var digests: [String: String] = [:] + for case let candidate as URL in enumerator { + let values = try candidate.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) + let resolvedCandidate = candidate.resolvingSymlinksInPath() + let relativePath = resolvedCandidate.path.replacingOccurrences(of: root.path + "/", with: "") + guard !relativePath.isEmpty else { continue } + guard values.isSymbolicLink != true else { + throw Error.unsupportedFile(relativePath) + } + if values.isDirectory == true { continue } + guard relativePath != signatureFileName else { continue } + guard !relativePath.hasPrefix("/"), + !relativePath.split(separator: "/", omittingEmptySubsequences: false).contains(".."), + !relativePath.contains("\0"), + !relativePath.contains("\n"), + !relativePath.contains("\r") else { + throw Error.unsupportedFile(relativePath) + } + let data = try Data(contentsOf: resolvedCandidate, options: [.mappedIfSafe]) + let digest = SHA256.hash(data: data) + .map { String(format: "%02x", $0) } + .joined() + digests[relativePath] = digest + } + return digests + } + + private static func canonicalPayload( + pluginID: String, + pluginVersion: String, + files: [String: String] + ) -> Data { + var lines = [ + "schemaVersion=\(schemaVersion)", + "algorithm=\(algorithm)", + "keyID=\(keyID)", + "pluginID=\(token(pluginID))", + "pluginVersion=\(token(pluginVersion))" + ] + lines.append(contentsOf: files.keys.sorted().map { path in + "file=\(token(path))=\(files[path]!)" + }) + return Data((lines.joined(separator: "\n") + "\n").utf8) + } + + private static func token(_ value: String) -> String { + value.data(using: .utf8)! + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift index 5051a39cb..807524627 100644 --- a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift +++ b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift @@ -598,6 +598,76 @@ package final class WorkspaceFeatureModel: ObservableObject { if request.kind == .createFile { openFile?(destination) } } + package func pasteProjectItems(_ urls: [URL], in directory: URL) async { + guard !isPerformingProjectItemOperation else { return } + isPerformingProjectItemOperation = true + let copied = await pasteProjectItemsWithinOperation(urls, in: directory) + isPerformingProjectItemOperation = false + if copied { await refreshCurrent() } + } + + /// Copies into one directory. The caller owns `isPerformingProjectItemOperation` + /// and the refresh, so a batch is not interleaved with another file operation. + private func pasteProjectItemsWithinOperation(_ urls: [URL], in directory: URL) async -> Bool { + guard isWorkspaceURL(directory), fileOperations.isDirectory(at: directory), + let operationWorkspaceURL = workspaceURL else { return false } + let generation = workspaceGeneration + guard let sources = validatedCopySources(urls, in: directory, workspace: operationWorkspaceURL) else { return false } + let fileOperations = self.fileOperations + let result = await Task.detached(priority: .userInitiated) { + Self.copyProjectItems(sources, in: directory, fileOperations: fileOperations) + }.value + guard workspaceURL == operationWorkspaceURL, workspaceGeneration == generation else { return false } + if let error = result.1 { notify?(error) } else { notify?("Pasted files") } + return result.0 > 0 + } + + private func validatedCopySources(_ urls: [URL], in directory: URL, workspace: URL) -> [URL]? { + let sources = Array(Set(urls.filter { $0.isFileURL }.map { $0.standardizedFileURL })) + .sorted { $0.path < $1.path } + let topLevelSources = sources.filter { source in + !sources.contains { parent in parent != source && source.path.hasPrefix(parent.path + "/") } + } + guard !topLevelSources.isEmpty else { return nil } + // Resolving links prevents a destination alias from copying a directory into itself. + let target = directory.resolvingSymlinksInPath().standardizedFileURL + let resolvedRoot = workspace.resolvingSymlinksInPath().standardizedFileURL + guard target == resolvedRoot || target.path.hasPrefix(resolvedRoot.path + "/") else { + notify?("Choose a directory inside the project") + return nil + } + for source in topLevelSources where fileOperations.isDirectory(at: source) { + let resolved = source.resolvingSymlinksInPath().standardizedFileURL + if target == resolved || target.path.hasPrefix(resolved.path + "/") { + notify?("Cannot copy a directory into itself") + return nil + } + } + return topLevelSources + } + + private nonisolated static func copyProjectItems( + _ sources: [URL], in directory: URL, fileOperations: any WorkspaceFileOperations + ) -> (Int, String?) { + var copied = 0 + do { + for source in sources { + var destination = directory.appendingPathComponent(source.lastPathComponent) + var index = 1 + while fileOperations.fileExists(at: destination) { + let ext = source.pathExtension + let base = ext.isEmpty ? source.lastPathComponent : source.deletingPathExtension().lastPathComponent + let suffix = index == 1 ? " copy" : " copy \(index)" + destination = directory.appendingPathComponent(base + suffix + (ext.isEmpty ? "" : "." + ext)) + index += 1 + } + try fileOperations.copyItem(at: source, to: destination) + copied += 1 + } + return (copied, nil) + } catch { return (copied, error.localizedDescription) } + } + package func duplicateProjectItem(at sourceURL: URL) async { guard !isPerformingProjectItemOperation, isWorkspaceURL(sourceURL), @@ -622,6 +692,46 @@ package final class WorkspaceFeatureModel: ObservableObject { } } + package func duplicateProjectItems(_ urls: [URL]) async { + guard !isPerformingProjectItemOperation else { return } + let generation = workspaceGeneration + let sources = topLevelProjectItems(urls) + isPerformingProjectItemOperation = true + var copied = false + for directory in Set(sources.map { $0.deletingLastPathComponent() }).sorted(by: { $0.path < $1.path }) { + guard workspaceGeneration == generation else { break } + copied = await pasteProjectItemsWithinOperation( + sources.filter { $0.deletingLastPathComponent() == directory }, in: directory + ) || copied + } + isPerformingProjectItemOperation = false + if copied { await refreshCurrent() } + } + + private func topLevelProjectItems(_ urls: [URL]) -> [URL] { + let sources = Array(Set(urls.map { $0.standardizedFileURL })) + .filter { isWorkspaceURL($0) && $0 != workspaceURL?.standardizedFileURL } + .sorted { $0.path < $1.path } + return sources.filter { source in + !sources.contains { parent in parent != source && source.path.hasPrefix(parent.path + "/") } + } + } + + package func requestDeleteProjectItems(_ urls: [URL]) { + guard !isPerformingProjectItemOperation else { return } + let sources = topLevelProjectItems(urls) + guard let first = sources.first else { return } + guard !sources.contains(where: hasDirtyDocument(in:)) else { + notify?("Save or discard unsaved files before deleting this item") + return + } + var request = ProjectItemDeletionRequest(url: first, isDirectory: fileOperations.isDirectory(at: first)) + request.additionalItems = sources.dropFirst().map { + ProjectItemDeletionRequest(url: $0, isDirectory: fileOperations.isDirectory(at: $0)) + } + pendingProjectItemDeletion = request + } + package func requestDeleteProjectItem(at url: URL, isDirectory: Bool) { guard !isPerformingProjectItemOperation, isWorkspaceURL(url), @@ -641,13 +751,44 @@ package final class WorkspaceFeatureModel: ObservableObject { if pendingProjectItemDeletion?.id == request.id { pendingProjectItemDeletion = nil } - guard !isPerformingProjectItemOperation, - isWorkspaceURL(request.url), - request.url.standardizedFileURL != workspaceURL?.standardizedFileURL else { return } - guard let operationWorkspaceURL = workspaceURL else { return } + guard !isPerformingProjectItemOperation else { return } + let generation = workspaceGeneration + var first = request + first.additionalItems = [] + let items = [first] + request.additionalItems + // The whole batch owns the operation flag, so no other file operation + // can start between items and make later items skip silently. + isPerformingProjectItemOperation = true + var changed = false + for (index, item) in items.enumerated() { + guard workspaceGeneration == generation else { break } + changed = await trashProjectItemWithinOperation(item) || changed + guard workspaceGeneration == generation else { break } + if fileOperations.fileExists(at: item.url) { + // Each item reports its own failure; also say that the rest + // of the batch was intentionally left in place. + if index < items.count - 1 { notify?("Stopped moving the remaining items to Trash") } + break + } + } + isPerformingProjectItemOperation = false + if changed { await refreshCurrent() } + } + + /// Moves one item to the Trash and returns whether the tree needs a refresh. + /// The caller owns `isPerformingProjectItemOperation` and the refresh. + private func trashProjectItemWithinOperation(_ request: ProjectItemDeletionRequest) async -> Bool { + guard isWorkspaceURL(request.url), + request.url.standardizedFileURL != workspaceURL?.standardizedFileURL, + let operationWorkspaceURL = workspaceURL else { return false } + // Editors stay usable while the dialog is open and while earlier batch + // items wait for the Trash, so the request-time check can be stale. + guard !hasDirtyDocument(in: request.url) else { + notify?("Save or discard unsaved files before deleting this item") + return false + } let operationWorkspaceGeneration = workspaceGeneration let operationDirectoryMarks = directoryMarks - isPerformingProjectItemOperation = true // Update the visible tree before waiting for the native Trash operation. // A failed operation reloads the disk snapshot below to restore the item. removeProjectItemFromSnapshot(request.url) @@ -666,11 +807,9 @@ package final class WorkspaceFeatureModel: ObservableObject { continuation.resume(returning: errorMessage) } } - isPerformingProjectItemOperation = false if let errorMessage { notify?(errorMessage) - await refreshCurrent() - return + return true } if request.isDirectory { let marksToRemoveFrom: [String: WorkspaceDirectoryMark] @@ -693,10 +832,19 @@ package final class WorkspaceFeatureModel: ObservableObject { ) } guard workspaceURL == operationWorkspaceURL, - workspaceGeneration == operationWorkspaceGeneration else { return } - closeDocuments?(request.url) + workspaceGeneration == operationWorkspaceGeneration else { return false } + if hasDirtyDocument(in: request.url) { + // Edits made while the Trash operation ran exist only in memory. + // Keep those documents open so the user can still save them. + for document in documentsProvider?() ?? [] + where !document.isDirty && urlContains(request.url, child: document.url) { + closeDocuments?(document.url) + } + } else { + closeDocuments?(request.url) + } notify?("Moved \(request.url.lastPathComponent) to Trash") - await refreshCurrent() + return true } package func readFile(at workspaceURL: URL, relativePath: String) async -> String? { @@ -1039,6 +1187,10 @@ package final class WorkspaceFeatureModel: ObservableObject { return childPath == parentPath || childPath.hasPrefix(parentPath + "/") } + private func hasDirtyDocument(in url: URL) -> Bool { + documentsProvider?().contains { $0.isDirty && urlContains(url, child: $0.url) } == true + } + private func removeProjectItemFromSnapshot(_ targetURL: URL) { projectFiles.removeAll { urlContains(targetURL, child: $0) } rootNode = rootNode.flatMap { removingProjectItem(targetURL, from: $0) } diff --git a/macos/Tests/LitheGitModuleTests/GitFileContextMenuPlanTests.swift b/macos/Tests/LitheGitModuleTests/GitFileContextMenuPlanTests.swift new file mode 100644 index 000000000..862c851e5 --- /dev/null +++ b/macos/Tests/LitheGitModuleTests/GitFileContextMenuPlanTests.swift @@ -0,0 +1,65 @@ +import Foundation +@testable import LitheGitModule +import Testing + +@Suite("Git file context menu plan") +struct GitFileContextMenuPlanTests { + private func makeChange( + indexStatus: Character, + workTreeStatus: Character, + canToggleStaging: Bool = true + ) -> GitChange { + GitChange( + repositoryRoot: URL(fileURLWithPath: "/repo"), + path: "src/App.java", + originalPath: nil, + indexStatus: indexStatus, + workTreeStatus: workTreeStatus, + canToggleStaging: canToggleStaging + ) + } + + @Test("No change entry hides every action") + func hidesWithoutChange() { + let plan = GitFileContextMenuPlan(change: nil) + #expect(plan.showsAdd == false) + #expect(plan.showsStageAndOpenCommit == false) + } + + @Test("Unstaged modification keeps Add") + func unstagedModification() { + let plan = GitFileContextMenuPlan(change: makeChange(indexStatus: " ", workTreeStatus: "M")) + #expect(plan.showsAdd == true) + #expect(plan.showsStageAndOpenCommit == true) + } + + @Test("Staged entry hides Add because staging again is a no-op") + func stagedEntry() { + let plan = GitFileContextMenuPlan(change: makeChange(indexStatus: "M", workTreeStatus: " ")) + #expect(plan.showsAdd == false) + #expect(plan.showsStageAndOpenCommit == true) + } + + @Test("Untracked file can be added and committed") + func untrackedFile() { + let plan = GitFileContextMenuPlan(change: makeChange(indexStatus: "?", workTreeStatus: "?")) + #expect(plan.showsAdd == true) + #expect(plan.showsStageAndOpenCommit == true) + } + + @Test("Partially staged files retain Add", arguments: [Character("M"), Character("A")]) + func partiallyStagedFile(indexStatus: Character) { + let plan = GitFileContextMenuPlan(change: makeChange(indexStatus: indexStatus, workTreeStatus: "M")) + #expect(plan.showsAdd) + #expect(plan.showsStageAndOpenCommit) + } + + @Test("Add honors the canToggleStaging gate") + func stagingGate() { + let plan = GitFileContextMenuPlan( + change: makeChange(indexStatus: " ", workTreeStatus: "M", canToggleStaging: false) + ) + #expect(plan.showsAdd == false) + #expect(plan.showsStageAndOpenCommit == true) + } +} diff --git a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift index 1133a2aa1..4e48d1c52 100644 --- a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift +++ b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift @@ -617,6 +617,17 @@ struct GitModuleTests { #expect(feature.beginSetStaging([change], staged: true).isEmpty) } + @Test(arguments: [Character("M"), Character("A")]) + func explicitStagingIncludesRemainingEditsWithoutChangingCheckboxSemantics(indexStatus: Character) { + let feature = GitFeatureModel(service: GitService(operations: TestGitOperations())) + defer { feature.reset() } + let change = GitChange(repositoryRoot: URL(fileURLWithPath: "/workspace"), path: "Main.java", + originalPath: nil, indexStatus: indexStatus, workTreeStatus: "M") + #expect(feature.beginSetStaging([change], staged: true).isEmpty) + #expect(feature.beginSetStaging([change], staged: true, includeWorkingTreeChanges: true) == [change]) + #expect(feature.beginSetStaging([change], staged: true, includeWorkingTreeChanges: true).isEmpty) + } + @Test func workspaceCommitConfirmationUsesTheSharedPlanAndForwardsOptions() async throws { let preparation = try workspacePreparation() diff --git a/macos/Tests/LitheGitPerformanceTests/GitGraphPerformanceBaselineTests.swift b/macos/Tests/LitheGitPerformanceTests/GitGraphPerformanceBaselineTests.swift index 8a0550d76..4ad9656d9 100644 --- a/macos/Tests/LitheGitPerformanceTests/GitGraphPerformanceBaselineTests.swift +++ b/macos/Tests/LitheGitPerformanceTests/GitGraphPerformanceBaselineTests.swift @@ -74,17 +74,25 @@ struct GitGraphPerformanceBaselineTests { #expect(benchmark.viewportDrawReductionPercent == 97.5) } - @Test("The native graph view frame sample stays within the test budget") + @Test("The native graph view samples only a visible viewport", arguments: [0, 480, 960]) @MainActor - func nativeGraphViewFrameSample() { - let commits = SyntheticGitGraphFixture.mergeHeavy(commitCount: 1_000) + func nativeGraphViewFrameSample(startRow: Int) throws { + let rowCount = 1_000 + let viewportRowCount = 40 + let rowHeight = GitGraphGeometry.rowHeight + let commits = SyntheticGitGraphFixture.mergeHeavy(commitCount: rowCount) let layout = GitGraphLayoutService.layout(commits: commits) - let view = GitGraphNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 1_000 * 30)) - view.update(snapshot: GitGraphLayoutService.routingSnapshot(for: layout), width: 120, rowHeight: 30) - let bitmap = NSBitmapImageRep( + let view = GitGraphNSView(frame: NSRect(x: 0, y: 0, width: 120, height: CGFloat(rowCount) * rowHeight)) + view.update(snapshot: GitGraphLayoutService.routingSnapshot(for: layout), width: 120, rowHeight: rowHeight) + // Keep the complete history, but draw a viewport as AppKit does while + // scrolling. Repainting all 1,000 rows 220 times measures bulk raster + // work rather than a frame and can exceed the unchanged CI test budget. + let viewport = NSRect(x: 0, y: CGFloat(startRow) * rowHeight, + width: 120, height: CGFloat(viewportRowCount) * rowHeight) + let bitmap = try #require(NSBitmapImageRep( bitmapDataPlanes: nil, pixelsWide: 120, - pixelsHigh: 1_000 * 30, + pixelsHigh: Int(viewport.height), bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, @@ -93,26 +101,38 @@ struct GitGraphPerformanceBaselineTests { bitmapFormat: [], bytesPerRow: 0, bitsPerPixel: 0 - )! - let context = NSGraphicsContext(bitmapImageRep: bitmap)! + )) + let context = try #require(NSGraphicsContext(bitmapImageRep: bitmap)) + context.cgContext.clear(NSRect(origin: .zero, size: viewport.size)) + // Mid-history view coordinates must land inside the viewport bitmap; + // otherwise clipping would make an empty render look artificially fast. + context.cgContext.translateBy(x: 0, y: -viewport.minY) let clock = ContinuousClock() var samples: [Double] = [] samples.reserveCapacity(10) for _ in 0..<1 { - _ = sampleFrame(view: view, context: context, clock: clock) + _ = sampleFrame(view: view, dirtyRect: viewport, context: context, clock: clock) } for _ in 0..<10 { - samples.append(sampleFrame(view: view, context: context, clock: clock)) + samples.append(sampleFrame(view: view, dirtyRect: viewport, context: context, clock: clock)) } let sorted = samples.sorted() let median = sorted[sorted.count / 2] let p95 = sorted[Int(ceil(Double(sorted.count) * 0.95)) - 1] let maximum = sorted.last ?? 0 - print("GitGraph frame sample: rows=1000, samples=10, median=\(String(format: "%.3f", median))ms, p95=\(String(format: "%.3f", p95))ms, max=\(String(format: "%.3f", maximum))ms") + print("GitGraph frame sample: rows=\(rowCount), viewportRows=\(viewportRowCount), startRow=\(startRow), samples=10, median=\(String(format: "%.3f", median))ms, p95=\(String(format: "%.3f", p95))ms, max=\(String(format: "%.3f", maximum))ms") #expect(samples.count == 10) #expect(median < 100) + let bytes = try #require(bitmap.bitmapData) + // Only inspect pixel alpha, never uninitialized row-padding bytes. + let didPaint = (0.. Double { @@ -997,7 +1018,7 @@ private func sampleFrame( NSGraphicsContext.saveGraphicsState() NSGraphicsContext.current = context for _ in 0..<20 { - view.draw(view.bounds) + view.draw(dirtyRect) } NSGraphicsContext.restoreGraphicsState() return milliseconds(clock.now - start) / 20 diff --git a/macos/Tests/LitheTests/AgentBrandIconResourceTests.swift b/macos/Tests/LitheTests/AgentBrandIconResourceTests.swift index 4e85e96d6..04f429c60 100644 --- a/macos/Tests/LitheTests/AgentBrandIconResourceTests.swift +++ b/macos/Tests/LitheTests/AgentBrandIconResourceTests.swift @@ -1,10 +1,49 @@ import AppKit +import SwiftUI import Testing @testable import Lithe @MainActor @Suite("Agent brand icon resources") struct AgentBrandIconResourceTests { + @Test(arguments: ["Claude", "Codex"], [false, true]) + func composerBrandMarksStayVisibleInNativeMenus(name: String, isDark: Bool) throws { + let host = NSHostingView(rootView: AgentComposerView( + agents: [.init(id: "example-agent", name: name)], + selectedAgent: .init(id: "example-agent", name: name), + isResponding: false, isBlocked: false, onSend: { _, _ in }, onCancel: {}, + onSelectAgent: { _ in }, onOpenSettings: {}, onError: { _ in } + ).environment(\.colorScheme, isDark ? .dark : .light)) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 480, height: 200), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: isDark ? .darkAqua : .aqua) + defer { window.close() } + window.contentView = host + host.layoutSubtreeIfNeeded() + + // Crop the real native Menu label, excluding neighboring controls and + // the text-field caret. No visible window or image baseline is required. + let region = NSRect(x: 46, y: host.isFlipped ? host.bounds.height - 42 : 10, width: 28, height: 26) + let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: region)) + host.cacheDisplay(in: region, to: bitmap) + var markPixels = 0 + for y in 0.. 0.5, red > green * 1.3, green > blue * 1.1 { markPixels += 1 } + } else if abs(red - green) < 0.05, abs(green - blue) < 0.05 { + if isDark ? min(red, green, blue) > 0.6 : max(red, green, blue) < 0.4 { markPixels += 1 } + } + } + } + #expect(markPixels > 10, "The native selector must show a visible orange Claude or contrasting Codex mark") + #expect(try #require(AgentBrandIconLoader.image(name: name, size: 18)).isTemplate, + "Coloring the toolbar must not mutate the shared template used by other views") + } + @Test func installedAppLoadsBothMarksWithoutDevelopmentResources() throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) diff --git a/macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift b/macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift index 451b897b7..a9f8a9080 100644 --- a/macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift +++ b/macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift @@ -309,6 +309,146 @@ struct AgentConversationFeatureModelTests { #expect(feature.sessions.first?.title == "Project overview") } + @Test + func interleavedReasoningAndReplyBecomeSeparateMessagesInArrivalOrder() throws { + let (feature, _) = try respondingFeature() + // Thought and reply chunks share one flush window; a role change must + // still end the previous segment instead of merging the texts. + try feature.receive(event("agentThoughtChunk")) + try feature.receive(event("agentThoughtChunk", ["update": ["sessionUpdate": "agent_thought_chunk", + "content": ["type": "text", "text": " Then test."]]])) + try feature.receive(event("agentMessageChunk")) + try feature.receive(event("toolCall")) + try feature.receive(event("agentThoughtChunk")) + try feature.receive(event("turnFinished")) + + let messages = try #require(feature.selectedConversation?.messages) + #expect(messages.map(\.role) == [.user, .thought, .agent, .tool, .thought]) + #expect(messages[1].text == "Read the manifest first. Then test.") + #expect(messages[2].text == "This project **builds** an IDE.") + #expect(messages[4].text == "Read the manifest first.") + let document = AgentHistoryDocument(id: "session-1", title: "Sample", messages: messages) + let markdown = AgentHistoryDocument.markdown([document]) + #expect(markdown.contains("> Read the manifest first. Then test.")) + #expect(markdown.contains("This project **builds** an IDE.")) + #expect(!markdown.contains("> This project")) + } + + @Test + func planUpdatesReplaceTheWholePlanAndAnEmptyPlanClearsIt() throws { + let (feature, _) = try respondingFeature() + try feature.receive(event("plan")) + let plan = try #require(feature.selectedConversation?.plan) + #expect(plan.entries.map(\.status) == [.completed, .inProgress, .pending]) + #expect(plan.entries.first?.priority == "high") + #expect(plan.completedCount == 1) + #expect(plan.currentEntry?.content == "Run the tests") + #expect(!plan.isComplete) + + try feature.receive(event("plan", ["update": ["sessionUpdate": "plan", "entries": [ + ["content": "Summarize the results", "priority": "low", "status": "completed"], + ["content": "Unknown status", "priority": "low", "status": "blocked"] + ]]])) + let replaced = try #require(feature.selectedConversation?.plan) + #expect(replaced.entries.map(\.content) == ["Summarize the results"]) + #expect(replaced.isComplete) + #expect(replaced.currentEntry == nil) + + try feature.receive(event("plan", ["update": ["sessionUpdate": "plan", "entries": [] as [Any]]])) + #expect(feature.selectedConversation?.plan == nil) + // A malformed update leaves the plan state unchanged rather than inventing one. + try feature.receive(event("plan", ["update": ["sessionUpdate": "plan"]])) + #expect(feature.selectedConversation?.plan == nil) + } + + @Test + func advertisedCommandsArePerSessionAndFilterOnlyWhileTypingAName() throws { + let (feature, connection) = try respondingFeature() + try feature.receive(event("availableCommands")) + let commands = try #require(feature.selectedConversation?.availableCommands) + #expect(commands.map(\.name) == ["review", "compact"]) + #expect(commands[0].hint == "optional focus") + #expect(commands[1].hint == nil) + + #expect(AgentCommand.suggestions(for: "/", in: commands)?.map(\.name) == ["review", "compact"]) + #expect(AgentCommand.suggestions(for: "/CO", in: commands)?.map(\.name) == ["compact"]) + // Prefix matches rank before names that only contain the query. + #expect(AgentCommand.suggestions(for: "/e", in: commands)?.map(\.name) == ["review"]) + #expect(AgentCommand.suggestions(for: "/zz", in: commands)?.isEmpty == true) + #expect(AgentCommand.suggestions(for: "/review ", in: commands) == nil) + #expect(AgentCommand.suggestions(for: "review", in: commands) == nil) + #expect(AgentCommand.suggestions(for: "/", in: []) == nil) + #expect(commands.map(\.invocation) == ["/review", "/compact"]) + // A dollar sign is ordinary text unless the agent advertises skills. + #expect(AgentCommand.suggestions(for: "$", in: commands) == nil) + + // codex-acp lists skills as `$name` commands and ignores `/$name`, so a + // skill is inserted as a `$` mention and `$` filters to skills only. + let withSkills = commands + [AgentCommand(name: "$pdf", description: "Read PDF files"), + AgentCommand(name: "$spreadsheets", description: "Edit sheets")] + #expect(withSkills[2].isSkill && !withSkills[0].isSkill) + #expect(withSkills[2].invocation == "$pdf") + #expect(AgentCommand.suggestions(for: "$", in: withSkills)?.map(\.name) == ["$pdf", "$spreadsheets"]) + #expect(AgentCommand.suggestions(for: "$sp", in: withSkills)?.map(\.name) == ["$spreadsheets"]) + #expect(AgentCommand.suggestions(for: "/pd", in: withSkills)?.map(\.name) == ["$pdf"]) + #expect(AgentCommand.suggestions(for: "$pdf ", in: withSkills) == nil) + + // Another session has its own list; the command text is sent as typed. + feature.startNewConversation() + try feature.receive(event("sessionCreated", ["token": connection.commands.last?["token"] as Any, + "sessionId": "session-2"])) + #expect(feature.selectedConversation?.availableCommands.isEmpty == true) + try feature.send("/review src") + #expect(connection.commands.last?["text"] as? String == "/review src") + try feature.receive(event("availableCommands", ["sessionId": "session-2", "update": [ + "sessionUpdate": "available_commands_update", "availableCommands": [] as [Any] + ]])) + #expect(feature.conversations["session-1"]?.availableCommands.count == 2) + } + + @Test + func duplicateAdvertisedCommandsKeepTheFirstDefinitionAndUniqueRowIdentities() throws { + let (feature, _) = try respondingFeature() + let advertised: [[String: Any]] = [ + ["name": "review", "description": "First definition", "input": ["hint": "focus"]], + ["name": "review", "description": "Repeated definition"], + ["name": "compact", "description": "Compact the conversation"] + ] + (0..<201).map { ["name": "command-\($0)"] } + try feature.receive(event("availableCommands", ["update": [ + "sessionUpdate": "available_commands_update", "availableCommands": advertised + ]])) + let commands = try #require(feature.selectedConversation?.availableCommands) + #expect(commands.count == 199, "Only the first 200 upstream entries are consumed") + #expect(Set(commands.map(\.id)).count == commands.count) + #expect(commands.prefix(2).map(\.name) == ["review", "compact"]) + #expect(commands[0].description == "First definition") + #expect(commands[0].hint == "focus") + #expect(AgentCommand.suggestions(for: "/re", in: commands)?.count == 1) + } + + @Test + func agentReportedModeChangeMovesOnlyTheMatchingModeSelector() throws { + let (feature, connection) = try connectedFeature() + feature.prepareConversation() + let configured = try #require(JSONSerialization.jsonObject(with: Data(event("sessionConfigured").utf8)) as? [String: Any]) + try feature.receive(event("sessionCreated", ["token": connection.commands.last?["token"] as Any, + "configOptions": configured["configOptions"] as Any])) + func current(_ category: String) -> String? { + feature.selectedConversation?.configOptions.first { $0.category == category }?.currentValue + } + #expect(current("mode") == "read-only") + let commandCount = connection.commands.count + try feature.receive(event("currentModeUpdate")) + #expect(current("mode") == "auto") + #expect(current("model") == "example-model") + // The agent already switched; echoing it back as a request would be wrong. + #expect(connection.commands.count == commandCount) + // A mode the selector does not offer is ignored rather than shown as a raw id. + try feature.receive(event("currentModeUpdate", ["update": ["sessionUpdate": "current_mode_update", + "currentModeId": "unlisted"]])) + #expect(current("mode") == "auto") + } + @Test func permissionChoicesAreAnsweredOrRejectedByCancel() throws { let (feature, connection) = try respondingFeature() @@ -403,11 +543,17 @@ struct AgentConversationFeatureModelTests { try feature.send("Explain this project") try feature.receive(event("sessionCreated", ["token": transport.connections[0].commands.last?["token"] as Any])) + try feature.receive(event("availableCommands")) + #expect(feature.selectedConversation?.availableCommands.count == 2) + try feature.receive(event("availableCommands", ["sessionId": "session-2"])) + #expect(feature.conversations["session-2"]?.availableCommands.count == 2) + try feature.receive(event("stopped")) #expect(feature.connectionState == .failed("The Agent connection closed unexpectedly")) #expect(feature.selectedConversation?.isResponding == false) #expect(feature.selectedConversation?.isAttached == false) #expect(feature.selectedConversation?.messages.count == 1) + #expect(feature.conversations.values.allSatisfy { $0.availableCommands.isEmpty }) #expect(throws: AgentConversationError.notConnected) { try feature.send("again") } await feature.stop() @@ -416,7 +562,13 @@ struct AgentConversationFeatureModelTests { try feature.receive(event("ready")) try feature.send("again") #expect(transport.connections[1].commands.last?["kind"] as? String == "loadSession") + #expect(feature.selectedConversation?.availableCommands.isEmpty == true) + try feature.receive(event("sessionLoaded", ["token": transport.connections[1].commands.last?["token"] as Any])) + try feature.receive(event("availableCommands", ["update": ["sessionUpdate": "available_commands_update", + "availableCommands": [["name": "new-process-command", "description": "Current capabilities"]]]])) + #expect(feature.selectedConversation?.availableCommands.map(\.name) == ["new-process-command"]) await feature.stop() + #expect(feature.selectedConversation?.availableCommands.isEmpty == true) #expect(transport.connections[1].closeCount == 1) } diff --git a/macos/Tests/LitheTests/AgentConversationPresentationTests.swift b/macos/Tests/LitheTests/AgentConversationPresentationTests.swift index ccde395e4..049aebb15 100644 --- a/macos/Tests/LitheTests/AgentConversationPresentationTests.swift +++ b/macos/Tests/LitheTests/AgentConversationPresentationTests.swift @@ -104,6 +104,228 @@ struct AgentConversationPresentationTests { #expect(AgentTranscriptItem.grouped(updated).map(\.id) == grouped.map(\.id)) } + @Test + func planReasoningAndCommandListsFitNarrowAndWidePanelsInBothAppearances() throws { + let plan = AgentPlan(entries: [ + .init(content: "Read the manifest and the build scripts of the sample project", priority: "high", status: .completed), + .init(content: "Run the tests", priority: "medium", status: .inProgress), + .init(content: "Summarize the results", priority: "low", status: .pending) + ]) + let commands = [AgentCommand(name: "review", description: "Review the current changes before committing", hint: "optional focus"), + AgentCommand(name: "compact", description: "Summarize the conversation to free context", hint: nil)] + for (name, scheme, width) in [("dark-narrow", ColorScheme.dark, 280.0), ("light-narrow", .light, 280.0), + ("dark-wide", .dark, 620.0), ("light-wide", .light, 620.0)] { + let host = NSHostingView(rootView: VStack(alignment: .leading, spacing: 10) { + AgentThoughtRow(text: "The manifest names the entry point, so read it first.", isStreaming: true, + expansion: .constant(AgentThoughtExpansion())) + AgentCommandSuggestionList(commands: commands, highlightedIndex: 0, onSelect: { _ in }) + AgentPlanView(plan: plan, isResponding: true) + }.padding(12).frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(AgentPanelStyle.canvas).environment(\.colorScheme, scheme)) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: width, height: 260), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + defer { window.close() } + window.contentView = host + host.frame.size = NSSize(width: width, height: 260) + host.layoutSubtreeIfNeeded() + #expect(host.fittingSize.height <= 260, "Reasoning, commands and plan summary must fit a short panel") + if let folder = ProcessInfo.processInfo.environment["LITHE_AGENT_STATISTICS_SCREENSHOTS"] { + let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: bitmap) + let data = try #require(bitmap.representation(using: .png, properties: [:])) + try data.write(to: URL(fileURLWithPath: folder).appendingPathComponent("guidance-\(name).png")) + } + } + } + + @Test + func reasoningSplitsToolTimelinesAndIsSearchableButNotCountedAsAMessage() throws { + let messages = [ + AgentConversationMessage(id: "list", role: .tool, text: "List files"), + AgentConversationMessage(id: "why", role: .thought, text: "The manifest names the entry point"), + AgentConversationMessage(id: "read", role: .tool, text: "Read file"), + AgentConversationMessage(id: "reply", role: .agent, text: "Done") + ] + let items = AgentTranscriptItem.grouped(messages) + #expect(items.map(\.id) == ["tools:list", "why", "tools:read", "reply"]) + #expect(items.filter { $0.matches("entry point") }.map(\.id) == ["why"]) + + var conversation = AgentConversation() + conversation.isAttached = true + conversation.messages = [AgentConversationMessage(role: .user, text: "Explain")] + messages + #expect(AgentHistoryPresentation.messageCount(conversation) == 2) + } + + @Test + func thoughtSearchRevealsAManuallyCollapsedMatchAndRestoresItsPreference() { + var expansion = AgentThoughtExpansion() + #expect(expansion.isExpanded(isStreaming: true, isSearching: false)) + expansion.toggle(isStreaming: true, isSearching: false) + #expect(!expansion.isExpanded(isStreaming: true, isSearching: false)) + + // Search changes the effective disclosure without replacing the stored preference. + #expect(expansion.isExpanded(isStreaming: false, isSearching: true)) + expansion.toggle(isStreaming: false, isSearching: true) + #expect(expansion.isExpanded(isStreaming: false, isSearching: true)) + #expect(!expansion.isExpanded(isStreaming: false, isSearching: false)) + + expansion.toggle(isStreaming: false, isSearching: false) + #expect(expansion.isExpanded(isStreaming: false, isSearching: true)) + #expect(expansion.isExpanded(isStreaming: false, isSearching: false)) + } + + @Test + func thoughtStreamingCollapsesAutomaticallyButKeepsManualExpansion() { + var expansion = AgentThoughtExpansion() + #expect(expansion.isExpanded(isStreaming: true, isSearching: false)) + #expect(!expansion.isExpanded(isStreaming: false, isSearching: false)) + #expect(expansion.isExpanded(isStreaming: false, isSearching: true)) + #expect(!expansion.isExpanded(isStreaming: false, isSearching: false)) + expansion.toggle(isStreaming: false, isSearching: false) + #expect(expansion.isExpanded(isStreaming: true, isSearching: false)) + #expect(expansion.isExpanded(isStreaming: false, isSearching: false)) + } + + @Test + func commandKeysCompleteWithoutSendingAndSubmitFullInvocations() { + let commands = [AgentCommand(name: "review", description: "Review"), + AgentCommand(name: "compact", description: "Compact"), + AgentCommand(name: "$pdf", description: "Read PDFs")] + var completion = AgentCommandCompletion() + completion.draft = "/" + #expect(completion.handle(.up, commands: commands, isResponding: false) == .handled) + #expect(completion.highlightedIndex == 2) + #expect(completion.handle(.down, commands: commands, isResponding: false) == .handled) + #expect(completion.highlightedIndex == 0) + #expect(completion.handle(.down, commands: commands, isResponding: false) == .handled) + #expect(completion.handle(.tab, commands: commands, isResponding: false) == .handled) + #expect(completion.draft == "/compact ") + #expect(completion.suggestions(in: commands) == nil) + #expect(completion.handle(.submit, commands: commands, isResponding: false) == .send) + + completion.draft = "/re" + #expect(completion.handle(.submit, commands: commands, isResponding: false) == .handled) + #expect(completion.draft == "/review ") + completion.draft = "/review" + #expect(completion.handle(.submit, commands: commands, isResponding: false) == .send) + #expect(completion.draft == "/review") + completion.draft = "$pd" + #expect(completion.handle(.tab, commands: commands, isResponding: false) == .handled) + #expect(completion.draft == "$pdf ") + } + + @Test + func escapeDismissesSuggestionsBeforeCancellingAndEditingReopensThem() { + let commands = [AgentCommand(name: "review", description: "Review")] + var completion = AgentCommandCompletion() + completion.draft = "/" + #expect(completion.handle(.escape, commands: commands, isResponding: true) == .handled) + #expect(completion.draft == "/") + #expect(completion.suggestions(in: commands) == nil) + #expect(completion.handle(.escape, commands: commands, isResponding: true) == .cancel) + #expect(completion.handle(.escape, commands: commands, isResponding: false) == .ignored) + completion.draft = "/r" + #expect(completion.suggestions(in: commands)?.count == 1) + completion.draft = "/" + #expect(completion.suggestions(in: commands)?.count == 1) + completion.draft = "/missing" + #expect(completion.suggestions(in: commands)?.isEmpty == true) + #expect(completion.handle(.escape, commands: commands, isResponding: true) == .handled) + #expect(completion.handle(.escape, commands: commands, isResponding: true) == .cancel) + } + + @Test + func unavailableCommandKeysLeaveOrdinaryTypingAndChangedListsUsable() { + let commands = [AgentCommand(name: "review", description: "Review"), + AgentCommand(name: "compact", description: "Compact")] + var completion = AgentCommandCompletion() + for draft in ["text", "$", "/missing", "/review argument"] { + completion.draft = draft + for key in [AgentCommandCompletion.Key.up, .down, .tab] { + #expect(completion.handle(key, commands: commands, isResponding: false) == .ignored) + #expect(completion.draft == draft) + } + #expect(completion.handle(.submit, commands: commands, isResponding: false) == .send) + } + completion.draft = "/" + #expect(completion.handle(.up, commands: commands, isResponding: false) == .handled) + // A fresh upstream list can be shorter while the same draft is focused. + #expect(completion.handle(.tab, commands: Array(commands.prefix(1)), isResponding: false) == .handled) + #expect(completion.draft == "/review ") + completion.draft = "/" + #expect(completion.handle(.tab, commands: [], isResponding: false) == .ignored) + } + + @Test + func activeThoughtKeepsOneThinkingLabelAndTheWaitingTimer() { + #expect(AgentThinkingRow().status == String(localized: "Thinking…")) + #expect(AgentThinkingRow(hasStreamingThought: true).status == String(localized: "Responding…")) + #expect(AgentThinkingRow(isCancelling: true, hasStreamingThought: true).status == String(localized: "Stopping…")) + } + + @Test + func commandSuggestionsKeepAWritingLineAtDefaultAndMinimumComposerHeights() throws { + let commands = (0..<200).map { + AgentCommand(name: "command-\($0)", description: "An upstream command with a long description", hint: nil) + } + let attachment = try AgentFileReference(url: URL(fileURLWithPath: "/example/project/README.md")) + for height in [400.0, 700.0] { + for width in [280.0, 620.0] { + for files in [[], [attachment]] { + for count in [0, 1, 40, 200] { + var editor: NSView? + var composer: NSView? + let host = NSHostingView(rootView: AgentConversationLayout { + Color.clear + } composer: { + AgentComposerContent(files: files, commands: Array(commands.prefix(count)), highlightedIndex: 0, + onSelect: { _ in }, onRemoveFile: { _ in }, onFocus: {}) { + Color.clear.frame(height: AgentComposerMetrics.contextHeight) + } editor: { + TextField("Message the Agent", text: .constant("/"), axis: .vertical) + .textFieldStyle(.plain).font(.system(size: 13)).lineLimit(1...) + .background(AgentDraftFrameProbe { editor = $0 }) + .padding(.horizontal, 8).padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .topLeading) + } toolbar: { + Color.clear.frame(height: AgentComposerMetrics.toolbarHeight) + } + .padding(.horizontal, 8).padding(.bottom, AgentComposerMetrics.bottomInset) + .background(AgentDraftFrameProbe { composer = $0 }) + }) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: width, height: height), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + defer { window.close() } + window.contentView = host + host.frame.size = NSSize(width: width, height: height) + host.layoutSubtreeIfNeeded() + let input = try #require(editor) + let frame = host.convert(input.bounds, from: input) + let composerView = try #require(composer) + let pane = host.convert(composerView.bounds, from: composerView) + #expect(pane.height + AgentComposerMetrics.splitTopInset >= AgentComposerMetrics.minimumHeight(hasFiles: !files.isEmpty) - 0.5) + #expect(frame.height > 0, "The actual text field must survive \(count) commands and \(files.count) attachments") + #expect(pane.insetBy(dx: -0.5, dy: -0.5).contains(frame), "The writing line must stay inside the sized pane") + let writingScroll = try #require(enclosingScroll(of: input)) + #expect(writingScroll.bounds.height >= AgentComposerMetrics.writingLineHeight - 0.5) + if count > 0 { + let list = try #require(commandScroll(in: host, excluding: input)) + let listFrame = host.convert(list.bounds, from: list) + #expect(listFrame.height >= 26, "Suggestions must retain a complete, selectable row") + #expect(host.bounds.insetBy(dx: -0.5, dy: -0.5).contains(listFrame), + "The floating list must stay inside its conversation scope") + let isAboveInput = host.isFlipped ? listFrame.maxY <= frame.minY + 0.5 + : listFrame.minY >= frame.maxY - 0.5 + #expect(isAboveInput, "The floating list must not cover the writing line") + } + } + } + } + } + } + @Test func toolSearchKeepsTheOriginalGroupWhenEvidenceMatches() throws { var first = AgentConversationMessage(id: "list", role: .tool, text: "List files") @@ -266,4 +488,27 @@ struct AgentConversationPresentationTests { if let handle = view as? SplitHandleInteractionView { return handle } return view.subviews.lazy.compactMap { splitHandle(in: $0) }.first } + + private func enclosingScroll(of view: NSView) -> NSScrollView? { + if let scroll = view.superview as? NSScrollView { return scroll } + return view.superview.flatMap { enclosingScroll(of: $0) } + } + + private func commandScroll(in view: NSView, excluding editor: NSView) -> NSScrollView? { + if let scroll = view as? NSScrollView, scroll.hasVerticalScroller, !editor.isDescendant(of: scroll) { return scroll } + return view.subviews.lazy.compactMap { commandScroll(in: $0, excluding: editor) }.first + } +} + +/// Observe the actual native editor frame assigned by SwiftUI, without a run loop delay. +private struct AgentDraftFrameProbe: NSViewRepresentable { + let onCreate: (NSView) -> Void + + func makeNSView(context: Context) -> NSView { + let view = NSView() + onCreate(view) + return view + } + + func updateNSView(_ nsView: NSView, context: Context) {} } diff --git a/macos/Tests/LitheTests/AgentConversationSelectionTests.swift b/macos/Tests/LitheTests/AgentConversationSelectionTests.swift new file mode 100644 index 000000000..169bb9988 --- /dev/null +++ b/macos/Tests/LitheTests/AgentConversationSelectionTests.swift @@ -0,0 +1,346 @@ +import AppKit +import SwiftUI +import Testing +import LitheCoreContracts +@testable import Lithe +@testable import LitheAgentConversationModule + +@MainActor +@Suite(.serialized) +struct AgentConversationSelectionTests { + @Test(arguments: ["claude-acp", "codex-acp"]) + func closingTheLastTabPreparesSettingsAndAllowsSendingAfterConfirmation(agentID: String) async throws { + let transport = SelectionTransport() + let panel = AgentConversationFeatureModel(transport: transport) + panel.setAgents([.init(id: agentID, name: agentID)]) + do { + let feature = try prepare(panel, transport: transport, agentID: agentID, model: "Confirmed model") + let native = try #require(transport.connections.last) + let initialToken = try #require(native.commands.last?["token"] as? String) + let commandCount = native.commands.count + let host = hostingView(panel) + let window = hiddenWindow(host) + defer { window.close() } + host.layoutSubtreeIfNeeded() + let readyToolbar = try toolbarPixels(in: host) + + // Closing the final tab must start real work without another view + // appearance or connection-state change to escape the loading gate. + feature.closeConversation(agentID) + #expect(feature.connectionState == .ready) + #expect(feature.selectedSessionID == nil) + #expect(feature.openSessionIDs.isEmpty) + #expect(feature.conversations[agentID] == nil) + #expect(feature.isCreatingSession) + #expect(native.commands.count == commandCount + 1) + let command = try #require(native.commands.last) + #expect(command["kind"] as? String == "newSession") + let token = try #require(command["token"] as? String) + #expect(token != initialToken) + feature.closeConversation(agentID) + feature.prepareConversation() + #expect(native.commands.count == commandCount + 1, "Pending creation must not be duplicated") + host.layoutSubtreeIfNeeded() + let loadingToolbar = try toolbarPixels(in: host) + #expect(loadingToolbar != readyToolbar) + + feature.receive(try json(["kind": "sessionCreated", "sessionId": agentID, "token": initialToken, + "configOptions": options(model: "Stale model", current: "default")])) + #expect(feature.selectedSessionID == nil, "The closed session's response must not select it again") + #expect(feature.isCreatingSession) + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) == loadingToolbar) + + let newSessionID = "replacement-session" + feature.receive(try json(["kind": "sessionCreated", "sessionId": newSessionID, "token": token, + "configOptions": options(model: "Confirmed model", current: "default")])) + #expect(feature.selectedSessionID == newSessionID) + #expect(feature.openSessionIDs == [newSessionID]) + #expect(feature.selectedConversation?.isAttached == true) + #expect(!feature.isCreatingSession) + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) == readyToolbar, "Confirmed settings must release the composer loading gate") + + try feature.send("Message after closing the last tab") + #expect(native.commands.last?["kind"] as? String == "prompt") + #expect(native.commands.last?["sessionId"] as? String == newSessionID) + #expect(feature.selectedConversation?.messages.last?.text == "Message after closing the last tab") + } catch { + await panel.stop() + throw error + } + await panel.stop() + #expect(transport.connections.allSatisfy { $0.closeCount == 1 }) + } + + @Test + func preparingSessionSettingsHidesSavedModelsUntilTheSessionConfirms() async throws { + let transport = SelectionTransport() + let panel = AgentConversationFeatureModel(transport: transport) + func agents(_ model: String) -> [AgentOption] { + [.init(id: "claude-acp", name: "Claude"), .init(id: "codex-acp", name: "Codex", modelName: model)] + } + panel.setAgents(agents("Saved model")) + do { + _ = try prepare(panel, transport: transport, agentID: "claude-acp", model: "Claude model") + let host = hostingView(panel) + let window = hiddenWindow(host) + defer { window.close() } + host.layoutSubtreeIfNeeded() + let claudeToolbar = try toolbarPixels(in: host) + + // Hold each ACP boundary explicitly: first selection, process ready, + // pending session (including default repair), then the confirmed catalog. + panel.selectAgent("codex-acp") + host.layoutSubtreeIfNeeded() + let loadingToolbar = try toolbarPixels(in: host) + #expect(loadingToolbar != claudeToolbar) + panel.setAgents(agents("Another saved model")) + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) == loadingToolbar, "Unconfirmed saved models must not flash") + + let codex = panel.connection(for: "codex-acp") + try connect(codex, agentID: "codex-acp") + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) == loadingToolbar) + codex.receive(try json(["kind": "ready", "agentName": "Codex", "canLoadSessions": true])) + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) == loadingToolbar, "Process readiness does not confirm session settings") + codex.prepareConversation() + let token = try #require(transport.connections.last?.commands.last?["token"]) + codex.receive(try json(["kind": "sessionCreated", "sessionId": "codex-session", "token": "stale", + "configOptions": options(model: "Wrong model", current: "default")])) + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) == loadingToolbar) + codex.receive(try json(["kind": "sessionCreated", "sessionId": "codex-session", "token": token, + "configOptions": options(model: "Confirmed model", current: "default")])) + host.layoutSubtreeIfNeeded() + let confirmedToolbar = try toolbarPixels(in: host) + #expect(confirmedToolbar != loadingToolbar) + #expect(codex.selectedConversation?.configOptions.first?.currentLabel == "Confirmed model") + panel.setAgents(agents("Saved model")) + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) == confirmedToolbar, "Confirmed choices remain authoritative") + + panel.selectAgent("claude-acp") + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) == claudeToolbar) + panel.selectAgent("codex-acp") + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) == confirmedToolbar, "Prepared connections do not reload on every switch") + #expect(transport.connections.count == 2) + + codex.selectSession("history-session") + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) == loadingToolbar, "History waits for its own settings") + codex.receive(try json(["kind": "sessionLoaded", "sessionId": "history-session", + "token": transport.connections.last?.commands.last?["token"] as Any, + "configOptions": options(model: "History model", current: "default")])) + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) != loadingToolbar) + #expect(codex.selectedConversation?.configOptions.first?.currentLabel == "History model") + } catch { + await panel.stop() + throw error + } + await panel.stop() + #expect(transport.connections.allSatisfy { $0.closeCount == 1 }) + } + + @Test + func failedOrUnsupportedSettingsStopLoadingAndKeepTheConfiguredFallback() async throws { + let transport = SelectionTransport() + let panel = AgentConversationFeatureModel(transport: transport) + func agents(_ model: String) -> [AgentOption] { + [.init(id: "codex-acp", name: "Codex", modelName: model)] + } + panel.setAgents(agents("Saved model")) + do { + let codex = panel.connection(for: "codex-acp") + let host = hostingView(panel) + let window = hiddenWindow(host) + defer { window.close() } + host.layoutSubtreeIfNeeded() + let loadingToolbar = try toolbarPixels(in: host) + + codex.reportConnectionFailure("Missing API key") + host.layoutSubtreeIfNeeded() + let fallbackToolbar = try toolbarPixels(in: host) + #expect(fallbackToolbar != loadingToolbar, "Connection failures must not keep showing loading") + panel.setAgents(agents("Another saved model")) + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) != fallbackToolbar, "Failed setup still shows the configured model") + panel.setAgents(agents("Saved model")) + + try connect(codex, agentID: "codex-acp") + codex.receive(try json(["kind": "ready", "agentName": "Codex", "canLoadSessions": true])) + host.layoutSubtreeIfNeeded() + codex.prepareConversation() + codex.receive(try json(["kind": "requestFailed", "message": "Session failed", + "token": transport.connections.last?.commands.last?["token"] as Any])) + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) == fallbackToolbar, "Creation failures stop waiting") + #expect(codex.errorMessage == "Session failed") + + codex.prepareConversation() + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) == loadingToolbar) + codex.receive(try json(["kind": "sessionCreated", "sessionId": "no-settings", + "token": transport.connections.last?.commands.last?["token"] as Any])) + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) == fallbackToolbar, "An adapter without config options is ready, not loading") + #expect(codex.selectedConversation?.isAttached == true) + #expect(codex.selectedConversation?.configOptions.isEmpty == true) + + codex.selectSession("history-session") + codex.receive(try json(["kind": "requestFailed", "message": "History failed", + "token": transport.connections.last?.commands.last?["token"] as Any])) + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) == fallbackToolbar, "History failures stop waiting") + #expect(codex.selectedConversation?.errorMessage == "History failed") + } catch { + await panel.stop() + throw error + } + await panel.stop() + #expect(transport.connections.allSatisfy { $0.closeCount == 1 }) + } + + @Test + func selectedAgentAndConfirmedModelRefreshWithoutReplacingTheRootView() async throws { + let transport = SelectionTransport() + let panel = AgentConversationFeatureModel(transport: transport) + panel.setAgents([.init(id: "codex-acp", name: "Codex"), .init(id: "claude-acp", name: "Claude")]) + do { + let codex = try prepare(panel, transport: transport, agentID: "codex-acp", model: "Codex model") + let claude = try prepare(panel, transport: transport, agentID: "claude-acp", model: "Claude model") + let host = NSHostingView(rootView: AgentConfiguredConversationView( + feature: panel, setupError: nil, onSelectAgent: panel.selectAgent, + onConnect: {}, onOpenSettings: {}, onCopySessionID: { _ in }, onOpenFile: { _ in } + )) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 480, height: 640), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + defer { window.close() } + window.contentView = host + host.layoutSubtreeIfNeeded() + let codexToolbar = try toolbarPixels(in: host) + + // Only the module selection changes: no AppModel notification or + // rootView replacement may be needed to render the new connection. + panel.selectAgent("claude-acp") + host.layoutSubtreeIfNeeded() + let claudeToolbar = try toolbarPixels(in: host) + #expect(claudeToolbar != codexToolbar, "The native toolbar must render Claude and its model") + #expect(panel.selectedConnection === claude) + #expect(claude.selectedConversation?.configOptions.first?.currentLabel == "Claude model") + + claude.setConfigOption("model", value: "alternate") + let command = try #require(transport.connections[1].commands.last) + #expect(command["kind"] as? String == "setConfigOption") + #expect(claude.selectedConversation?.configOptions.first?.currentLabel == "Claude model") + claude.receive(try json(["kind": "sessionConfigured", "sessionId": "claude-acp", + "token": command["token"] as Any, + "configOptions": options(model: "Claude model", current: "alternate")])) + host.layoutSubtreeIfNeeded() + let alternateToolbar = try toolbarPixels(in: host) + #expect(alternateToolbar != claudeToolbar, "Confirmed model changes must render in the native toolbar") + #expect(claude.selectedConversation?.configOptions.first?.currentLabel == "Alternate model") + + panel.selectAgent("codex-acp") + host.layoutSubtreeIfNeeded() + #expect(panel.selectedConnection === codex) + #expect(try toolbarPixels(in: host) == codexToolbar) + panel.selectAgent("claude-acp") + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) == alternateToolbar) + #expect(transport.connections.count == 2, "Switching reuses the project connections") + + panel.setAgents([.init(id: "codex-acp", name: "Codex")]) + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) == codexToolbar) + } catch { + await panel.stop() + throw error + } + await panel.stop() + #expect(transport.connections.allSatisfy { $0.closeCount == 1 }) + } + + private func prepare(_ panel: AgentConversationFeatureModel, transport: SelectionTransport, + agentID: String, model: String) throws -> AgentConnectionModel { + let connection = panel.connection(for: agentID) + try connect(connection, agentID: agentID) + connection.receive(try json(["kind": "ready", "agentName": agentID, "canLoadSessions": true])) + connection.prepareConversation() + let native = try #require(transport.connections.last) + connection.receive(try json(["kind": "sessionCreated", "sessionId": agentID, + "token": native.commands.last?["token"] as Any, + "configOptions": options(model: model, current: "default")])) + return connection + } + + private func connect(_ connection: AgentConnectionModel, agentID: String) throws { + try connection.connect(configuration: .init( + agentID: agentID, command: "", arguments: [], workspaceURL: URL(fileURLWithPath: "/example/project"), + dataDirectory: URL(fileURLWithPath: "/example/agents"), providerProtocol: "responses", + providerEndpoint: "https://gateway.example.com/v1", apiKey: "test-key", providerName: "Example", + model: "", allowsInsecureHTTP: false + )) + } + + private func hostingView(_ panel: AgentConversationFeatureModel) -> NSHostingView { + NSHostingView(rootView: AgentConfiguredConversationView( + feature: panel, setupError: nil, onSelectAgent: panel.selectAgent, + onConnect: {}, onOpenSettings: {}, onCopySessionID: { _ in }, onOpenFile: { _ in } + )) + } + + private func hiddenWindow(_ host: NSView) -> NSWindow { + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 480, height: 640), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = host + return window + } + + private func options(model: String, current: String) -> [[String: Any]] { + [["id": "model", "name": "Model", "category": "model", "type": "select", "currentValue": current, + "options": [["value": "default", "name": model], ["value": "alternate", "name": "Alternate model"]]]] + } + + private func json(_ value: [String: Any]) throws -> String { + String(decoding: try JSONSerialization.data(withJSONObject: value), as: UTF8.self) + } + + private func toolbarPixels(in view: NSView) throws -> Data { + // Capture only the static bottom toolbar; text-field caret blinking and + // the transcript are outside the comparison. No image files are written. + let region = NSRect(x: 16, y: view.isFlipped ? view.bounds.height - 44 : 8, + width: view.bounds.width - 32, height: 32) + let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: region)) + view.cacheDisplay(in: region, to: bitmap) + let bytes = try #require(bitmap.bitmapData) + return Data(bytes: bytes, count: bitmap.bytesPerRow * bitmap.pixelsHigh) + } +} + +@MainActor +private final class SelectionTransport: AgentConversationTransport { + var connections: [SelectionConnection] = [] + func open(configuration: AgentLaunchConfiguration, onEvent: @escaping @Sendable (String) -> Void) throws -> any AgentConnection { + let connection = SelectionConnection() + connections.append(connection) + return connection + } +} + +@MainActor +private final class SelectionConnection: AgentConnection { + var commands: [[String: Any]] = [] + var closeCount = 0 + func send(commandJSON: String) throws { + commands.append(try #require(JSONSerialization.jsonObject(with: Data(commandJSON.utf8)) as? [String: Any])) + } + func close() async { closeCount += 1 } +} diff --git a/macos/Tests/LitheTests/AgentSessionSelectorLayoutTests.swift b/macos/Tests/LitheTests/AgentSessionSelectorLayoutTests.swift new file mode 100644 index 000000000..b5481a536 --- /dev/null +++ b/macos/Tests/LitheTests/AgentSessionSelectorLayoutTests.swift @@ -0,0 +1,128 @@ +import AppKit +import SwiftUI +import Testing +@testable import Lithe +@testable import LitheAgentConversationModule + +@MainActor +@Suite("Agent selector layout") +struct AgentSessionSelectorLayoutTests { + @Test + func twoLinePermissionDescriptionsHaveRoomAndKeepTheirSelectionAction() async throws { + let description = "批准后才能修改文件。\n其他操作继续遵守权限规则。" + let option = AgentSessionConfigOption( + id: "mode", name: "Mode", category: "mode", currentValue: "manual", + choices: [.init(id: "manual", name: "Manual", description: description), + .init(id: "auto", name: "Auto", description: description)] + ) + for scheme in [ColorScheme.dark, .light] { + var selected: String? + let host = NSHostingView(rootView: AgentModePopover(option: option) { selected = $0 } + .environment(\.colorScheme, scheme).environment(\.locale, Locale(identifier: "zh_Hans"))) + let window = makeWindow(host, size: NSSize(width: 350, height: 110)) + defer { window.close() } + let scroll = try #require(scrollView(in: host)) + let document = try #require(scroll.documentView) + try #require(await waitUntil { + host.layoutSubtreeIfNeeded() + return host.fittingSize.height >= document.bounds.height + 10 + }, "A short permission list must fit both description lines without scrolling") + window.setContentSize(host.fittingSize) + host.layoutSubtreeIfNeeded() + let rowHeight = document.bounds.height / CGFloat(option.choices.count) + #expect(rowHeight >= 48, + "The row must grow to fit a title and both description lines") + #expect(rowHeight < 64, + "Wrapping must not restore the old excessive spacing") + try click(NSPoint(x: 100, y: rowHeight / 2), in: document, window: window) + #expect(await waitUntil { selected == "manual" }, "The rendered choice must preserve the upstream ID") + try capture(host, name: "permission-two-lines-\(scheme)") + } + } + + @Test + func longPermissionAndModelListsScrollToTheirLastChoice() async throws { + let choices: [AgentSessionConfigOption.Choice] = (0..<30).map { + .init(id: "choice-\($0)", name: "Choice \($0)") + } + for scheme in [ColorScheme.dark, .light] { + for category in ["mode", "model"] { + let option = AgentSessionConfigOption( + id: category, name: category, category: category, + currentValue: "choice-0", choices: choices + ) + var selected: String? + let view = category == "mode" + ? AnyView(AgentModePopover(option: option) { selected = $0 }) + : AnyView(AgentModelPopover(option: option, settings: [], agentName: "Claude") { id, value in + #expect(id == category) + selected = value + }) + let size = NSSize(width: category == "mode" ? 350 : 330, + height: category == "mode" ? 330 : 290) + let host = NSHostingView(rootView: view.environment(\.colorScheme, scheme)) + let window = makeWindow(host, size: size) + defer { window.close() } + let scroll = try #require(scrollView(in: host)) + let document = try #require(scroll.documentView) + #expect(document.bounds.height > scroll.contentView.bounds.height, + "A long list must exceed the viewport and remain scrollable") + scroll.contentView.scroll(to: NSPoint(x: 0, y: document.bounds.maxY - scroll.contentView.bounds.height)) + scroll.reflectScrolledClipView(scroll.contentView) + host.layoutSubtreeIfNeeded() + #expect(scroll.contentView.bounds.minY > 0) + let lastPoint = NSPoint(x: 100, y: document.bounds.maxY - 13) + #expect(scroll.contentView.bounds.contains(lastPoint), "The final choice must be inside the scrolled viewport") + try click(lastPoint, in: document, window: window) + #expect(await waitUntil { selected == "choice-29" }, "Scrolling must make the final upstream choice selectable") + try capture(host, name: "\(category)-scrolled-\(scheme)") + } + } + } + + private func makeWindow(_ host: NSHostingView, size: NSSize) -> NSWindow { + let window = NSWindow(contentRect: NSRect(origin: .zero, size: size), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = host + host.frame.size = size + window.orderFront(nil) + host.layoutSubtreeIfNeeded() + return window + } + + private func scrollView(in view: NSView) -> NSScrollView? { + if let scroll = view as? NSScrollView { return scroll } + return view.subviews.lazy.compactMap { scrollView(in: $0) }.first + } + + private func click(_ point: NSPoint, in view: NSView, window: NSWindow) throws { + for type in [NSEvent.EventType.leftMouseDown, .leftMouseUp] { + let event = try #require(NSEvent.mouseEvent( + with: type, location: view.convert(point, to: nil), modifierFlags: [], + timestamp: 0, windowNumber: window.windowNumber, context: nil, + eventNumber: 1, clickCount: 1, pressure: type == .leftMouseDown ? 1 : 0 + )) + window.sendEvent(event) + } + } + + private func waitUntil(_ condition: () -> Bool) async -> Bool { + // Native mouse events can publish a SwiftUI action asynchronously; wait on the callback, not a delay. + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(1)) + while clock.now < deadline { + if condition() { return true } + await Task.yield() + } + return condition() + } + + private func capture(_ host: NSView, name: String) throws { + guard let folder = ProcessInfo.processInfo.environment["LITHE_AGENT_SELECTOR_SCREENSHOTS"] else { return } + let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: bitmap) + let data = try #require(bitmap.representation(using: .png, properties: [:])) + try data.write(to: URL(fileURLWithPath: folder).appendingPathComponent("\(name).png")) + } +} diff --git a/macos/Tests/LitheTests/AgentSessionSelectorPresentationTests.swift b/macos/Tests/LitheTests/AgentSessionSelectorPresentationTests.swift new file mode 100644 index 000000000..e0d05e527 --- /dev/null +++ b/macos/Tests/LitheTests/AgentSessionSelectorPresentationTests.swift @@ -0,0 +1,72 @@ +import Foundation +import Testing +@testable import Lithe +@testable import LitheAgentConversationModule + +@MainActor +@Suite("Agent selector names") +struct AgentSessionSelectorPresentationTests { + @Test + func approvalMenuAndCurrentValueKeepAgentNamesDespiteChineseTranslationCollisions() throws { + let chinese = try localizationBundle("zh-Hans") + // The unrelated app translation must remain available while Agent names bypass it. + #expect(AgentSessionSelectorPresentation.localized("Plan", bundle: chinese) == "计划") + let names = ["Manual", "Accept edits", "Plan", "Auto", "Bypass permissions", "Future mode"] + let ids = ["manual", "acceptEdits", "plan", "auto", "bypassPermissions", "future"] + var option = AgentSessionConfigOption( + id: "mode", name: "Mode", category: "mode", currentValue: "plan", + choices: zip(ids, names).map { .init(id: $0.0, name: $0.1) } + ) + for language in ["zh-Hans", "en"] { + let bundle = try localizationBundle(language) + #expect(option.choices.map { + AgentSessionSelectorPresentation.choiceTitle($0, in: option, bundle: bundle) + } == names) + for choice in option.choices { + option.currentValue = choice.id + #expect(AgentSessionSelectorPresentation.currentTitle(option, bundle: bundle) == choice.name) + } + } + } + + @Test + func thinkingAndModelNamesUseTheSameUpstreamRuleInBothLanguages() throws { + let names = ["Low", "Medium", "High", "Xhigh", "Max", "Ultra"] + for language in ["zh-Hans", "en"] { + let bundle = try localizationBundle(language) + for category in ["thought_level", "model"] { + var option = AgentSessionConfigOption( + id: category, name: category, category: category, currentValue: "Medium", + choices: names.map { .init(id: $0, name: $0) } + ) + for choice in option.choices { + option.currentValue = choice.id + #expect(AgentSessionSelectorPresentation.choiceTitle(choice, in: option, bundle: bundle) == choice.name) + #expect(AgentSessionSelectorPresentation.currentTitle(option, bundle: bundle) == choice.name) + } + } + } + } + + @Test + func speedAndOtherControlsStillUseTheSelectedLanguage() throws { + let bundle = try localizationBundle("zh-Hans") + let speed = AgentSessionConfigOption( + id: "fast-mode", name: "Speed", category: "model_config", currentValue: "on", + choices: [.init(id: "off", name: "Disabled"), .init(id: "on", name: "Enabled")] + ) + #expect(AgentSessionSelectorPresentation.currentTitle(speed, bundle: bundle) + == bundle.localizedString(forKey: "Fast", value: nil, table: nil)) + let other = AgentSessionConfigOption( + id: "other", name: "Other", category: "custom", currentValue: "plan", + choices: [.init(id: "plan", name: "Plan")] + ) + #expect(AgentSessionSelectorPresentation.currentTitle(other, bundle: bundle) == "计划") + } + + private func localizationBundle(_ language: String) throws -> Bundle { + let repository = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + return try #require(Bundle(url: repository.appendingPathComponent("macos/Resources/\(language).lproj"))) + } +} diff --git a/macos/Tests/LitheTests/AgentThoughtTranscriptTests.swift b/macos/Tests/LitheTests/AgentThoughtTranscriptTests.swift new file mode 100644 index 000000000..d9a729026 --- /dev/null +++ b/macos/Tests/LitheTests/AgentThoughtTranscriptTests.swift @@ -0,0 +1,195 @@ +import AppKit +import SwiftUI +import Testing +import LitheCoreContracts +@testable import Lithe +@testable import LitheAgentConversationModule + +@MainActor +@Suite("Thought transcript lifecycle", .serialized) +struct AgentThoughtTranscriptTests { + @Test + func manualExpansionSurvivesNonmatchingSearchRemovingAndRecreatingTheRow() async throws { + try await withTranscript { feature, host, window in + renderFrame(host) + let collapsed = try snapshot(host) + try record(host, name: "collapsed") + try pressThought(in: host, window: window) + try await renderUntil(host, expected: "expanded thought") { try snapshot(host) != collapsed } + let expanded = try snapshot(host) + try record(host, name: "expanded") + + host.rootView = transcript(feature, search: "no matching thought") + try await renderUntil(host, expected: "filtered row") { try snapshot(host) != expanded && snapshot(host) != collapsed } + try record(host, name: "filtered") + // Visiting a new empty session disposes the real scroll subtree while search remains active. + // Returning must retain this session's choice even after SwiftUI's lazy row cache is gone. + let filtered = try snapshot(host) + feature.selectSession("empty") + try await renderUntil(host, expected: "empty conversation") { try snapshot(host) != filtered } + feature.selectSession("first") + host.rootView = transcript(feature) + try await renderUntil(host, expected: "restored manual expansion") { try snapshot(host) == expanded } + try record(host, name: "restored") + } + } + + @Test + func matchingSearchRevealsACollapsedThoughtWithoutReplacingItsPreference() async throws { + try await withTranscript { feature, host, window in + renderFrame(host) + let collapsed = try snapshot(host) + try pressThought(in: host, window: window) + try await renderUntil(host, expected: "manual expansion") { try snapshot(host) != collapsed } + let expanded = try snapshot(host) + try pressThought(in: host, window: window) + try await renderUntil(host, expected: "manual collapse") { try snapshot(host) == collapsed } + + host.rootView = transcript(feature, search: "manifest") + try await renderUntil(host, expected: "matching thought text") { try snapshot(host) == expanded } + try pressThought(in: host, window: window) + renderFrame(host) + #expect(try snapshot(host) == expanded, "Searching must keep the matched reasoning visible after a click") + host.rootView = transcript(feature) + try await renderUntil(host, expected: "original manual collapse") { try snapshot(host) == collapsed } + } + } + + @Test + func switchingSessionsKeepsIndependentPreferencesAndClosingResetsThem() async throws { + try await withTranscript { feature, host, window in + seedThought(in: feature, sessionID: "second") + renderFrame(host) + let collapsed = try snapshot(host) + try pressThought(in: host, window: window) + try await renderUntil(host, expected: "first session expanded") { try snapshot(host) != collapsed } + let expanded = try snapshot(host) + + feature.selectSession("second") + host.rootView = transcript(feature) + try await renderUntil(host, expected: "second session default collapse") { try snapshot(host) == collapsed } + feature.selectSession("first") + host.rootView = transcript(feature) + try await renderUntil(host, expected: "first session retained expansion") { try snapshot(host) == expanded } + + feature.closeConversation("first") + host.rootView = transcript(feature) + try await renderUntil(host, expected: "remaining session") { try snapshot(host) == collapsed } + // Reopening the session with replayed messages must use the fresh row's default. + seedThought(in: feature, sessionID: "first") + feature.selectSession("first") + host.rootView = transcript(feature) + try await renderUntil(host, expected: "reopened session default") { try snapshot(host) == collapsed } + } + } + + private func withTranscript( + _ run: (AgentConnectionModel, NSHostingView, NSWindow) async throws -> Void + ) async throws { + let feature = AgentConnectionModel(transport: UnusedThoughtTransport()) + seedThought(in: feature, sessionID: "first") + feature.selectSession("first") + let host = NSHostingView(rootView: transcript(feature)) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 360, height: 300), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + defer { window.contentView = nil; window.close() } + window.contentView = host + host.frame.size = NSSize(width: 360, height: 300) + window.orderFront(nil) + do { + try await run(feature, host, window) + await feature.stop() + } catch { + await feature.stop() + throw error + } + } + + private func transcript(_ feature: AgentConnectionModel, search: String = "") -> AnyView { + AnyView(AgentTranscriptView(feature: feature, agentName: "Fixture", agentVersion: nil, + agents: [], onSelectAgent: { _ in }, searchText: search) + .background(AgentPanelStyle.canvas).environment(\.colorScheme, .dark)) + } + + private func seedThought(in feature: AgentConnectionModel, sessionID: String) { + feature.receive(""" + {"kind":"update","sessionId":"\(sessionID)","update":{"sessionUpdate":"agent_thought_chunk",\ + "content":{"type":"text","text":"Find the manifest entry point"}}} + """) + feature.receive(""" + {"kind":"turnFinished","sessionId":"\(sessionID)"} + """) + } + + private func pressThought(in host: NSView, window: NSWindow) throws { + // The fixture's only thought is the first transcript row, inside the 12pt padding. + let point = NSPoint(x: 80, y: host.isFlipped ? 20 : host.bounds.height - 20) + let location = host.convert(point, to: nil) + for type in [NSEvent.EventType.leftMouseDown, .leftMouseUp] { + let event = try #require(NSEvent.mouseEvent(with: type, location: location, modifierFlags: [], + timestamp: 0, windowNumber: window.windowNumber, context: nil, eventNumber: 0, + clickCount: 1, pressure: type == .leftMouseDown ? 1 : 0)) + window.sendEvent(event) + } + } + + private func renderFrame(_ host: NSView) { + // Pump one native event without waiting: SwiftUI's lazy row disposal commits on the run loop. + CFRunLoopRunInMode(CFRunLoopMode.defaultMode, 0, true) + host.layoutSubtreeIfNeeded() + host.displayIfNeeded() + CATransaction.flush() + } + + // Compare complete frames from this one host, without font- or OS-specific golden images. + private func snapshot(_ host: NSView) throws -> Data { + let bitmap = try renderedBitmap(host) + let pixels = try #require(bitmap.bitmapData) + return Data(bytes: pixels, count: bitmap.bytesPerRow * bitmap.pixelsHigh) + } + + private func renderedBitmap(_ host: NSView) throws -> NSBitmapImageRep { + host.displayIfNeeded() + CATransaction.flush() + let bitmap = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, + pixelsWide: Int(host.bounds.width), pixelsHigh: Int(host.bounds.height), + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, + colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0)) + let context = try #require(NSGraphicsContext(bitmapImageRep: bitmap)) + let layer = try #require(host.layer) + context.cgContext.translateBy(x: 0, y: host.bounds.height) + context.cgContext.scaleBy(x: 1, y: -1) + layer.render(in: context.cgContext) + return bitmap + } + + private func record(_ host: NSView, name: String) throws { + guard let directory = ProcessInfo.processInfo.environment["LITHE_THOUGHT_SCREENSHOTS"] else { return } + let bitmap = try renderedBitmap(host) + let data = try #require(bitmap.representation(using: .png, properties: [:])) + try data.write(to: URL(fileURLWithPath: directory).appendingPathComponent("\(name).png")) + } + + /// Wait for observable native rendering, with no fixed delay or private SwiftUI state access. + private func renderUntil(_ host: NSView, expected: String, condition: () throws -> Bool) async throws { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(2)) + while clock.now < deadline { + renderFrame(host) + if try condition() { return } + await Task.yield() + } + #expect(try condition(), "Did not render \(expected) within two seconds") + throw ThoughtRenderError.timeout + } +} + +private enum ThoughtRenderError: Error { case timeout } + +@MainActor +private struct UnusedThoughtTransport: AgentConversationTransport { + func open(configuration: AgentLaunchConfiguration, onEvent: @escaping @Sendable (String) -> Void) throws -> any AgentConnection { + throw AgentConversationError.notConnected + } +} diff --git a/macos/Tests/LitheTests/LitheCoreLogicTests.swift b/macos/Tests/LitheTests/LitheCoreLogicTests.swift index 74791fa93..d92069441 100644 --- a/macos/Tests/LitheTests/LitheCoreLogicTests.swift +++ b/macos/Tests/LitheTests/LitheCoreLogicTests.swift @@ -4367,6 +4367,216 @@ struct EditorDocumentTests { #expect(markStore.savedMarks == expected) } + @Test + @MainActor + func batchPasteKeepsExistingFilesAndAllocatesDistinctNames() async { + let workspace = URL(fileURLWithPath: "/batch-copy") + let destination = workspace.appendingPathComponent("target") + let first = workspace.appendingPathComponent("first/a.txt") + let second = workspace.appendingPathComponent("second/a.txt") + let operations = RecordingBatchProjectFileOperations( + files: [first, second, destination.appendingPathComponent("a.txt"), destination.appendingPathComponent("a copy.txt")], + directories: [workspace, destination] + ) + let model = makeWorkspaceObservationUnitModel( + fileOperations: operations, provider: SequencedGitWatchContextProvider([nil]), + watcherFactory: TestDirectoryWatcherFactory(), refreshGit: {} + ) + defer { model.reset() } + model.beginWorkspace(at: workspace, visibilityRules: .default) + await model.pasteProjectItems([second, first, first], in: destination) + #expect(operations.copiedDestinations == [destination.appendingPathComponent("a copy 2.txt"), destination.appendingPathComponent("a copy 3.txt")]) + #expect(operations.fileExists(at: destination.appendingPathComponent("a.txt"))) + } + + @Test + @MainActor + func batchPasteRejectsCopyingDirectoryIntoItsDescendant() async { + let workspace = URL(fileURLWithPath: "/batch-copy") + let folder = workspace.appendingPathComponent("folder") + let destination = folder.appendingPathComponent("child") + let operations = RecordingBatchProjectFileOperations(files: [], directories: [workspace, folder, destination]) + let model = makeWorkspaceObservationUnitModel( + fileOperations: operations, provider: SequencedGitWatchContextProvider([nil]), + watcherFactory: TestDirectoryWatcherFactory(), refreshGit: {} + ) + defer { model.reset() } + model.beginWorkspace(at: workspace, visibilityRules: .default) + await model.pasteProjectItems([folder], in: destination) + #expect(operations.copiedDestinations.isEmpty) + } + + @Test + @MainActor + func batchTrashDeduplicatesParentsAndSupportsCancellingConfirmation() async throws { + let workspace = URL(fileURLWithPath: "/batch-trash") + let folder = workspace.appendingPathComponent("folder") + let child = folder.appendingPathComponent("child.txt") + let file = workspace.appendingPathComponent("a.txt") + let operations = RecordingBatchProjectFileOperations(files: [child, file], directories: [workspace, folder]) + let model = makeWorkspaceObservationUnitModel( + fileOperations: operations, provider: SequencedGitWatchContextProvider([nil]), + watcherFactory: TestDirectoryWatcherFactory(), refreshGit: {} + ) + defer { model.reset() } + model.beginWorkspace(at: workspace, visibilityRules: .default) + model.requestDeleteProjectItems([folder, child, file, workspace, URL(fileURLWithPath: "/outside")]) + let request = try #require(model.pendingProjectItemDeletion) + #expect(Set(([request] + request.additionalItems).map(\.url)) == [folder, file]) + model.cancelProjectItemDeletion() + #expect(model.pendingProjectItemDeletion == nil) + #expect(operations.trashedURLs.isEmpty) + await model.confirmProjectItemDeletion(request) + #expect(Set(operations.trashedURLs) == [folder, file]) + } + + @Test + @MainActor + func batchFileOperationsHoldTheOperationUntilTheWholeBatchFinishes() async throws { + let workspace = URL(fileURLWithPath: "/batch-ownership") + let first = workspace.appendingPathComponent("a/x.txt") + let second = workspace.appendingPathComponent("b/y.txt") + let other = workspace.appendingPathComponent("z.txt") + let operations = RecordingBatchProjectFileOperations( + files: [first, second, other], + // Duplicate targets each source's parent URL as returned by Foundation. + directories: [workspace, first.deletingLastPathComponent(), second.deletingLastPathComponent()], + pausesFirstCopy: true + ) + defer { operations.releaseFirstCopy() } + let scans = BatchProgressScanOperations(files: operations) + let model = makeWorkspaceObservationUnitModel( + operations: scans, fileOperations: operations, provider: SequencedGitWatchContextProvider([nil]), + watcherFactory: TestDirectoryWatcherFactory(), refreshGit: {} + ) + defer { model.reset() } + model.beginWorkspace(at: workspace, visibilityRules: .default) + + let duplicate = Task { await model.duplicateProjectItems([first, second]) } + #expect(await operations.waitUntilFirstCopyStarted()) + // Another file operation cannot start in the middle of the batch. + #expect(model.isPerformingProjectItemOperation) + await model.pasteProjectItems([other], in: second.deletingLastPathComponent()) + operations.releaseFirstCopy() + await duplicate.value + #expect(operations.copiedDestinations == [ + workspace.appendingPathComponent("a/x copy.txt"), workspace.appendingPathComponent("b/y copy.txt") + ]) + #expect(!model.isPerformingProjectItemOperation) + + // Trash moves both items before the single refresh, too. + model.requestDeleteProjectItems([first, second]) + await model.confirmProjectItemDeletion(try #require(model.pendingProjectItemDeletion)) + #expect(Set(operations.trashedURLs) == [first, second]) + // Each batch rescans once, after its last item instead of between items. + #expect(scans.progressAtScans == [[2, 0], [2, 2]]) + } + + @Test + @MainActor + func batchTrashKeepsLaterItemEditedWhileEarlierItemIsTrashed() async throws { + let workspace = URL(fileURLWithPath: "/batch-trash-dirty") + let folder = workspace.appendingPathComponent("a-folder") + let child = folder.appendingPathComponent("child.txt") + let edited = workspace.appendingPathComponent("b.txt") + let operations = RecordingBatchProjectFileOperations( + files: [child, edited], directories: [workspace, folder], pausesFirstTrash: true + ) + defer { operations.releaseFirstTrash() } + let childDocument = EditorDocument(url: child, text: "child", modificationDate: nil) + let editedDocument = EditorDocument(url: edited, text: "saved", modificationDate: nil) + let recorder = WorkspaceCallbackRecorder() + let model = makeWorkspaceObservationUnitModel( + fileOperations: operations, provider: SequencedGitWatchContextProvider([nil]), + watcherFactory: TestDirectoryWatcherFactory(), refreshGit: {}, + documentsProvider: { [childDocument, editedDocument] }, + notify: { recorder.messages.append($0) }, + closeDocuments: { recorder.closedURLs.append($0) } + ) + defer { model.reset() } + model.beginWorkspace(at: workspace, visibilityRules: .default) + model.requestDeleteProjectItems([edited, folder]) + let request = try #require(model.pendingProjectItemDeletion) + + // Order: the folder's Trash call starts, b.txt becomes dirty in the + // editor, then the folder finishes and the batch reaches b.txt. + let deletion = Task { await model.confirmProjectItemDeletion(request) } + #expect(await operations.waitUntilFirstTrashStarted()) + editedDocument.text = "unsaved edit" + operations.releaseFirstTrash() + await deletion.value + + #expect(operations.trashedURLs == [folder]) + #expect(operations.fileExists(at: edited)) + #expect(recorder.closedURLs == [folder]) + #expect(editedDocument.text == "unsaved edit") + // b.txt is the last item, so only the unsaved-file reason is reported. + #expect(recorder.messages.last == "Save or discard unsaved files before deleting this item") + } + + @Test + @MainActor + func trashKeepsDocumentEditedWhileItsTrashOperationRuns() async throws { + let workspace = URL(fileURLWithPath: "/trash-edit-race") + let folder = workspace.appendingPathComponent("folder") + let clean = folder.appendingPathComponent("clean.txt") + let edited = folder.appendingPathComponent("edited.txt") + let operations = RecordingBatchProjectFileOperations( + files: [clean, edited], directories: [workspace, folder], pausesFirstTrash: true + ) + defer { operations.releaseFirstTrash() } + let cleanDocument = EditorDocument(url: clean, text: "clean", modificationDate: nil) + let editedDocument = EditorDocument(url: edited, text: "saved", modificationDate: nil) + let recorder = WorkspaceCallbackRecorder() + let model = makeWorkspaceObservationUnitModel( + fileOperations: operations, provider: SequencedGitWatchContextProvider([nil]), + watcherFactory: TestDirectoryWatcherFactory(), refreshGit: {}, + documentsProvider: { [cleanDocument, editedDocument] }, + closeDocuments: { recorder.closedURLs.append($0) } + ) + defer { model.reset() } + model.beginWorkspace(at: workspace, visibilityRules: .default) + model.requestDeleteProjectItem(at: folder, isDirectory: true) + let request = try #require(model.pendingProjectItemDeletion) + + let deletion = Task { await model.confirmProjectItemDeletion(request) } + #expect(await operations.waitUntilFirstTrashStarted()) + editedDocument.text = "unsaved edit" + operations.releaseFirstTrash() + await deletion.value + + // The edit exists only in memory now, so its tab must stay open. + #expect(operations.trashedURLs == [folder]) + #expect(recorder.closedURLs == [clean]) + } + + @Test + @MainActor + func batchTrashReportsSkippedItemsAfterFailure() async throws { + let workspace = URL(fileURLWithPath: "/batch-trash-failure") + let first = workspace.appendingPathComponent("a.txt") + let blocked = workspace.appendingPathComponent("b.txt") + let last = workspace.appendingPathComponent("c.txt") + let operations = RecordingBatchProjectFileOperations( + files: [first, blocked, last], directories: [workspace], failingTrashURLs: [blocked] + ) + let recorder = WorkspaceCallbackRecorder() + let model = makeWorkspaceObservationUnitModel( + fileOperations: operations, provider: SequencedGitWatchContextProvider([nil]), + watcherFactory: TestDirectoryWatcherFactory(), refreshGit: {}, + notify: { recorder.messages.append($0) } + ) + defer { model.reset() } + model.beginWorkspace(at: workspace, visibilityRules: .default) + model.requestDeleteProjectItems([last, blocked, first]) + let request = try #require(model.pendingProjectItemDeletion) + await model.confirmProjectItemDeletion(request) + + #expect(operations.trashedURLs == [first]) + #expect(operations.fileExists(at: last)) + #expect(recorder.messages.last == "Stopped moving the remaining items to Trash") + } + @Test @MainActor func deletingDirectoryRemovesItsMarkAndDescendantMarks() async throws { @@ -6162,7 +6372,10 @@ private func makeWorkspaceObservationUnitModel( reloadProjectServices: @escaping @MainActor () async -> Void = {}, recordHistory: @escaping @MainActor (URL, LocalHistoryReason) async -> Void = { _, _ in }, directoryMarkStore: any WorkspaceDirectoryMarkStoring = EmptyWorkspaceDirectoryMarkStore(), - observationDelay: (@Sendable (Duration) async throws -> Void)? = nil + observationDelay: (@Sendable (Duration) async throws -> Void)? = nil, + documentsProvider: @escaping @MainActor @Sendable () -> [EditorDocument] = { [] }, + notify: @escaping @MainActor @Sendable (String) -> Void = { _ in }, + closeDocuments: @escaping @MainActor @Sendable (URL) -> Void = { _ in } ) -> WorkspaceFeatureModel { let model = WorkspaceFeatureModel( operations: operations, @@ -6175,17 +6388,17 @@ private func makeWorkspaceObservationUnitModel( observationDelay: observationDelay ) model.configure( - documentsProvider: { [] }, + documentsProvider: documentsProvider, activeDocumentProvider: { nil }, selectedSidebarProvider: { "project" }, setSelectedSidebar: { _ in }, restoreSession: { _, _ in }, openFile: { _ in }, - notify: { _ in }, + notify: notify, recordHistory: recordHistory, relocateHistory: { _, _ in }, relocateOpenDocuments: { _, _ in }, - closeDocuments: { _ in }, + closeDocuments: closeDocuments, processExternalChanges: processExternalChanges, notifyWorkspaceFileChanges: notifyWorkspaceFileChanges, reloadProjectServices: reloadProjectServices, @@ -7365,3 +7578,101 @@ private struct PreviewExternalChangeLifecycleDecider: DocumentLifecycleDeciding } } } + +/// Records how many copies and Trash moves had finished each time the tree +/// was rescanned, to show when a batch refreshes the project tree. +private final class BatchProgressScanOperations: WorkspaceOperations, @unchecked Sendable { + private let files: RecordingBatchProjectFileOperations + private let lock = NSLock() + private var progress: [[Int]] = [] + + init(files: RecordingBatchProjectFileOperations) { self.files = files } + + var progressAtScans: [[Int]] { lock.withLock { progress } } + + func snapshot(at rootURL: URL, visibilityRules: FileVisibilityRules) -> WorkspaceSnapshot? { + let current = [files.copiedDestinations.count, files.trashedURLs.count] + lock.withLock { progress.append(current) } + return nil + } + func warmSearchIndex(at rootURL: URL, visibilityRules: FileVisibilityRules) {} + func updateSearchIndex(at rootURL: URL, changedPaths: [String], visibilityRules: FileVisibilityRules) {} + func invalidateSearchIndex(at rootURL: URL, visibilityRules: FileVisibilityRules) {} + func readFile(at rootURL: URL, relativePath: String) -> String? { nil } + func writeFile(_ text: String, at rootURL: URL, relativePath: String) -> Bool { false } +} + +/// Records workspace callbacks, which always run on the main actor. +@MainActor +private final class WorkspaceCallbackRecorder { + var messages: [String] = [] + var closedURLs: [URL] = [] +} + +/// Mutable fake filesystem state is shared only under the lock. +private final class RecordingBatchProjectFileOperations: WorkspaceFileOperations, @unchecked Sendable { + private let lock = NSLock() + private var files: Set + private let directories: Set + private var copies: [URL] = [] + private var trash: [URL] = [] + private let firstTrashStarted = TestGate() + private let firstTrashRelease: TestGate? + private let firstCopyStarted = TestGate() + private let firstCopyRelease: TestGate? + private let failingTrashURLs: Set + + /// `pausesFirstTrash` / `pausesFirstCopy` hold the first such call on the + /// production worker thread until released so a test can act mid-batch. + init( + files: [URL], directories: [URL], pausesFirstTrash: Bool = false, pausesFirstCopy: Bool = false, + failingTrashURLs: Set = [] + ) { + self.files = Set(files) + self.directories = Set(directories) + firstTrashRelease = pausesFirstTrash ? TestGate() : nil + firstCopyRelease = pausesFirstCopy ? TestGate() : nil + self.failingTrashURLs = failingTrashURLs + } + + func waitUntilFirstTrashStarted() async -> Bool { await firstTrashStarted.waitUntilOpen() } + func releaseFirstTrash() { firstTrashRelease?.open() } + func waitUntilFirstCopyStarted() async -> Bool { await firstCopyStarted.waitUntilOpen() } + func releaseFirstCopy() { firstCopyRelease?.open() } + + var copiedDestinations: [URL] { lock.withLock { copies } } + var trashedURLs: [URL] { lock.withLock { trash } } + func fileExists(at url: URL) -> Bool { + lock.withLock { (files.contains(url) || directories.contains(url)) && !trash.contains(url) } + } + func isDirectory(at url: URL) -> Bool { directories.contains(url) } + func copyItem(at sourceURL: URL, to destinationURL: URL) throws { + if !firstCopyStarted.isOpen { + firstCopyStarted.open() + if let firstCopyRelease, !firstCopyRelease.waitSynchronously() { throw CocoaError(.userCancelled) } + } + try lock.withLock { + guard !files.contains(destinationURL), !directories.contains(destinationURL) else { throw CocoaError(.fileWriteFileExists) } + guard files.contains(sourceURL) || directories.contains(sourceURL) else { throw CocoaError(.fileReadNoSuchFile) } + files.insert(destinationURL) + copies.append(destinationURL) + } + } + func trashItem(at url: URL) throws { + if !firstTrashStarted.isOpen { + firstTrashStarted.open() + if let firstTrashRelease, !firstTrashRelease.waitSynchronously() { throw CocoaError(.userCancelled) } + } + if failingTrashURLs.contains(url) { throw CocoaError(.fileWriteNoPermission) } + lock.withLock { + files = files.filter { $0 != url && !$0.path.hasPrefix(url.path + "/") } + trash.append(url) + } + } + func createFile(at url: URL) throws {} + func createDirectory(at url: URL, withIntermediateDirectories: Bool) throws {} + func moveItem(at sourceURL: URL, to destinationURL: URL) throws {} + func removeItem(at url: URL) throws {} + func writeText(_ text: String, to url: URL) throws {} + func readText(from url: URL) throws -> String { "" } +} diff --git a/macos/Tests/LitheTests/PluginPackageSignatureTests.swift b/macos/Tests/LitheTests/PluginPackageSignatureTests.swift new file mode 100644 index 000000000..b43925877 --- /dev/null +++ b/macos/Tests/LitheTests/PluginPackageSignatureTests.swift @@ -0,0 +1,101 @@ +import CryptoKit +import Foundation +import LithePluginPackageSigning +import Testing + +struct PluginPackageSignatureTests { + @Test + func signatureCoversEveryPackageFile() throws { + let root = try makePackage() + defer { try? FileManager.default.removeItem(at: root) } + let privateKey = try #require( + try? Curve25519.Signing.PrivateKey(rawRepresentation: Data(repeating: 1, count: 32)) + ) + let document = try PluginPackageSignature.makeDocument( + packageAt: root, + pluginID: "dev.example.plugin", + pluginVersion: "0.3.0", + privateKey: privateKey + ) + try PluginPackageSignature.write(document, to: root) + + try PluginPackageSignature.verify( + packageAt: root, + pluginID: "dev.example.plugin", + pluginVersion: "0.3.0", + document: document, + publicKey: privateKey.publicKey + ) + } + + @Test + func tamperingWithAPluginFileIsRejected() throws { + let root = try makePackage() + defer { try? FileManager.default.removeItem(at: root) } + let privateKey = try #require( + try? Curve25519.Signing.PrivateKey(rawRepresentation: Data(repeating: 2, count: 32)) + ) + let document = try PluginPackageSignature.makeDocument( + packageAt: root, + pluginID: "dev.example.plugin", + pluginVersion: "0.3.0", + privateKey: privateKey + ) + try PluginPackageSignature.write(document, to: root) + try Data("changed".utf8).write( + to: root.appendingPathComponent("Example.bundle/Contents/MacOS/plugin") + ) + + #expect(throws: PluginPackageSignature.Error.fileDigestMismatch( + "Example.bundle/Contents/MacOS/plugin" + )) { + try PluginPackageSignature.verify( + packageAt: root, + pluginID: "dev.example.plugin", + pluginVersion: "0.3.0", + document: document, + publicKey: privateKey.publicKey + ) + } + } + + @Test + func addingAFileAfterSigningIsRejected() throws { + let root = try makePackage() + defer { try? FileManager.default.removeItem(at: root) } + let privateKey = try #require( + try? Curve25519.Signing.PrivateKey(rawRepresentation: Data(repeating: 3, count: 32)) + ) + let document = try PluginPackageSignature.makeDocument( + packageAt: root, + pluginID: "dev.example.plugin", + pluginVersion: "0.3.0", + privateKey: privateKey + ) + try PluginPackageSignature.write(document, to: root) + try Data("unexpected".utf8).write(to: root.appendingPathComponent("unexpected.txt")) + + #expect(throws: PluginPackageSignature.Error.fileListMismatch) { + try PluginPackageSignature.verify( + packageAt: root, + pluginID: "dev.example.plugin", + pluginVersion: "0.3.0", + document: document, + publicKey: privateKey.publicKey + ) + } + } + + private func makePackage() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-plugin-signature-\(UUID().uuidString)", isDirectory: true) + let executable = root.appendingPathComponent("Example.bundle/Contents/MacOS/plugin") + try FileManager.default.createDirectory( + at: executable.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try Data("manifest".utf8).write(to: root.appendingPathComponent("plugin.json")) + try Data("binary".utf8).write(to: executable) + return root + } +} diff --git a/macos/Tests/LitheTests/PluginPackageStoreTests.swift b/macos/Tests/LitheTests/PluginPackageStoreTests.swift index 120b5d859..a076ddbdc 100644 --- a/macos/Tests/LitheTests/PluginPackageStoreTests.swift +++ b/macos/Tests/LitheTests/PluginPackageStoreTests.swift @@ -256,6 +256,53 @@ struct PluginPackageStoreTests { #expect(try store.installedPlugins().first?.manifest.version == version) } + @Test + func officialPublisherPackageCannotDowngradeSignatureRequirement() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-php-plugin-policy-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let verifier = TestPluginSignatureVerifier() + let store = MacPluginPackageStore( + rootURL: root.appendingPathComponent("installed", isDirectory: true), + verifier: verifier + ) + let package = try makePHPPluginPackage( + root: root, + name: "downgraded-policy", + version: BuiltInPluginCatalog.hostVersion + ) + let manifestURL = package.appendingPathComponent("plugin.json") + let decoder = JSONDecoder() + let manifest = try decoder.decode( + PluginManifest.self, + from: Data(contentsOf: manifestURL) + ) + let downgradedManifest = PluginManifest( + id: manifest.id, + displayName: manifest.displayName, + version: manifest.version, + hostCompatibility: manifest.hostCompatibility, + vendor: PluginVendor( + id: manifest.vendor.id, + displayName: manifest.vendor.displayName, + signatureRequirement: .sameTeamAsHost + ), + entrypoint: manifest.entrypoint, + modules: manifest.modules, + languageSupports: manifest.languageSupports ?? [] + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + try encoder.encode(downgradedManifest).write(to: manifestURL, options: .atomic) + + #expect(throws: PluginPackageStoreError.officialSignatureRequirementMismatch( + OfficialPluginCatalog.phpPluginID + )) { + _ = try store.installPackage(from: package) + } + #expect(verifier.verifiedVersions.isEmpty) + } + @Test func requiredPluginCannotBeUninstalled() throws { let root = FileManager.default.temporaryDirectory diff --git a/macos/Tests/LitheTests/ProjectTreeBatchFileWorkflowTests.swift b/macos/Tests/LitheTests/ProjectTreeBatchFileWorkflowTests.swift new file mode 100644 index 000000000..2b42114af --- /dev/null +++ b/macos/Tests/LitheTests/ProjectTreeBatchFileWorkflowTests.swift @@ -0,0 +1,122 @@ +import Foundation +import Testing +@testable import Lithe + +/// Drives project tree batch actions through the production app model, service +/// container, Rust workspace scan and native file manager on a temporary project. +/// Successful Trash moves stay in unit tests so this suite never touches ~/.Trash. +@Suite("Project tree batch file workflow", .serialized) +@MainActor +struct ProjectTreeBatchFileWorkflowTests { + @Test + func copyPasteDuplicateAndTrashUseRealFilesAndKeepUnsavedEdits() async throws { + let fixture = try BatchFileFixture() + defer { fixture.remove() } + let clipboard = BatchFileClipboardUI() + let model = fixture.makeAppModel(platformUI: clipboard) + model.openProjectDirectly(fixture.root) + #expect(await awaitChange(on: model) { fixture.treeContains(model, "b.txt") }) + + // ⌘C writes both files; ⌘V into a folder holding a.txt keeps it and adds a copy. + model.copyProjectItems([fixture.url("a.txt"), fixture.url("b.txt")]) + #expect(clipboard.files == [fixture.url("a.txt"), fixture.url("b.txt")]) + await model.pasteProjectItems(in: fixture.url("dest")) + #expect(try fixture.text("dest/a.txt") == "existing") + #expect(try fixture.text("dest/a copy.txt") == "alpha") + #expect(try fixture.text("dest/b.txt") == "beta") + #expect(await awaitChange(on: model) { fixture.treeContains(model, "dest/a copy.txt") }) + + // Duplicate keeps each copy next to its own source. + await model.duplicateProjectItems([fixture.url("a.txt"), fixture.url("dest/b.txt")]) + #expect(try fixture.text("a copy.txt") == "alpha") + #expect(try fixture.text("dest/b copy.txt") == "beta") + + // Pasting a folder into its own child is rejected without copying. + model.copyProjectItems([fixture.url("dest")]) + await model.pasteProjectItems(in: fixture.url("dest")) + #expect(!FileManager.default.fileExists(atPath: fixture.url("dest/dest").path)) + + // An edit made after the dialog opened keeps its file and editor tab. + // Only that file is requested so the test never writes to the user's Trash. + model.openFile(fixture.url("b.txt")) + #expect(await awaitChange(on: model) { model.openDocuments.contains { $0.url == fixture.url("b.txt") } }) + let edited = try #require(model.openDocuments.first { $0.url == fixture.url("b.txt") }) + model.requestDeleteProjectItems([fixture.url("b.txt"), fixture.root]) + let request = try #require(model.pendingProjectItemDeletion) + #expect(request.url == fixture.url("b.txt") && request.additionalItems.isEmpty) + model.cancelProjectItemDeletion() + edited.text = "unsaved" + await model.confirmProjectItemDeletion(request) + #expect(try fixture.text("b.txt") == "beta") + #expect(model.openDocuments.contains { $0 === edited }) + #expect(edited.text == "unsaved") + #expect(model.notifications.contains { $0.message == "Save or discard unsaved files before deleting this item" }) + } +} + +@MainActor +private final class BatchFileClipboardUI: PlatformUI { + private(set) var files: [URL] = [] + + func copyFilesToClipboard(_ urls: [URL]) -> Bool { + files = urls + return !urls.isEmpty + } + func fileURLsFromClipboard() -> [URL] { files } + func activateApplication() {} + func chooseDirectory(title: String, prompt: String) -> URL? { nil } + func chooseFile(title: String, prompt: String) -> URL? { nil } + func revealInFileBrowser(_ url: URL) {} + func open(_ url: URL) {} + func copyToClipboard(_ value: String) {} + func markdownImageFromClipboard() -> MarkdownImageSource? { nil } + func startAccessingProject(_ url: URL) -> Bool { false } + func stopAccessingProject(_ url: URL) {} +} + +@MainActor +private struct BatchFileFixture { + let root: URL + + init() throws { + // `target` is hidden by the default build-output rules, so use `dest`. + let created = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-batch-files-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: created, withIntermediateDirectories: true) + root = created.standardizedFileURL + try FileManager.default.createDirectory(at: url("dest"), withIntermediateDirectories: true) + try Data("alpha".utf8).write(to: url("a.txt")) + try Data("beta".utf8).write(to: url("b.txt")) + try Data("existing".utf8).write(to: url("dest/a.txt")) + } + + func url(_ path: String) -> URL { root.appendingPathComponent(path).standardizedFileURL } + func text(_ path: String) throws -> String { try String(contentsOf: url(path), encoding: .utf8) } + func remove() { try? FileManager.default.removeItem(at: root) } + + func treeContains(_ model: AppModel, _ path: String) -> Bool { + func contains(_ node: FileNode) -> Bool { + node.url.standardizedFileURL == url(path) || (node.children ?? []).contains(where: contains) + } + return model.rootNode.map(contains) == true + } + + func makeAppModel(platformUI: any PlatformUI) -> AppModel { + let store = BatchFileTestStore() + let settings = AppSettings(store: store) + let services = MacServiceContainer( + store: store, settings: settings, moduleLaunchMode: .safeMode, platformUI: platformUI + ).services + return AppModel(settings: settings, services: services) + } +} + +private final class BatchFileTestStore: KeyValueStore, @unchecked Sendable { + private var values: [String: Any] = [:] + + func data(forKey key: String) -> Data? { values[key] as? Data } + func object(forKey key: String) -> Any? { values[key] } + func string(forKey key: String) -> String? { values[key] as? String } + func stringArray(forKey key: String) -> [String]? { values[key] as? [String] } + func set(_ value: Any?, forKey key: String) { values[key] = value } +} diff --git a/macos/Tests/LitheTests/ProjectTreeSelectionTests.swift b/macos/Tests/LitheTests/ProjectTreeSelectionTests.swift new file mode 100644 index 000000000..74dd562f5 --- /dev/null +++ b/macos/Tests/LitheTests/ProjectTreeSelectionTests.swift @@ -0,0 +1,299 @@ +import AppKit +import Foundation +import LitheCoreContracts +import SwiftUI +import Testing +@testable import Lithe + +struct ProjectTreeSelectionTests { + @Test + @MainActor + func nativeClipboardRoundTripsMultipleFilesAndIgnoresText() { + let pasteboard = NSPasteboard(name: .init("lithe-file-test-" + UUID().uuidString)) + defer { pasteboard.releaseGlobally() } + let urls = [URL(fileURLWithPath: "/workspace/a.txt"), URL(fileURLWithPath: "/workspace/b.txt")] + #expect(MacFileClipboard.write(urls, to: pasteboard)) + #expect(MacFileClipboard.read(from: pasteboard) == urls) + pasteboard.clearContents() + pasteboard.setString("ordinary editor text", forType: .string) + #expect(MacFileClipboard.read(from: pasteboard).isEmpty) + #expect(!MacFileClipboard.write([], to: pasteboard)) + #expect(pasteboard.string(forType: .string) == "ordinary editor text") + } + + @Test + @MainActor + func treeShortcutsYieldToKeyboardFocusMovedAfterTheTreeClick() throws { + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + let tree = ProjectTreeKeyboardCommandView(frame: NSRect(x: 0, y: 0, width: 200, height: 300)) + let other = ProjectTreeFocusTestView(frame: NSRect(x: 200, y: 0, width: 200, height: 150)) + let search = NSTextField(frame: NSRect(x: 200, y: 200, width: 180, height: 24)) + let filter = NSTextField(frame: NSRect(x: 200, y: 160, width: 180, height: 24)) + defer { tree.removeMonitor(); window.makeFirstResponder(nil); window.close() } + [tree, other, search, filter].forEach { window.contentView?.addSubview($0) } + var copies = 0 + tree.copyItems = { copies += 1 } + + // Clicking the tree claims ⌘C even though the tree has no focusable view. + #expect(tree.handle(try mouseDown(at: NSPoint(x: 50, y: 50), in: window)) != nil) + #expect(tree.handle(try commandKey("c", in: window)) == nil) + #expect(copies == 1) + + // A text field focused afterwards, such as Search Everywhere, keeps ⌘C/⌘V/⌘A. + #expect(window.makeFirstResponder(search)) + #expect((window.firstResponder as? NSTextView)?.isFieldEditor == true) + #expect(tree.handle(try commandKey("c", in: window)) != nil) + #expect(tree.handle(try commandKey("v", in: window)) != nil) + #expect(copies == 1) + + // Ownership stays released until the tree is clicked again. + #expect(window.makeFirstResponder(nil)) + #expect(tree.handle(try commandKey("c", in: window)) != nil) + _ = tree.handle(try mouseDown(at: NSPoint(x: 50, y: 50), in: window)) + #expect(tree.handle(try commandKey("c", in: window)) == nil) + #expect(copies == 2) + + // Any other keyboard focus change after the first shortcut also releases it. + #expect(window.makeFirstResponder(other)) + #expect(tree.handle(try commandKey("c", in: window)) != nil) + #expect(copies == 2) + + // Fields share one field editor, so focusing another field still releases. + #expect(window.makeFirstResponder(filter)) + _ = tree.handle(try mouseDown(at: NSPoint(x: 50, y: 50), in: window)) + #expect(tree.handle(try commandKey("c", in: window)) == nil) + #expect(copies == 3) + #expect(window.makeFirstResponder(search)) + #expect(tree.handle(try commandKey("c", in: window)) != nil) + #expect(copies == 3) + + // A click outside the tree returns shortcuts to the clicked area. + _ = tree.handle(try mouseDown(at: NSPoint(x: 50, y: 50), in: window)) + _ = tree.handle(try mouseDown(at: NSPoint(x: 300, y: 50), in: window)) + #expect(tree.handle(try commandKey("c", in: window)) != nil) + #expect(copies == 3) + } + + @Test + @MainActor + func consumedTreeShortcutIsNotAlsoDeliveredToTheEditor() throws { + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + let tree = ProjectTreeKeyboardCommandView(frame: NSRect(x: 0, y: 0, width: 200, height: 300)) + defer { tree.removeMonitor(); window.close() } + window.contentView?.addSubview(tree) + var selections = 0 + tree.selectAllItems = { selections += 1 } + + // The installed monitor returns this value to AppKit; a non-nil result + // means the editor would also select all text or paste the clipboard. + _ = ProjectTreeKeyboardCommandView.monitorResult(for: try mouseDown(at: NSPoint(x: 50, y: 50), in: window), view: tree) + #expect(ProjectTreeKeyboardCommandView.monitorResult(for: try commandKey("a", in: window), view: tree) == nil) + #expect(selections == 1) + #expect(ProjectTreeKeyboardCommandView.monitorResult(for: try commandKey("a", in: window), view: nil) != nil) + } + + @MainActor + private func mouseDown(at point: NSPoint, in window: NSWindow) throws -> NSEvent { + try #require(NSEvent.mouseEvent( + with: .leftMouseDown, location: point, modifierFlags: [], timestamp: 0, + windowNumber: window.windowNumber, context: nil, eventNumber: 1, clickCount: 1, pressure: 1)) + } + + @MainActor + private func commandKey(_ character: String, in window: NSWindow) throws -> NSEvent { + try #require(NSEvent.keyEvent( + with: .keyDown, location: .zero, modifierFlags: [.command], timestamp: 0, + windowNumber: window.windowNumber, context: nil, characters: character, + charactersIgnoringModifiers: character, isARepeat: false, keyCode: 0)) + } + + @Test + func modifiedClicksToggleAndShiftKeepsItsAnchor() { + var selection = ProjectTreeSelection() + let rows = ["a", "b", "c", "d"] + selection.select("b", visiblePaths: rows, extending: false, toggling: false) + selection.select("d", visiblePaths: rows, extending: true, toggling: false) + #expect(selection.paths == ["b", "c", "d"]) + selection.select("a", visiblePaths: rows, extending: true, toggling: false) + #expect(selection.paths == ["a", "b"]) + selection.select("d", visiblePaths: rows, extending: false, toggling: true) + #expect(selection.paths == ["a", "b", "d"]) + selection.select("b", visiblePaths: rows, extending: false, toggling: true) + #expect(selection.paths == ["a", "d"]) + } + + @Test + @MainActor + func modifiedRowClicksReachTheOverlayThroughWindowDispatch() throws { + let rows = ["folder", "a", "b", "c"] + var selection = ProjectTreeSelection() + var activated: [String] = [] + let rowHeight: CGFloat = 22 + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 240, height: rowHeight * CGFloat(rows.count)), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + defer { window.orderOut(nil); window.close() } + // Mirrors FileNodeRow: the Button selects and opens a file or folds a + // folder, and the overlay handles modified clicks. + window.contentView = NSHostingView(rootView: VStack(spacing: 0) { + ForEach(rows, id: \.self) { row in + Button { + selection.select(row, visiblePaths: rows, extending: false, toggling: false) + activated.append(row) + } label: { + Color.clear.frame(width: 240, height: rowHeight).contentShape(Rectangle()) + } + .buttonStyle(.litheNoPress) + .overlay { + ProjectTreeModifiedClick { flags in + selection.select(row, visiblePaths: rows, extending: flags.contains(.shift), + toggling: flags.contains(.command)) + } + } + } + }) + // Hosting views hit-test only in an on-screen window; it never becomes key. + window.orderFrontRegardless() + window.layoutIfNeeded() + // The window hit-tests each event and routes it to the overlay or the + // Button as in the app; only the current-event lookup is substituted. + var dispatching: NSEvent? + for overlay in try #require(window.contentView).descendants.compactMap({ $0 as? ProjectTreeModifiedClickView }) { + overlay.currentEvent = { dispatching } + } + func click(_ index: Int, _ flags: NSEvent.ModifierFlags) throws { + let point = NSPoint(x: 50, y: rowHeight * (CGFloat(rows.count - index) - 0.5)) + for type in [NSEvent.EventType.leftMouseDown, .leftMouseUp] { + let event = try #require(NSEvent.mouseEvent( + with: type, location: point, modifierFlags: flags, timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, context: nil, eventNumber: 1, clickCount: 1, pressure: 1)) + dispatching = event + window.sendEvent(event) + } + dispatching = nil + } + + // A plain click goes to the Button and sets the range anchor. + try click(1, []) + #expect(activated == ["a"]) + #expect(selection.paths == ["a"]) + // Shift extends forward and backward, ⌘ toggles; none of these clicks + // reach the Button, so files stay closed and the folder stays as is. + try click(3, .shift) + #expect(selection.paths == ["a", "b", "c"]) + try click(2, .command) + #expect(selection.paths == ["a", "c"]) + try click(3, []) + try click(1, .shift) + #expect(selection.paths == ["a", "b", "c"]) + try click(0, .command) + #expect(selection.paths == ["folder", "a", "b", "c"]) + #expect(activated == ["a", "c"]) + + // Control-click passes through the overlay to the row below, where + // FileNodeRow's context menu capture handles it. + try click(2, [.control, .shift]) + #expect(activated == ["a", "c", "b"]) + } + + @Test + func rightClickPreservesTheGroupOnlyForSelectedRows() { + let root = FileNode(url: URL(fileURLWithPath: "/p"), isDirectory: true, children: [ + FileNode(url: URL(fileURLWithPath: "/p/a"), isDirectory: false, children: nil), + FileNode(url: URL(fileURLWithPath: "/p/b"), isDirectory: false, children: nil) + ]) + var selection = ProjectTreeSelection() + selection.selectAll(in: root) + selection.selectForContextMenu("/p/a") + #expect(selection.paths == ["/p/a", "/p/b"]) + selection.selectForContextMenu("/p/c") + #expect(selection.paths == ["/p/c"]) + #expect(selection.anchorPath == "/p/c") + } + + @Test + func selectAllUsesTheFocusedRowsSiblings() { + func node(_ path: String, _ children: [FileNode]? = nil, collapsed: [String] = []) -> FileNode { + FileNode(url: URL(fileURLWithPath: path), isDirectory: children != nil, children: children, + collapsedAncestorPaths: collapsed) + } + // `src/com/acme` is a compacted package shown directly below `src`. + let root = node("/p", [ + node("/p/dest", [node("/p/dest/a.txt")]), + node("/p/dest copy", [node("/p/dest copy/a.txt"), node("/p/dest copy/b.txt")]), + node("/p/src", [node("/p/src/com/acme", [node("/p/src/com/acme/App.java")], collapsed: ["/p/src/com"])]), + node("/p/alpha.txt"), node("/p/gamma.txt") + ]) + let topLevel: Set = ["/p/dest", "/p/dest copy", "/p/src", "/p/alpha.txt", "/p/gamma.txt"] + var selection = ProjectTreeSelection() + func selectAll(focusing path: String) -> Set { + selection.select(path, visiblePaths: [path], extending: false, toggling: false) + selection.selectAll(in: root) + return selection.paths + } + + // A file inside a folder selects that folder's items only. + #expect(selectAll(focusing: "/p/dest copy/a.txt") == ["/p/dest copy/a.txt", "/p/dest copy/b.txt"]) + #expect(selection.focusedPath == "/p/dest copy/a.txt") + // A folder selects the items beside it, not its own contents. + #expect(selectAll(focusing: "/p/dest copy") == topLevel) + #expect(selectAll(focusing: "/p/alpha.txt") == topLevel) + // A similarly named sibling folder stays out of the scope. + #expect(selectAll(focusing: "/p/dest/a.txt") == ["/p/dest/a.txt"]) + // Compacted packages use their displayed parent. + #expect(selectAll(focusing: "/p/src/com/acme") == ["/p/src/com/acme"]) + #expect(selectAll(focusing: "/p/src/com/acme/App.java") == ["/p/src/com/acme/App.java"]) + // Selected folders show their contents as selected too, without adding + // them to the action set or matching a similarly named sibling. + #expect(selectAll(focusing: "/p/dest copy") == topLevel) + #expect(selection.covers("/p/dest copy/a.txt")) + #expect(selection.covers("/p/src/com/acme/App.java")) + #expect(!selection.paths.contains("/p/dest copy/a.txt")) + // Right-clicking a covered row keeps the group for the batch menu. + selection.selectForContextMenu("/p/dest copy/a.txt") + #expect(selection.paths == topLevel) + // Inside a single selected folder, right-click targets the clicked row. + selection.select("/p/dest", visiblePaths: ["/p/dest"], extending: false, toggling: false) + #expect(selection.covers("/p/dest/a.txt") && !selection.covers("/p/dest copy/a.txt")) + selection.selectForContextMenu("/p/dest/a.txt") + #expect(selection.paths == ["/p/dest/a.txt"]) + // The project row, or no focus, selects the top-level items. + #expect(selectAll(focusing: "/p") == topLevel) + var unfocused = ProjectTreeSelection() + unfocused.selectAll(in: root) + #expect(unfocused.paths == topLevel) + } + + @Test + func collapsedChildrenAreExcludedFromRangesAndSelection() { + let rootURL = URL(fileURLWithPath: "/workspace") + let child = FileNode(url: rootURL.appendingPathComponent("folder/hidden"), isDirectory: false, children: nil) + let folder = FileNode(url: rootURL.appendingPathComponent("folder"), isDirectory: true, children: [child]) + let last = FileNode(url: rootURL.appendingPathComponent("last"), isDirectory: false, children: nil) + let root = FileNode(url: rootURL, isDirectory: true, children: [folder, last]) + let visible = ProjectTreeSelection.visibleNodes(in: root, expandedPaths: [rootURL.path]).map { $0.url.path } + #expect(visible == [rootURL.path, folder.url.path, last.url.path]) + var selection = ProjectTreeSelection() + selection.select(last.url.path, visiblePaths: visible, extending: false, toggling: false) + selection.select(child.url.path, visiblePaths: visible, extending: false, toggling: true) + selection.retain(visiblePaths: visible) + #expect(!selection.paths.contains(child.url.path)) + #expect(selection.focusedPath == nil) + selection.select(folder.url.path, visiblePaths: visible, extending: false, toggling: false) + selection.select(last.url.path, visiblePaths: visible, extending: true, toggling: false) + #expect(selection.paths == [folder.url.path, last.url.path]) + } +} + +/// A non-text responder standing in for a focused editor or panel. +private final class ProjectTreeFocusTestView: NSView { + override var acceptsFirstResponder: Bool { true } +} + +private extension NSView { + var descendants: [NSView] { subviews + subviews.flatMap(\.descendants) } +} diff --git a/macos/Tools/LithePluginPackageSigner/main.swift b/macos/Tools/LithePluginPackageSigner/main.swift new file mode 100644 index 000000000..a5002e17f --- /dev/null +++ b/macos/Tools/LithePluginPackageSigner/main.swift @@ -0,0 +1,86 @@ +import CryptoKit +import Foundation +import LithePluginPackageSigning + +@main +struct LithePluginPackageSigner { + static func main() throws { + guard CommandLine.arguments.count == 2 || + (CommandLine.arguments.count == 3 && CommandLine.arguments[1] == "--verify") else { + throw SignerError.usage + } + let isVerification = CommandLine.arguments.count == 3 + let packageURL = URL( + fileURLWithPath: CommandLine.arguments[isVerification ? 2 : 1], + isDirectory: true + ) + let manifest = try loadManifest(from: packageURL) + + if isVerification { + let document = try PluginPackageSignature.read(from: packageURL) + guard let publicKeyData = Data(base64Encoded: PluginPackageSignature.publisherPublicKeyBase64), + let publicKey = try? Curve25519.Signing.PublicKey(rawRepresentation: publicKeyData) else { + throw SignerError.invalidPublicKey + } + try PluginPackageSignature.verify( + packageAt: packageURL, + pluginID: manifest.pluginID, + pluginVersion: manifest.pluginVersion, + document: document, + publicKey: publicKey + ) + writeStatus("Verified publisher signature for \(manifest.pluginID) \(manifest.pluginVersion)") + return + } + + let keyText = String( + data: FileHandle.standardInput.readDataToEndOfFile(), + encoding: .utf8 + )?.trimmingCharacters(in: .whitespacesAndNewlines) + guard let keyText, + let keyData = Data(base64Encoded: keyText), + let privateKey = try? Curve25519.Signing.PrivateKey(rawRepresentation: keyData) else { + throw SignerError.invalidPrivateKey + } + guard privateKey.publicKey.rawRepresentation.base64EncodedString() + == PluginPackageSignature.publisherPublicKeyBase64 else { + throw SignerError.untrustedPrivateKey + } + + let document = try PluginPackageSignature.makeDocument( + packageAt: packageURL, + pluginID: manifest.pluginID, + pluginVersion: manifest.pluginVersion, + privateKey: privateKey + ) + try PluginPackageSignature.write(document, to: packageURL) + writeStatus("Signed publisher package \(manifest.pluginID) \(manifest.pluginVersion)") + } + + /// Keep stdout available for machine-readable command results. + /// Build scripts capture stdout as the package path, so status messages belong on stderr. + private static func writeStatus(_ message: String) { + FileHandle.standardError.write(Data((message + "\n").utf8)) + } + + private static func loadManifest(from packageURL: URL) throws -> (pluginID: String, pluginVersion: String) { + let manifestURL = packageURL.appendingPathComponent("plugin.json") + guard let manifestData = try? Data(contentsOf: manifestURL), + let manifest = try? JSONSerialization.jsonObject(with: manifestData) as? [String: Any], + let pluginID = manifest["id"] as? String, + let pluginVersion = manifest["version"] as? String, + !pluginID.isEmpty, + !pluginVersion.isEmpty else { + throw SignerError.invalidManifest + } + return (pluginID, pluginVersion) + } +} + +private enum SignerError: Error { + case usage + case invalidPrivateKey + case untrustedPrivateKey + case invalidPublicKey + case invalidManifest +} diff --git a/rust/lithe-agent-host/src/catalog.rs b/rust/lithe-agent-host/src/catalog.rs index 73418954b..18fe121b8 100644 --- a/rust/lithe-agent-host/src/catalog.rs +++ b/rust/lithe-agent-host/src/catalog.rs @@ -1,7 +1,7 @@ //! Agents Lithe can install and launch, and how each receives its settings. //! -//! Every adapter signs in through the ACP `gateway` method, so the user's API -//! key travels over stdio in the header of the agent's provider protocol. +//! API keys travel over ACP stdio. Codex uses `gateway` authentication; Claude +//! uses its public session options to avoid gateway placeholder credentials. //! //! Entries mirror the official ACP registry //! (`cdn.agentclientprotocol.com/registry/v1/latest/registry.json`): the same diff --git a/rust/lithe-agent-host/src/lib.rs b/rust/lithe-agent-host/src/lib.rs index aa545fbbc..afc521e0b 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 session_routing; mod subscription; pub use catalog::{ModelDelivery, ProviderProtocol}; @@ -52,7 +53,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. -/// API-key mode uses this method and never falls back to account login. +/// Responses 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. @@ -150,13 +151,15 @@ impl std::fmt::Debug for ProviderCredentials { } } -/// Gateway sign-in sent in ACP `authenticate`. +/// API-key routing sent over ACP stdio, using each adapter's supported interface. #[derive(Clone)] struct GatewaySignIn { + protocol: ProviderProtocol, base_url: String, /// Authentication headers in the dialect of the provider's protocol. headers: Vec<(String, String)>, provider_name: Option, + model: Option, } /// A validated launch: what to run and how the key reaches the agent. @@ -164,7 +167,7 @@ struct ResolvedLaunch { command: PathBuf, args: Vec, cwd: PathBuf, - /// Extra environment, used only by adapters that accept no other key input. + /// Non-secret CLI paths and model defaults for the native adapters. env: Vec<(String, String)>, gateway: Option, /// Key to redact from diagnostics. @@ -205,8 +208,8 @@ fn resolve_with( if !launch.cwd.is_absolute() { return Err("The workspace path must be absolute".into()); } - // Every adapter signs in through the ACP `gateway` method; the key travels - // over stdio in the header its protocol expects. + // Credentials travel over ACP stdio. Claude uses its public session options + // because its gateway mode adds a conflicting placeholder Bearer token. let gateway = |provider: &ProviderCredentials| -> Result { let (base_url, header) = match provider.protocol { ProviderProtocol::AnthropicMessages => ( @@ -222,9 +225,11 @@ fn resolve_with( ), }; Ok(GatewaySignIn { + protocol: provider.protocol, base_url, headers: vec![header], provider_name: provider.name.clone(), + model: provider.model.clone(), }) }; let Some(agent_id) = launch.agent_id else { @@ -829,6 +834,7 @@ where OB: futures::io::AsyncWrite + Send + 'static, IB: futures::io::AsyncRead + Send + 'static, { + let session_meta = session_routing::metadata(gateway.as_ref())?; let (auth_tx, mut auth_rx) = tokio::sync::watch::channel(None::); let turns: RunningTurns = Arc::new(Mutex::new(HashMap::new())); let updates = emit.clone(); @@ -945,7 +951,7 @@ where .await .map_err(|_| internal("The Agent did not finish initialization in time"))??; // API-key routing is explicit and never falls back to account login. - if let Some(gateway) = &gateway { + if let Some(gateway) = gateway.as_ref().filter(|route| route.protocol != ProviderProtocol::AnthropicMessages) { if !initialized .auth_methods .iter() @@ -1046,10 +1052,13 @@ where let connection = connection.clone(); let emit = emit.clone(); let cwd = cwd.clone(); + let session_meta = session_meta.clone(); tasks.spawn(async move { + let mut request = agent_client_protocol::schema::v1::NewSessionRequest::new(cwd); + request.meta = session_meta; let result = request_with_timeout( SESSION_REQUEST_TIMEOUT, - session_defaults::new_session(&connection, cwd), + session_defaults::new_session(&connection, request), ) .await; emit(match result { @@ -1066,11 +1075,14 @@ where let connection = connection.clone(); let emit = emit.clone(); let cwd = cwd.clone(); + let session_meta = session_meta.clone(); tasks.spawn(async move { + let mut request = LoadSessionRequest::new(session_id.clone(), cwd); + request.meta = session_meta; let result = request_with_timeout( LOAD_SESSION_TIMEOUT, connection - .send_request(LoadSessionRequest::new(session_id.clone(), cwd)) + .send_request(request) .block_task(), ) .await; diff --git a/rust/lithe-agent-host/src/session_defaults.rs b/rust/lithe-agent-host/src/session_defaults.rs index e2b39c8b4..b287150a4 100644 --- a/rust/lithe-agent-host/src/session_defaults.rs +++ b/rust/lithe-agent-host/src/session_defaults.rs @@ -4,8 +4,6 @@ //! Its legacy model state excludes that choice, and its versioned AIR extension //! supplies the actual recommended model. Neither fact is guessed from a name. -use std::path::PathBuf; - use agent_client_protocol::schema::v1::{ NewSessionRequest, NewSessionResponse, SessionConfigKind, SessionConfigOptionCategory, SessionConfigSelectOptions, SetSessionConfigOptionRequest, @@ -103,12 +101,10 @@ fn replacement(response: &CatalogSessionResponse) -> Option<(String, String)> { /// request deadline covers both requests; history and global CLI files stay intact. pub(crate) async fn new_session( connection: &ConnectionTo, - cwd: PathBuf, + request: NewSessionRequest, ) -> Result { let mut response = connection - .send_request(CatalogSessionRequest { - request: NewSessionRequest::new(cwd), - }) + .send_request(CatalogSessionRequest { request }) .block_task() .await?; if let Some((id, model)) = replacement(&response) { diff --git a/rust/lithe-agent-host/src/session_routing.rs b/rust/lithe-agent-host/src/session_routing.rs new file mode 100644 index 000000000..35e4756a2 --- /dev/null +++ b/rust/lithe-agent-host/src/session_routing.rs @@ -0,0 +1,57 @@ +//! Claude API-key routing through the upstream adapter's public session options. +//! +//! Credentials reach the adapter over ACP stdio, never through host launch +//! arguments, environment, or files. The adapter and Claude SDK own their native +//! credential conversion; Lithe does not patch installed third-party packages. + +use serde_json::{json, Map, Value}; + +use crate::{GatewaySignIn, ProviderProtocol}; + +/// Apply the same explicit route when creating or restoring a Claude session. +pub(crate) fn metadata( + route: Option<&GatewaySignIn>, +) -> Result>, String> { + let Some(route) = route.filter(|route| route.protocol == ProviderProtocol::AnthropicMessages) + else { + return Ok(None); + }; + let key = route + .headers + .iter() + .find(|(name, value)| name == "x-api-key" && !value.trim().is_empty()) + .ok_or("The Claude API key route is missing its credential")?; + // Gateway mode injects `Bearer acp-proxy`, even with a real x-api-key. Native + // API-key options avoid that token and retain the complete upstream agent. + let env = json!({ + "ANTHROPIC_BASE_URL": route.base_url, + "ANTHROPIC_API_KEY": key.1, + "ANTHROPIC_AUTH_TOKEN": "", + "ANTHROPIC_CUSTOM_HEADERS": "", + "CLAUDE_CODE_OAUTH_TOKEN": "", + "CLAUDE_CODE_USE_BEDROCK": "0", + "CLAUDE_CODE_USE_VERTEX": "0", + "CLAUDE_CODE_USE_FOUNDRY": "0", + "CLAUDE_CODE_USE_ANTHROPIC_AWS": "0", + "ANTHROPIC_BEDROCK_BASE_URL": "", + "ANTHROPIC_VERTEX_BASE_URL": "" + }); + let mut options = json!({ + "env": env, + // Claude loads settings after subprocess env. Both public SDK tiers + // must carry the selected route so user/project settings cannot replace it. + "settings": {"apiKeyHelper": "", "env": env} + }); + if let Some(model) = route + .model + .as_deref() + .map(str::trim) + .filter(|model| !model.is_empty()) + { + options["model"] = json!(model); + } + Ok(Some(Map::from_iter([( + "claudeCode".into(), + json!({"options": options}), + )]))) +} diff --git a/rust/lithe-agent-host/src/tests.rs b/rust/lithe-agent-host/src/tests.rs index 9a7f98750..d014152ca 100644 --- a/rust/lithe-agent-host/src/tests.rs +++ b/rust/lithe-agent-host/src/tests.rs @@ -26,12 +26,27 @@ fn provider() -> ProviderCredentials { fn gateway() -> Option { Some(GatewaySignIn { + protocol: ProviderProtocol::Responses, base_url: "https://gateway.example.com/v1".into(), headers: vec![("Authorization".into(), "Bearer test-key-123".into())], provider_name: Some("Example".into()), + model: None, }) } +fn claude_route() -> GatewaySignIn { + let provider: ProviderCredentials = + serde_json::from_value(fixture()["upstream"]["claudeSessionRouting"]["provider"].clone()) + .expect("Claude provider fixture"); + GatewaySignIn { + protocol: provider.protocol, + base_url: provider.anthropic_base_url().expect("valid endpoint"), + headers: vec![("x-api-key".into(), provider.api_key)], + provider_name: provider.name, + model: provider.model, + } +} + fn custom_launch(command: &str, provider: ProviderCredentials) -> AgentLaunch { AgentLaunch { agent_id: None, @@ -116,6 +131,10 @@ impl Harness { } fn start_with(subscription: bool) -> Self { + Self::start_with_route(subscription, if subscription { None } else { gateway() }) + } + + fn start_with_route(subscription: bool, route: Option) -> 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); @@ -125,7 +144,7 @@ impl Harness { let connection = tokio::spawn(run_connection( ByteStreams::new(client_writer.compat_write(), client_reader.compat()), std::env::temp_dir(), - if subscription { None } else { gateway() }, + route, subscription.then(|| PathBuf::from("/fixture/codex")), receiver, permissions.clone(), @@ -306,6 +325,10 @@ fn serialized_events_match_the_shared_fixture() { "toolCallUpdate", "sessionInfo", "usageUpdate", + "agentThoughtChunk", + "plan", + "availableCommands", + "currentModeUpdate", ] { let update = &events[name]["update"]; let parsed: agent_client_protocol::schema::v1::SessionUpdate = @@ -446,6 +469,78 @@ async fn handshake_signs_in_through_the_gateway_with_the_user_key() { assert_eq!(harness.stop().await, Ok(())); } +#[test] +fn claude_session_routing_clears_conflicting_credentials_and_preserves_sdk_options() { + let mut route = claude_route(); + let meta = session_routing::metadata(Some(&route)).unwrap().unwrap(); + assert_eq!( + json!(meta), + fixture()["upstream"]["claudeSessionRouting"]["meta"] + ); + let options = &meta["claudeCode"]["options"]; + assert!(options.get("permissionMode").is_none()); + assert!(options.get("tools").is_none()); + // No explicit model keeps the upstream default rather than inventing one. + route.model = Some(" ".into()); + let meta = session_routing::metadata(Some(&route)).unwrap().unwrap(); + assert!(meta["claudeCode"]["options"].get("model").is_none()); + assert!(session_routing::metadata(gateway().as_ref()) + .unwrap() + .is_none()); + assert!(session_routing::metadata(None).unwrap().is_none()); +} + +#[test] +fn claude_session_routing_rejects_missing_credentials_without_native_account_fallback() { + let mut route = claude_route(); + route.headers.clear(); + assert!(session_routing::metadata(Some(&route)).is_err()); + route.headers.push(("x-api-key".into(), " ".into())); + assert!(session_routing::metadata(Some(&route)).is_err()); +} + +#[tokio::test(flavor = "current_thread")] +async fn claude_new_and_restored_sessions_receive_credentials_over_stdio_without_gateway_login() { + let mut harness = Harness::start_with_route(false, Some(claude_route())); + let initialize = harness.agent.expect("initialize").await; + harness + .agent + .reply( + &initialize, + json!({ + "protocolVersion": 1, + "agentInfo": {"name": "claude-acp", "version": "fixture"}, + "agentCapabilities": {"loadSession": true}, + "authMethods": [] + }), + ) + .await; + assert!(matches!(harness.event().await, AgentEvent::Ready { .. })); + let expected = fixture()["upstream"]["claudeSessionRouting"]["meta"].clone(); + harness.send(json!({"kind": "newSession", "token": "new-claude"})); + // The very next request must be session/new, never gateway authenticate. + let created = harness.agent.expect("session/new").await; + assert_eq!(created["params"]["_meta"], expected); + harness + .agent + .reply(&created, json!({"sessionId": "claude-1"})) + .await; + assert!(matches!( + harness.event().await, + AgentEvent::SessionCreated { .. } + )); + harness.send(json!({"kind": "loadSession", "token": "load-claude", "sessionId": "claude-1"})); + let loaded = harness.agent.expect("session/load").await; + assert_eq!(loaded["params"]["_meta"], expected); + assert_eq!(loaded["params"]["sessionId"], "claude-1"); + harness.agent.reply(&loaded, json!({})).await; + assert!(matches!( + harness.event().await, + AgentEvent::SessionLoaded { .. } + )); + assert_eq!(harness.stop().await, Ok(())); +} + #[tokio::test(flavor = "current_thread")] async fn agent_without_gateway_sign_in_is_rejected_without_account_login() { let mut harness = Harness::start(); @@ -1139,8 +1234,8 @@ fn catalog_agents_resolve_to_their_install_and_key_delivery() { }, )) .unwrap(); - // Claude signs in through the gateway too, in the Anthropic dialect; the - // key never enters the adapter's environment. + // Claude uses ACP session options; the key never enters the adapter's + // launch environment or arguments. let sign_in = claude.gateway.expect("gateway sign-in"); assert_eq!(sign_in.base_url, "https://api.example"); assert_eq!( diff --git a/rust/lithe-agent-host/tests/real_agent.rs b/rust/lithe-agent-host/tests/real_agent.rs index ed00d7ac4..6596903fb 100644 --- a/rust/lithe-agent-host/tests/real_agent.rs +++ b/rust/lithe-agent-host/tests/real_agent.rs @@ -11,9 +11,10 @@ //! //! `LITHE_ACP_E2E_MODEL` optionally selects the provider model and //! `LITHE_ACP_E2E_ARGS` holds newline-separated arguments. With -//! `LITHE_ACP_E2E_DATA_DIR` set instead of a command, the Codex adapter is +//! `LITHE_ACP_E2E_DATA_DIR` set instead of a command, the catalog adapter is //! installed there with the user's npm (if missing) and launched as a catalog -//! agent, covering the one-click install path. +//! agent, covering the one-click install path. `LITHE_ACP_E2E_AGENT_ID` selects +//! the adapter and its provider protocol (defaults to `codex-acp`). use std::sync::{mpsc, Arc}; use std::time::{Duration, Instant}; @@ -129,15 +130,20 @@ fn required(name: &str) -> String { fn open(workspace: &std::path::Path) -> Session { let (sender, events) = mpsc::channel(); let data_directory = std::env::var_os("LITHE_ACP_E2E_DATA_DIR").map(std::path::PathBuf::from); + let agent_id = std::env::var("LITHE_ACP_E2E_AGENT_ID").unwrap_or_else(|_| "codex-acp".into()); + let agent = lithe_agent_host::catalog::find(&agent_id).expect("catalog E2E agent"); + let protocol = if data_directory.is_some() { + agent.protocol + } else { + ProviderProtocol::Responses + }; if let Some(data) = &data_directory { - if install::installed_version(data, lithe_agent_host::catalog::find("codex-acp").unwrap()) - .is_none() - { - install::install(data, "codex-acp", &|| false).expect("adapter installs with npm"); + if install::installed_version(data, agent).is_none() { + install::install(data, &agent_id, &|| false).expect("adapter installs with npm"); } } let launch = AgentLaunch { - agent_id: data_directory.as_ref().map(|_| "codex-acp".to_owned()), + agent_id: data_directory.as_ref().map(|_| agent_id), command: std::env::var("LITHE_ACP_E2E_COMMAND").ok(), args: std::env::var("LITHE_ACP_E2E_ARGS") .map(|args| { @@ -151,7 +157,7 @@ fn open(workspace: &std::path::Path) -> Session { data_directory, authentication: lithe_agent_host::AgentAuthentication::ApiKey, provider: Some(ProviderCredentials { - protocol: ProviderProtocol::Responses, + protocol, base_url: required("LITHE_ACP_E2E_BASE_URL"), api_key: required("LITHE_ACP_E2E_API_KEY"), name: Some("Lithe end-to-end test".into()), @@ -289,7 +295,7 @@ fn real_agent_reads_edits_tests_and_continues_in_temporary_project() { ) .unwrap(); std::fs::write(workspace.0.join("sum.test.cjs"), - "const assert = require('node:assert/strict');\nconst sum = require('./sum.cjs');\nassert.equal(sum(2, 3), 5);\nconsole.log('LITHE_TEST_PASSED');\n").unwrap(); + "const assert = require('node:assert/strict');\nconst sum = require('./sum.cjs');\nassert.equal(sum(2, 3), 5);\nassert.equal(sum(-2, 4), 2);\nconsole.log('LITHE_TEST_PASSED');\n").unwrap(); let session = open(&workspace.0); let id = session.new_session("workflow"); session.send(AgentCommand::Prompt { @@ -353,7 +359,7 @@ fn real_agent_reads_edits_tests_and_continues_in_temporary_project() { } #[test] -#[ignore = "requires a real Codex ACP agent and API key configuration"] +#[ignore = "requires a real catalog ACP agent and API key configuration"] fn real_agent_configuration_options_are_selectable() { let workspace = TemporaryProject::new(); let session = open(&workspace.0); @@ -377,23 +383,29 @@ fn real_agent_configuration_options_are_selectable() { _ => None, }, ); - let options = options.as_array().expect("Codex reports config options"); + let mut options = options + .as_array() + .expect("Agent reports config options") + .clone(); for category in ["model", "mode", "thought_level"] { - let option = options - .iter() - .find(|option| option["category"] == category) - .unwrap_or_else(|| panic!("missing category {category}")); + let option = options.iter().find(|option| option["category"] == category); + // Thinking choices are optional upstream capabilities, not a product list. + if category == "thought_level" && option.is_none() { + continue; + } + let option = option.unwrap_or_else(|| panic!("missing category {category}")); let config_id = option["id"].as_str().unwrap(); let current = option["currentValue"].as_str().unwrap(); - // Change reasoning to another supported value; model and permission - // round trips preserve the user's effective defaults without a prompt. - let value = if category == "thought_level" { + // Switch models as well as reasoning; a same-value acknowledgement + // would miss the user-visible failure. Keep the permission mode intact. + let value = if category != "mode" { option["options"] .as_array() .unwrap() .iter() .filter_map(|choice| choice["value"].as_str()) - .find(|value| *value != current) + .filter(|value| *value != current) + .next_back() .unwrap_or(current) } else { current @@ -424,6 +436,25 @@ fn real_agent_configuration_options_are_selectable() { .find(|o| o["id"] == config_id) .unwrap(); assert_eq!(actual["currentValue"], value); + println!("Configuration {category}: {current} -> {value}"); + if category == "model" { + assert_ne!( + value, current, + "the integration must switch to another model" + ); + } + // Changing the model can remove thinking options (e.g. Claude Haiku). + // Subsequent choices must come from the confirmed current catalog. + options = updated.as_array().unwrap().clone(); } + let (reason, reply) = session.prompt( + &id, + "Do not use any tools. Reply with only LITHE_MODEL_SWITCH_OK.", + ); + assert_eq!(reason, "end_turn"); + assert!( + reply.contains("LITHE_MODEL_SWITCH_OK"), + "no reply after switching: {reply:?}" + ); session.handle.close(); } diff --git a/scripts/build-official-plugins.sh b/scripts/build-official-plugins.sh index 369f3b35f..6a10a70a1 100755 --- a/scripts/build-official-plugins.sh +++ b/scripts/build-official-plugins.sh @@ -31,13 +31,17 @@ case "$TRIPLE" in *) print -u2 -- "Unsupported macOS Swift triple: $TRIPLE"; exit 2 ;; esac -BUILD_DIR="$ROOT_DIR/.build/$TRIPLE/$CONFIGURATION" -if [[ -e "$BUILD_DIR/Modules/LitheModuleAPI.swiftmodule" && \ - -e "$BUILD_DIR/Modules/LitheCoreContracts.swiftmodule" ]]; then - MODULE_DIR="$BUILD_DIR/Modules" +SWIFT_LAYOUT_ARGS=( + --triple "$TRIPLE" + --configuration "$CONFIGURATION" +) +SWIFT_BIN_PATH=$(swift build --show-bin-path "${SWIFT_LAYOUT_ARGS[@]}") +if [[ -e "$SWIFT_BIN_PATH/Modules/LitheModuleAPI.swiftmodule" && \ + -e "$SWIFT_BIN_PATH/Modules/LitheCoreContracts.swiftmodule" ]]; then + MODULE_DIR="$SWIFT_BIN_PATH/Modules" else # SwiftPM 6.4 places package modules directly beside the executable. - MODULE_DIR="$BUILD_DIR" + MODULE_DIR="$SWIFT_BIN_PATH" fi if [[ ! -e "$MODULE_DIR/LitheModuleAPI.swiftmodule" || ! -e "$MODULE_DIR/LitheCoreContracts.swiftmodule" ]]; then print -u2 -- "Build Lithe for $TRIPLE ($CONFIGURATION) before packaging official plugins" @@ -45,9 +49,10 @@ if [[ ! -e "$MODULE_DIR/LitheModuleAPI.swiftmodule" || ! -e "$MODULE_DIR/LitheCo fi if [[ -z "$OUTPUT_DIR" ]]; then - OUTPUT_DIR="$BUILD_DIR/OfficialPlugins" + OUTPUT_DIR="$SWIFT_BIN_PATH/OfficialPlugins" fi -SDK_PATH=$(/usr/bin/xcrun --sdk macosx --show-sdk-path) +# Match the host build's explicit SDK when multiple SDKs are installed. +SDK_PATH="${SDKROOT:-$(/usr/bin/xcrun --sdk macosx --show-sdk-path)}" if ! SWIFT_COMPILER=$(command -v swiftc); then print -u2 -- "Swift compiler is not available on PATH" exit 1 @@ -59,6 +64,7 @@ for stale_package in "$OUTPUT_DIR"/*(/N); do rm -rf "$stale_package" done matched=0 +signer_binary="" for plugin_source in "$ROOT_DIR"/Plugins/mac/Official/*(/N); do manifest="$plugin_source/plugin.json" info_plist="$plugin_source/Info.plist" @@ -70,6 +76,15 @@ for plugin_source in "$ROOT_DIR"/Plugins/mac/Official/*(/N); do if [[ "$BUNDLED_ONLY" == true ]] && ! node "$ROOT_DIR/scripts/official-plugin-distribution.mjs" "$package_id"; then continue fi + signature_requirement=$(/usr/bin/plutil -extract vendor.signatureRequirement raw "$manifest") + if [[ "$signature_requirement" == "publisherPackage" && -z "${LITHE_PLUGIN_PACKAGE_PRIVATE_KEY:-}" ]]; then + if [[ "$CONFIGURATION" == "release" ]]; then + print -u2 -- "Configure LITHE_PLUGIN_PACKAGE_PRIVATE_KEY for publisher-signed plugin packages" + exit 1 + fi + print -u2 -- "Skipping publisher-signed debug plugin package $package_id; set LITHE_PLUGIN_PACKAGE_PRIVATE_KEY to build it" + continue + fi matched=$((matched + 1)) module_suffix="${plugin_source:t}" source_dir="$plugin_source/Sources/Lithe${module_suffix}Module" @@ -117,6 +132,24 @@ for plugin_source in "$ROOT_DIR"/Plugins/mac/Official/*(/N); do fi /usr/bin/codesign --force --sign "$SIGNING_IDENTITY" "$bundle_dir" + + if [[ "$signature_requirement" == "publisherPackage" ]]; then + if [[ -z "$signer_binary" ]]; then + swift build \ + "${SWIFT_LAYOUT_ARGS[@]}" \ + --product LithePluginPackageSigner >&2 + signer_bin_dir=$(swift build \ + "${SWIFT_LAYOUT_ARGS[@]}" \ + --show-bin-path) + signer_binary="$signer_bin_dir/LithePluginPackageSigner" + [[ -x "$signer_binary" ]] || { + print -u2 -- "Plugin package signer was not built: $signer_binary" + exit 1 + } + fi + print -rn -- "$LITHE_PLUGIN_PACKAGE_PRIVATE_KEY" | "$signer_binary" "$package_dir" + "$signer_binary" --verify "$package_dir" + fi done if (( matched == 0 )); then diff --git a/scripts/classify-ci-changes.sh b/scripts/classify-ci-changes.sh index 54a11d3ff..5339e48bd 100755 --- a/scripts/classify-ci-changes.sh +++ b/scripts/classify-ci-changes.sh @@ -389,7 +389,7 @@ while IFS=$'\t' read -r status first_path _; do rust_core=true macos_release=true ;; - scripts/build-windows.ps1|scripts/verify-windows-boundaries.ps1|scripts/verify-windows-boundaries.sh|scripts/prepare-jdtls.ps1|scripts/prepare-jdk.ps1|scripts/package-windows.ps1|scripts/install-windows-frontend-dependencies.ps1|scripts/invoke-windows-tauri-build.ps1|scripts/create-windows-updater-manifest.ps1|scripts/test-windows-updater-manifest.ps1) + scripts/build-windows.ps1|scripts/verify-windows-boundaries.ps1|scripts/verify-windows-boundaries.sh|scripts/prepare-jdtls.ps1|scripts/prepare-jdk.ps1|scripts/package-windows.ps1|scripts/install-windows-frontend-dependencies.ps1|scripts/invoke-windows-bun-install.ps1|scripts/windows-frontend-install.test.ts|scripts/invoke-windows-tauri-build.ps1|scripts/create-windows-updater-manifest.ps1|scripts/test-windows-updater-manifest.ps1) windows=true ;; scripts/prepare-lithe-pr-review.mjs|scripts/test-prepare-lithe-pr-review.mjs|scripts/run-lithe-codex-with-timeout.sh|scripts/update-repo-charts.py) diff --git a/scripts/create-sparkle-update.sh b/scripts/create-sparkle-update.sh index 42bb8988b..510941bc6 100644 --- a/scripts/create-sparkle-update.sh +++ b/scripts/create-sparkle-update.sh @@ -39,7 +39,7 @@ else fi while IFS=$'\t' read -r tag name; do [[ -n "$tag" ]] || continue - gh release download "$tag" --repo "$GITHUB_REPOSITORY" --pattern "$name" --dir "$archives" + node scripts/download-sparkle-baseline.mjs "$GITHUB_REPOSITORY" "$tag" "$name" "$archives" done < "$temporary/baselines" if [[ "$channel" == stable ]]; then diff --git a/scripts/download-sparkle-baseline.mjs b/scripts/download-sparkle-baseline.mjs new file mode 100644 index 000000000..ffbaaf065 --- /dev/null +++ b/scripts/download-sparkle-baseline.mjs @@ -0,0 +1,117 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { mkdtemp, rename, rm, stat } from "node:fs/promises"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { fileURLToPath } from "node:url"; + +const MAX_ATTEMPTS = 3; +const ATTEMPT_TIMEOUT_MS = 120_000; +const RETRY_DELAY_MS = 2_000; + +// gh has no per-download deadline. Own its process group so timeout/cancellation +// finishes the attempt before its partial archive is removed or retried. +export function runDownload(command, args, { signal, timeoutMs }) { + signal?.throwIfAborted(); + return new Promise((resolve, reject) => { + const child = spawn(command, args, { detached: true, stdio: ["ignore", "ignore", "pipe"] }); + let stderr = ""; + let timedOut = false; + const stop = () => { + if (child.pid) { + try { process.kill(-child.pid, "SIGKILL"); } + catch (error) { if (error.code !== "ESRCH") reject(error); } + } + }; + const timer = setTimeout(() => { timedOut = true; stop(); }, timeoutMs); + const cleanup = () => { + clearTimeout(timer); + signal?.removeEventListener("abort", stop); + }; + signal?.addEventListener("abort", stop, { once: true }); + if (signal?.aborted) stop(); + child.stderr.on("data", (data) => { stderr = (stderr + data.toString()).slice(-16_384); }); + child.once("error", (error) => { cleanup(); reject(error); }); + child.once("close", (code, terminationSignal) => { + cleanup(); + if (signal?.aborted) reject(signal.reason); + else resolve({ code, stderr, timedOut, terminationSignal }); + }); + }); +} + +function isTransient(result) { + if (result.timedOut) return true; + if (result.terminationSignal || [130, 143].includes(result.code)) return false; + const status = /\bHTTP\s+(\d{3})\b/i.exec(result.stderr); + if (status) return /^(408|429|5\d\d)$/.test(status[1]); + return /\b(i\/o timeout|connection reset|connection refused|TLS handshake timeout|temporary failure|unexpected EOF|network is unreachable)\b/i.test(result.stderr); +} + +export async function downloadSparkleBaseline({ repository, tag, name, archives }, { + signal, + run = runDownload, + sleep = (milliseconds) => delay(milliseconds, undefined, { signal }), + log = (message) => console.error(message), +} = {}) { + // Only the selector's exact archive name may be promoted into Sparkle's input. + if (!/^Lithe-(?:\d+\.\d+\.\d+|preview-\d+\.\d+)-(?:arm64|x86_64)\.zip$/.test(name)) { + throw new Error("Invalid Sparkle baseline archive name"); + } + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) { + signal?.throwIfAborted(); + const staging = await mkdtemp(path.join(archives, ".baseline-")); + const destination = path.join(archives, name); + let promoted = false; + let result; + try { + result = await run("gh", ["release", "download", tag, "--repo", repository, + "--pattern", name, "--dir", staging], { signal, timeoutMs: ATTEMPT_TIMEOUT_MS }); + signal?.throwIfAborted(); + if (result.code === 0 && !result.timedOut && !result.terminationSignal) { + const downloaded = path.join(staging, name); + const info = await stat(downloaded); + if (!info.isFile() || info.size === 0) throw new Error(`Empty Sparkle baseline: ${tag}/${name}`); + signal?.throwIfAborted(); + await rename(downloaded, destination); + promoted = true; + signal?.throwIfAborted(); + return; + } + } catch (error) { + if (promoted) await rm(destination, { force: true }); + throw error; + } finally { + await rm(staging, { recursive: true, force: true }); + } + const reason = result.timedOut ? "download timed out" : result.stderr.trim() || `gh exited ${result.code}`; + if (attempt === MAX_ATTEMPTS || !isTransient(result)) { + throw new Error(`Sparkle baseline ${tag}/${name} failed after ${attempt} attempt(s): ${reason}`); + } + log(`Retrying Sparkle baseline ${tag}/${name} (${attempt}/${MAX_ATTEMPTS}): ${reason}`); + await sleep(RETRY_DELAY_MS * (2 ** (attempt - 1))); + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const cancellation = new AbortController(); + const cancel = (signal) => cancellation.abort(new Error(`Baseline download cancelled by ${signal}`)); + const interrupt = () => cancel("SIGINT"); + const terminate = () => cancel("SIGTERM"); + process.once("SIGINT", interrupt); + process.once("SIGTERM", terminate); + try { + const [repository, tag, name, archives] = process.argv.slice(2); + if (!repository || !tag || !name || !archives || process.argv.length !== 6) { + throw new Error("Usage: download-sparkle-baseline.mjs "); + } + await downloadSparkleBaseline({ repository, tag, name, archives }, { signal: cancellation.signal }); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } finally { + process.removeListener("SIGINT", interrupt); + process.removeListener("SIGTERM", terminate); + } +} diff --git a/scripts/generate-platform-feature-matrix.mjs b/scripts/generate-platform-feature-matrix.mjs index e661b7045..254de2d43 100755 --- a/scripts/generate-platform-feature-matrix.mjs +++ b/scripts/generate-platform-feature-matrix.mjs @@ -1,14 +1,25 @@ #!/usr/bin/env node -import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; const root = resolve(import.meta.dirname, ".."); -const sourcePath = resolve(root, "shared/platform-feature-matrix.json"); -const outputPath = resolve(root, "docs/development/platform-parity-matrix.md"); -const csvOutputPath = resolve(root, "docs/development/platform-parity-matrix.csv"); +const sourcePath = resolve(root, "shared/platform-feature-matrix"); +const outputIndex = process.argv.indexOf("--output-dir"); +if (outputIndex !== -1 && !process.argv[outputIndex + 1]) throw new Error("--output-dir requires a path"); +const outputDir = resolve(root, outputIndex === -1 ? ".artifacts/platform-feature-matrix" : process.argv[outputIndex + 1]); +const outputPath = resolve(outputDir, "platform-parity-matrix.md"); +const csvOutputPath = resolve(outputDir, "platform-parity-matrix.csv"); const checkOnly = process.argv.includes("--check"); -const source = JSON.parse(readFileSync(sourcePath, "utf8")); +const source = JSON.parse(readFileSync(resolve(sourcePath, "metadata.json"), "utf8")); +if (source.schemaVersion !== 4 || Object.hasOwn(source, "features")) throw new Error("expected split matrix schema version 4"); +const featureDir = resolve(sourcePath, "features"); +source.features = readdirSync(featureDir).sort().map((name) => { + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*\.json$/.test(name)) throw new Error(`invalid feature filename: ${name}`); + const feature = JSON.parse(readFileSync(resolve(featureDir, name), "utf8")); + if (`${feature.id}.json` !== name) throw new Error(`feature ID must match filename: ${name}`); + return feature; +}); const statusDefinitions = source.statusDefinitions; const implementationDefinitions = statusDefinitions?.implementation; const verificationDefinitions = statusDefinitions?.verification; @@ -95,7 +106,7 @@ const areaSections = areaGroups.flatMap(({ area, features: areaFeatures }) => [ const markdown = [ "# macOS / Windows 功能对齐矩阵", "", - "> 本页由 `shared/platform-feature-matrix.json` 自动生成。不要直接编辑本文件;新增或变更功能时更新源数据,再运行 `node scripts/generate-platform-feature-matrix.mjs`。", + "> 本页由 `shared/platform-feature-matrix/features/*.json` 自动生成。不要直接编辑本文件;新增或变更功能时更新源数据,再运行 `node scripts/generate-platform-feature-matrix.mjs`。", "", `- 最后复核:${source.lastReviewed}`, `- 盘点状态:${source.review.status}(${source.review.method})`, @@ -148,15 +159,24 @@ const csvRows = features.map((feature) => [ ]); const csv = [csvHeader, ...csvRows].map((row) => row.map(escapeCsv).join(",")).join("\n") + "\n"; +// Escape every source string: PR-controlled capability text must remain inert in published HTML. +const escapeHtml = (value) => value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); +const renderPlatformHtml = (entry) => `${escapeHtml(implementationDefinitions[entry.implementationStatus].label)}
${escapeHtml(verificationDefinitions[entry.verificationStatus].label)}
证据路径
${escapeHtml(entry.evidence.join("\n"))}
`; +const html = `Lithe 功能矩阵 + +

Lithe 功能矩阵

数据版本:${escapeHtml(process.env.GITHUB_SHA ?? "本地工作树")}

+

Markdown · CSV · JSON

+

macOS:${escapeHtml(renderCounts("macos"))}

Windows:${escapeHtml(renderCounts("windows"))}

+

实现状态与运行验证相互独立;代码存在不代表已通过实机验收。

+
+${features.map((feature) => ``).join("\n")}
分类能力macOSWindows负责人验证方式 / 备注
${escapeHtml(feature.area)} / ${escapeHtml(feature.group)}${escapeHtml(feature.capability)}
${escapeHtml(feature.id)}
${renderPlatformHtml(feature.macos)}${renderPlatformHtml(feature.windows)}${escapeHtml(feature.owner)}${escapeHtml(feature.verification)}
${escapeHtml(feature.notes ?? "")}
\n`; if (checkOnly) { - const existingMarkdown = readFileSync(outputPath, "utf8"); - const existingCsv = readFileSync(csvOutputPath, "utf8"); - if (existingMarkdown !== markdown) throw new Error(`generated Markdown is stale: ${outputPath}`); - if (existingCsv !== csv) throw new Error(`generated CSV is stale: ${csvOutputPath}`); - console.log("platform feature matrix is up to date"); + console.log(`validated ${features.length} platform capabilities`); } else { + mkdirSync(outputDir, { recursive: true }); writeFileSync(outputPath, markdown); writeFileSync(csvOutputPath, csv); - console.log(`generated ${outputPath}`); - console.log(`generated ${csvOutputPath}`); + writeFileSync(resolve(outputDir, "platform-feature-matrix.json"), JSON.stringify(source, null, 2) + "\n"); + writeFileSync(resolve(outputDir, "index.html"), html); + console.log(`generated matrix views in ${outputDir}`); } diff --git a/scripts/install-windows-frontend-dependencies.ps1 b/scripts/install-windows-frontend-dependencies.ps1 index fed03f1cc..e5c256068 100644 --- a/scripts/install-windows-frontend-dependencies.ps1 +++ b/scripts/install-windows-frontend-dependencies.ps1 @@ -1,5 +1,5 @@ [CmdletBinding()] -param() +param([ValidateRange(1, 1200)][int]$InstallTimeoutSeconds = 300) $ErrorActionPreference = "Stop" $root = Split-Path -Parent $PSScriptRoot @@ -9,6 +9,27 @@ $expectedVersion = ([string]$package.packageManager) -replace '^bun@', '' $bunCache = [System.IO.Path]::GetFullPath((Join-Path $root ".artifacts/bun-cache")) $bunTemp = [System.IO.Path]::GetFullPath((Join-Path $root ".artifacts/bun-tmp")) $dependencyPaths = @("node_modules", "windows/tauri/node_modules", "frontend/editor/node_modules") | ForEach-Object { Join-Path $root $_ } +$powerShell = (Get-Process -Id $PID).Path +$installWorker = Join-Path $PSScriptRoot "invoke-windows-bun-install.ps1" + +function Remove-InstallPath { + param([string]$Path) + + $deadline = [Diagnostics.Stopwatch]::StartNew() + while (Test-Path -LiteralPath $Path) { + try { + Remove-Item -Recurse -Force -LiteralPath $Path -ErrorAction Stop + return + } catch { + if ($deadline.Elapsed.TotalSeconds -ge 10) { + throw "Cannot clean installation data after releasing its process tree: $Path. $($_.Exception.Message)" + } + # Native termination and Windows filesystem filters can release + # locks just after the worker exits. This retry has a local deadline. + Start-Sleep -Milliseconds 100 + } + } +} function Write-CacheWarning { param([string]$Message, [string]$Title = "Bun cache fallback") @@ -44,7 +65,7 @@ $env:BUN_INSTALL_CACHE_DIR = $bunCache $env:BUN_TMPDIR = $bunTemp $env:BUN_FEATURE_FLAG_DISABLE_INSTALL_INDEX = "1" -if (Test-Path -LiteralPath $bunTemp) { Remove-Item -Recurse -Force -LiteralPath $bunTemp } +Remove-InstallPath $bunTemp New-Item -ItemType Directory -Force -Path $bunCache, $bunTemp | Out-Null if ($null -eq (Get-Command bun -ErrorAction SilentlyContinue)) { @@ -58,20 +79,21 @@ if ($LASTEXITCODE -ne 0 -or $actualVersion -ne $expectedVersion) { Push-Location $root try { - & bun install --frozen-lockfile + & $powerShell -NoProfile -ExecutionPolicy Bypass -File $installWorker -TimeoutSeconds $InstallTimeoutSeconds if ($LASTEXITCODE -ne 0) { if ($env:LITHE_BUN_CACHE_VERIFIED -eq "true") { Write-CacheWarning "The verified Bun cache could not complete installation. Clearing it and retrying with ordinary downloads." } else { Write-CacheWarning "The initial Bun install failed. Clearing partial data and retrying with ordinary downloads." } - if (Test-Path -LiteralPath $bunCache) { Remove-Item -Recurse -Force -LiteralPath $bunCache } - if (Test-Path -LiteralPath $bunTemp) { Remove-Item -Recurse -Force -LiteralPath $bunTemp } + $env:LITHE_BUN_CACHE_VERIFIED = "false" + Remove-InstallPath $bunCache + Remove-InstallPath $bunTemp foreach ($dependencyPath in $dependencyPaths) { - if (Test-Path -LiteralPath $dependencyPath) { Remove-Item -Recurse -Force -LiteralPath $dependencyPath } + Remove-InstallPath $dependencyPath } New-Item -ItemType Directory -Force -Path $bunCache, $bunTemp | Out-Null - & bun install --frozen-lockfile --no-cache + & $powerShell -NoProfile -ExecutionPolicy Bypass -File $installWorker -NoCache -TimeoutSeconds $InstallTimeoutSeconds if ($LASTEXITCODE -ne 0) { throw "Windows frontend dependency installation failed after a clean retry." } @@ -95,7 +117,7 @@ try { } if (-not $cacheSealed) { Write-CacheWarning "The Bun download cache could not be sealed safely. Discarding it and continuing with the installed dependencies." - if (Test-Path -LiteralPath $bunCache) { Remove-Item -Recurse -Force -LiteralPath $bunCache } + Remove-InstallPath $bunCache if ($null -ne $env:GITHUB_ENV) { "LITHE_BUN_CACHE_VERIFIED=false" >> $env:GITHUB_ENV } } } diff --git a/scripts/invoke-windows-bun-install.ps1 b/scripts/invoke-windows-bun-install.ps1 new file mode 100644 index 000000000..af896ebcf --- /dev/null +++ b/scripts/invoke-windows-bun-install.ps1 @@ -0,0 +1,94 @@ +[CmdletBinding()] +param( + [switch]$NoCache, + [ValidateRange(1, 1200)][int]$TimeoutSeconds = 300 +) + +$ErrorActionPreference = "Stop" +if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { + throw "The Bun install worker requires Windows Job Objects." +} + +# Own the worker before Bun can spawn lifecycle scripts. The non-inheritable +# handle belongs to this worker process; Windows closes it at process exit and +# kills every remaining descendant, even when Bun has already exited on error. +# Do not dispose the handle in-process: the worker itself is a job member. +Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Threading; + +public static class LitheBunInstallJob { + static Timer deadline; + const uint KillOnJobClose = 0x2000; + const int ExtendedLimitInformation = 9; + + [StructLayout(LayoutKind.Sequential)] + struct BasicLimits { + public long PerProcessUserTime, PerJobUserTime; + public uint LimitFlags; + public UIntPtr MinimumWorkingSet, MaximumWorkingSet; + public uint ActiveProcessLimit; + public UIntPtr Affinity; + public uint PriorityClass, SchedulingClass; + } + + [StructLayout(LayoutKind.Sequential)] + struct IoCounters { + public ulong ReadOperations, WriteOperations, OtherOperations; + public ulong ReadBytes, WriteBytes, OtherBytes; + } + + [StructLayout(LayoutKind.Sequential)] + struct ExtendedLimits { + public BasicLimits Basic; + public IoCounters Io; + public UIntPtr ProcessMemory, JobMemory, PeakProcessMemory, PeakJobMemory; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + static extern IntPtr CreateJobObject(IntPtr securityAttributes, string name); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool SetInformationJobObject(IntPtr job, int kind, ref ExtendedLimits limits, uint size); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process); + [DllImport("kernel32.dll")] + static extern IntPtr GetCurrentProcess(); + [DllImport("kernel32.dll")] + static extern bool CloseHandle(IntPtr handle); + + public static IntPtr AttachWorker() { + IntPtr job = CreateJobObject(IntPtr.Zero, null); + if (job == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error()); + var limits = new ExtendedLimits(); + limits.Basic.LimitFlags = KillOnJobClose; + if (!SetInformationJobObject(job, ExtendedLimitInformation, ref limits, (uint)Marshal.SizeOf(limits)) || + !AssignProcessToJobObject(job, GetCurrentProcess())) { + int error = Marshal.GetLastWin32Error(); + CloseHandle(job); + throw new Win32Exception(error, "Cannot own the Bun installation process tree."); + } + return job; + } + + public static void StartDeadline(int seconds) { + deadline = new Timer(_ => { + Console.Error.WriteLine("Bun installation exceeded its " + seconds + " second deadline."); + Environment.Exit(124); + }, null, seconds * 1000, Timeout.Infinite); + } + + public static void FinishDeadline() { deadline.Dispose(); } +} +'@ + +$installJob = [LitheBunInstallJob]::AttachWorker() +[LitheBunInstallJob]::StartDeadline($TimeoutSeconds) +$arguments = @("install", "--frozen-lockfile") +if ($NoCache) { $arguments += "--no-cache" } +& bun @arguments +$installExitCode = $LASTEXITCODE +[LitheBunInstallJob]::FinishDeadline() +[GC]::KeepAlive($installJob) +exit $installExitCode diff --git a/scripts/reuse-worktree-resources.mjs b/scripts/reuse-worktree-resources.mjs index 69d5700a1..1b7602904 100644 --- a/scripts/reuse-worktree-resources.mjs +++ b/scripts/reuse-worktree-resources.mjs @@ -169,6 +169,8 @@ async function validatorArguments(resource, cachePath, targetRoot) { ]; } if (resource.validator === "bun") { + // This route validates sealed downloads only. Job-owned installation temp + // data and node_modules stay in the registry's non-reusable exclusions. const packagePath = await requiredFile(targetRoot, "package.json", resource.id); const packageManifest = JSON.parse(await fs.readFile(packagePath, "utf8")); const match = String(packageManifest.packageManager ?? "").match(/^bun@(.+)$/); @@ -366,8 +368,8 @@ async function main() { const selected = options.resources.length > 0 ? options.resources.map((identifier) => { - // Runtime snapshots, per-execution Java launch files and isolated plugin - // packages take this rejection route, never a content-hash validator. + // Generated matrix views, runtime snapshots, per-execution Java launch files, Sparkle baseline + // staging and isolated plugin packages take this rejection route, never a content-hash validator. // Identical bytes do not establish transferable execution ownership. if (excludedResources.some((resource) => resource.id === identifier)) { const excluded = excludedResources.find((resource) => resource.id === identifier); diff --git a/scripts/test-download-sparkle-baseline.mjs b/scripts/test-download-sparkle-baseline.mjs new file mode 100644 index 000000000..a4ae02529 --- /dev/null +++ b/scripts/test-download-sparkle-baseline.mjs @@ -0,0 +1,217 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import { watch } from "node:fs"; +import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { downloadSparkleBaseline, runDownload } from "./download-sparkle-baseline.mjs"; + +const name = "Lithe-0.5.10-arm64.zip"; +const success = { code: 0, stderr: "", timedOut: false }; +const failure = (stderr) => ({ code: 1, stderr, timedOut: false }); +const scheduleDeadline = globalThis.setTimeout; +const clearDeadline = globalThis.clearTimeout; + +async function fixture(t, archive = name) { + const archives = await mkdtemp(path.join(os.tmpdir(), "lithe-baseline-test-")); + t.after(() => rm(archives, { recursive: true, force: true })); + return { repository: "example/lithe", tag: "v0.5.10", name: archive, archives }; +} + +function downloader(outcomes, { beforeResult } = {}) { + const calls = []; + const waits = []; + return { + calls, waits, + sleep: async (ms) => { waits.push(ms); }, + log: () => {}, + run: async (command, args, options) => { + const staging = args.at(-1); + assert.deepEqual(await readdir(staging), [], "Every attempt must start empty"); + const index = calls.length; + calls.push({ command, args, options, staging }); + const result = outcomes[index]; + assert.ok(result, "No attempts beyond the supplied outcomes"); + await writeFile(path.join(staging, args[args.indexOf("--pattern") + 1]), + result.code === 0 ? "complete archive" : "partial archive"); + await beforeResult?.(index); + return result; + }, + }; +} + +test("transient HTTP failures retry in clean staging and promote only the complete archive", { timeout: 5000 }, async (t) => { + const input = await fixture(t); + const stub = downloader([failure("HTTP 500 (GitHub asset)"), failure("HTTP 503"), success]); + await downloadSparkleBaseline(input, stub); + assert.equal(await readFile(path.join(input.archives, name), "utf8"), "complete archive"); + assert.deepEqual(await readdir(input.archives), [name]); + assert.deepEqual(stub.waits, [2000, 4000]); + assert.equal(new Set(stub.calls.map((call) => call.staging)).size, 3); + for (const call of stub.calls) { + assert.equal(call.command, "gh"); + assert.deepEqual(call.args.slice(0, -1), ["release", "download", "v0.5.10", "--repo", "example/lithe", "--pattern", name, "--dir"]); + assert.equal(call.options.timeoutMs, 120_000); + } +}); + +test("exhausted failures stay fatal with three attempts and no partial inputs", { timeout: 5000 }, async (t) => { + const input = await fixture(t); + const stub = downloader(Array(3).fill(failure("HTTP 500"))); + await assert.rejects(downloadSparkleBaseline(input, stub), /failed after 3 attempt\(s\): HTTP 500/); + assert.equal(stub.calls.length, 3); + assert.deepEqual(stub.waits, [2000, 4000]); + assert.deepEqual(await readdir(input.archives), []); +}); + +for (const stderr of ["HTTP 401", "HTTP 403", "HTTP 404", "no assets match the release", "unknown failure"]) { + test(`${stderr} fails immediately without fallback`, { timeout: 5000 }, async (t) => { + const input = await fixture(t); + const stub = downloader([failure(stderr)]); + await assert.rejects(downloadSparkleBaseline(input, stub), /failed after 1 attempt/); + assert.equal(stub.calls.length, 1); + assert.deepEqual(stub.waits, []); + assert.deepEqual(await readdir(input.archives), []); + }); +} + +for (const result of [failure("HTTP 408"), failure("HTTP 429"), failure("read: connection reset by peer"), + { ...failure(""), timedOut: true }]) { + test(`${result.stderr || "attempt timeout"} can recover`, { timeout: 5000 }, async (t) => { + const input = await fixture(t, "Lithe-preview-45.1-x86_64.zip"); + const stub = downloader([result, success]); + await downloadSparkleBaseline(input, stub); + assert.equal(stub.calls.length, 2); + assert.deepEqual(await readdir(input.archives), [input.name]); + }); +} + +test("timeouts are bounded even if a killed command reports exit zero", { timeout: 5000 }, async (t) => { + const input = await fixture(t); + const stub = downloader(Array(3).fill({ ...success, timedOut: true })); + await assert.rejects(downloadSparkleBaseline(input, stub), /failed after 3 attempt\(s\): download timed out/); + assert.deepEqual(await readdir(input.archives), []); +}); + +test("an empty or missing successful download fails without publishing", { timeout: 5000 }, async (t) => { + for (const empty of [true, false]) { + const input = await fixture(t); + const run = async (_command, args) => { + if (empty) await writeFile(path.join(args.at(-1), name), ""); + return success; + }; + await assert.rejects(downloadSparkleBaseline(input, { run }), empty ? /Empty Sparkle baseline/ : /ENOENT/); + assert.deepEqual(await readdir(input.archives), []); + } +}); + +test("cancellation before promotion cleans the attempt and never retries", { timeout: 5000 }, async (t) => { + const input = await fixture(t); + const cancellation = new AbortController(); + const stub = downloader([success], { beforeResult: () => cancellation.abort(new Error("cancelled")) }); + await assert.rejects(downloadSparkleBaseline(input, { ...stub, signal: cancellation.signal }), /cancelled/); + assert.deepEqual(stub.waits, []); + assert.deepEqual(await readdir(input.archives), []); +}); + +test("cancellation during backoff never starts another attempt", { timeout: 5000 }, async (t) => { + const input = await fixture(t); + const cancellation = new AbortController(); + const stub = downloader([failure("HTTP 500")]); + await assert.rejects(downloadSparkleBaseline(input, { + ...stub, signal: cancellation.signal, + sleep: async () => { cancellation.abort(new Error("cancelled during backoff")); }, + }), /cancelled during backoff/); + assert.equal(stub.calls.length, 1); + assert.deepEqual(await readdir(input.archives), []); +}); + +for (const termination of [{ terminationSignal: "SIGTERM" }, { code: 130 }, { code: 143 }]) { +test(`cancelled commands (${JSON.stringify(termination)}) are fatal even with transient stderr`, { timeout: 5000 }, async (t) => { + const input = await fixture(t); + const stub = downloader([{ ...failure("HTTP 500"), ...termination }]); + await assert.rejects(downloadSparkleBaseline(input, stub), /failed after 1 attempt/); + assert.deepEqual(stub.waits, []); + assert.deepEqual(await readdir(input.archives), []); +}); +} + +test("spawn errors clean staging and never retry", { timeout: 5000 }, async (t) => { + const input = await fixture(t); + await assert.rejects(downloadSparkleBaseline(input, { run: async () => { throw new Error("spawn failed"); } }), /spawn failed/); + assert.deepEqual(await readdir(input.archives), []); +}); + +test("unsafe archive paths are rejected before spawning", { timeout: 5000 }, async (t) => { + const input = await fixture(t, "../baseline.zip"); + await assert.rejects(downloadSparkleBaseline(input, { run: () => assert.fail("must not spawn") }), /Invalid Sparkle baseline/); + assert.deepEqual(await readdir(input.archives), []); +}); + +async function blockedDownload(t) { + const archives = await mkdtemp(path.join(os.tmpdir(), "lithe-baseline-process-test-")); + const readyFile = path.join(archives, "ready"); + const cancellation = new AbortController(); + let pid; + let watcher; + let deadline; + // Readiness is an atomic file event, not a speed-dependent sleep. Teardown + // can kill the fixture independently if the production cancellation regresses. + const ready = new Promise((resolve, reject) => { + deadline = scheduleDeadline(() => reject(new Error("Download fixture did not become ready")), 3000); + watcher = watch(archives, async (_event, filename) => { + if (filename !== "ready") return; + try { + pid = Number(await readFile(readyFile, "utf8")); + watcher.close(); + clearDeadline(deadline); + resolve(); + } catch (error) { reject(error); } + }); + watcher.on("error", reject); + }); + t.after(async () => { + cancellation.abort(new Error("Test cleanup")); + watcher.close(); + clearDeadline(deadline); + if (!pid) pid = Number(await readFile(readyFile, "utf8").catch(() => "0")); + if (pid) { + try { process.kill(-pid, "SIGKILL"); } + catch (error) { if (error.code !== "ESRCH") throw error; } + } + await rm(archives, { recursive: true, force: true }); + }); + const script = "const fs = require('node:fs'); const file = process.argv[1]; " + + "fs.writeFileSync(file + '.tmp', String(process.pid)); fs.renameSync(file + '.tmp', file); setInterval(() => {}, 1000);"; + const result = runDownload(process.execPath, ["-e", script, readyFile], + { timeoutMs: 120_000, signal: cancellation.signal }); + result.catch(() => {}); // The assertion consumes it after fixture readiness. + await ready; + return { result, cancellation }; +} + +test("the real subprocess deadline kills and reaps a stuck download", { timeout: 5000 }, async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const { result } = await blockedDownload(t); + t.mock.timers.tick(120_000); + const completed = await result; + assert.equal(completed.timedOut, true); + assert.equal(completed.terminationSignal, "SIGKILL"); +}); + +test("the real subprocess is stopped and reaped on cancellation", { timeout: 5000 }, async (t) => { + const { result, cancellation } = await blockedDownload(t); + cancellation.abort(new Error("cancelled subprocess")); + await assert.rejects(result, /cancelled subprocess/); +}); + +test("stable release budgets leave room beyond compilation and bound Sparkle", { timeout: 5000 }, async () => { + const workflow = await readFile(new URL("../.github/workflows/release-macos.yml", import.meta.url), "utf8"); + assert.match(workflow, /name: Build \$\{\{ matrix.architecture \}\} release[\s\S]*?timeout-minutes: 75/); + assert.match(workflow, /name: Build and package the App\n\s+timeout-minutes: 35/); + assert.match(workflow, /name: Generate signed Sparkle updates\n\s+timeout-minutes: 30/); + const packaging = await readFile(new URL("./create-sparkle-update.sh", import.meta.url), "utf8"); + assert.match(packaging, /node scripts\/download-sparkle-baseline\.mjs "\$GITHUB_REPOSITORY" "\$tag" "\$name" "\$archives"/); +}); diff --git a/scripts/test-platform-feature-matrix.mjs b/scripts/test-platform-feature-matrix.mjs new file mode 100644 index 000000000..203992af1 --- /dev/null +++ b/scripts/test-platform-feature-matrix.mjs @@ -0,0 +1,117 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { cpSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import test from "node:test"; + +const repository = resolve(import.meta.dirname, ".."); +function fixture(t) { + const root = mkdtempSync(resolve(tmpdir(), "lithe-matrix-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + mkdirSync(resolve(root, "scripts")); + for (const name of ["generate-platform-feature-matrix.mjs", "verify-platform-feature-matrix-change.mjs"]) { + cpSync(resolve(repository, "scripts", name), resolve(root, "scripts", name)); + } + const directory = resolve(root, "shared/platform-feature-matrix"); + mkdirSync(resolve(directory, "features"), { recursive: true }); + cpSync(resolve(repository, "shared/platform-feature-matrix/metadata.json"), resolve(directory, "metadata.json")); + writeFileSync(resolve(root, "evidence.txt"), "fixture"); + const entry = { implementationStatus: "implemented", verificationStatus: "pending", evidence: ["evidence.txt"] }; + const feature = { id: "sample", area: "Test", group: "Test", capability: "Sample", owner: "Test", verification: "Run fixture", macos: entry, windows: entry }; + const save = (value = feature, name = "sample") => writeFileSync(resolve(directory, "features", `${name}.json`), JSON.stringify(value, null, 2) + "\n"); + save(); + const run = (script, args = [], env = {}) => spawnSync(process.execPath, [resolve(root, "scripts", script), ...args], { + cwd: root, encoding: "utf8", timeout: 5_000, env: { ...process.env, MATRIX_UPDATE_EXEMPT: "false", ...env }, + }); + const git = (...args) => execFileSync("git", args, { cwd: root, encoding: "utf8", timeout: 5_000 }); + return { root, directory, feature, save, run, git }; +} +const succeeded = (result) => { + assert.ifError(result.error); + assert.equal(result.status, 0, result.stderr); +}; +const failed = (result, message) => { + assert.ifError(result.error); + assert.notEqual(result.status, 0); + assert.match(result.stderr, message); +}; + +test("generation combines independent capabilities deterministically and escapes HTML", { timeout: 15_000 }, (t) => { + const f = fixture(t); + f.save({ ...f.feature, id: "another", capability: '' }, "another"); + succeeded(f.run("generate-platform-feature-matrix.mjs")); + const out = resolve(f.root, ".artifacts/platform-feature-matrix"); + const source = JSON.parse(readFileSync(resolve(out, "platform-feature-matrix.json"))); + assert.deepEqual(source.features.map(({ id }) => id), ["another", "sample"]); + assert.deepEqual(source.features[1], f.feature); + const html = readFileSync(resolve(out, "index.html"), "utf8"); + assert.ok(html.includes("<script>")); + assert.ok(!html.includes("